Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand All @@ -43,7 +43,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
Comment thread
mkmeral marked this conversation as resolved.
runs-on: ${{ matrix.os }}
steps:
Expand All @@ -62,17 +62,32 @@ jobs:
with:
python-version: ${{ matrix.python }}

# Create the venv and put its bin/Scripts dir on PATH so the
# subsequent steps work identically on Unix (.venv/bin) and
# Windows (.venv/Scripts).
- name: Create virtualenv
run: python -m venv .venv
shell: bash
run: |
python -m venv .venv
# GITHUB_PATH entries must be native paths. In Git Bash on Windows
# $PWD is an MSYS path (/d/a/shell/shell) that the runner's Windows
# PATH cannot resolve, so the venv would be silently ignored and
# later steps would fall back to the host interpreter. Convert to a
# native Windows path with cygpath so .venv\Scripts is actually used.
if [ "$RUNNER_OS" == "Windows" ]; then
cygpath -w "$PWD/.venv/Scripts" >> "$GITHUB_PATH"
else
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
fi

- name: Install build deps
run: .venv/bin/pip install maturin pytest
run: pip install maturin pytest

- name: Build and install wheel
run: .venv/bin/maturin develop --release
run: maturin develop --release

- name: pytest
run: .venv/bin/pytest tests/python -v
run: pytest tests/python -v

audit:
name: Security audit
Expand Down Expand Up @@ -115,7 +130,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22", "24"]
runs-on: ${{ matrix.os }}
steps:
Expand All @@ -134,6 +149,15 @@ jobs:
with:
node-version: ${{ matrix.node }}

# The package.json build scripts use `$(npm run --silent host-triple)`
# command substitution. npm runs script bodies with its configured
# script-shell, which defaults to cmd.exe on Windows (no `$(...)`
# support). Point it at the Git Bash that ships on the windows-latest
# runner so the substitution works identically across platforms.
- name: Use bash as npm script-shell (Windows)
if: runner.os == 'Windows'
run: npm config set script-shell bash

- name: npm install
# package-lock.json is gitignored, so `npm ci` can't run; use install.
run: npm install
Expand Down
22 changes: 14 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
manylinux: "2_28"
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -158,7 +160,7 @@ jobs:
set -euo pipefail
echo "Dists:"; ls -1 dist/
missing=()
for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-msvc; do
# Maturin encodes the target in the wheel filename's platform tag:
# aarch64-apple-darwin → macosx_*_arm64
# x86_64-apple-darwin → macosx_*_x86_64
Expand All @@ -169,6 +171,7 @@ jobs:
x86_64-apple-darwin) pattern="macosx_*_x86_64" ;;
x86_64-unknown-linux-gnu) pattern="manylinux_*_x86_64" ;;
aarch64-unknown-linux-gnu) pattern="manylinux_*_aarch64" ;;
x86_64-pc-windows-msvc) pattern="win_amd64" ;;
esac
if ! ls dist/*${pattern}*.whl >/dev/null 2>&1; then
missing+=("$target")
Expand Down Expand Up @@ -228,6 +231,8 @@ jobs:
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -285,9 +290,9 @@ jobs:
native.js
native.d.ts

# ── Pack all 5 npm packages (fan-in from the per-platform builds) ─────────
# ── Pack all 6 npm packages (fan-in from the per-platform builds) ─────────
# Assembles the exact publishable set — the main @strands-agents/shell package
# plus the 4 per-platform packages — using the napi-rs v3 release flow, then
# plus the 5 per-platform packages — using the napi-rs v3 release flow, then
# packs each to a .tgz and uploads them. Download the `npm-packages` artifact
# to install/test the real tarballs locally before any publish happens.
node-pack:
Expand Down Expand Up @@ -346,18 +351,18 @@ jobs:
for d in npm/*/; do (cd "$d" && npm pack --pack-destination "$GITHUB_WORKSPACE/dist-npm"); done

- name: Verify the packaged set matches the publish matrix
# Guard against drift: the publish stage uses a static matrix of the 4
# platform packages (+ the main package = 5 total). If package.json's
# Guard against drift: the publish stage uses a static matrix of the 5
# platform packages (+ the main package = 6 total). If package.json's
# napi.targets gains/loses a target, the packed count changes here and
# this fails the run BEFORE anything is published — prompting whoever
# changed the targets to update node-publish-platform's matrix to match.
run: |
set -euo pipefail
expected=5
expected=6
Comment thread
agent-of-mkmeral marked this conversation as resolved.
actual=$(ls dist-npm/*.tgz | wc -l | tr -d ' ')
echo "Packed tarballs ($actual):"; ls -1 dist-npm/*.tgz
if [ "$actual" -ne "$expected" ]; then
echo "::error::Expected $expected npm packages (1 main + 4 platform) but packed $actual. If napi.targets changed, update node-publish-platform's matrix to match."
echo "::error::Expected $expected npm packages (1 main + 5 platform) but packed $actual. If napi.targets changed, update node-publish-platform's matrix to match."
exit 1
fi

Expand All @@ -374,7 +379,7 @@ jobs:
name: npm-packages
path: dist-npm/*.tgz

# ── Publish the 4 per-platform packages (one independently retriable job each)
# ── Publish the 5 per-platform packages (one independently retriable job each)
# Each leg publishes exactly one tarball that node-inspect packed + uploaded,
# so a flaky single platform can be re-run on its own via "Re-run failed jobs"
# without touching the others. The matrix is static — its entries must match
Expand All @@ -401,6 +406,7 @@ jobs:
- strands-agents-shell-darwin-arm64
- strands-agents-shell-linux-x64-gnu
- strands-agents-shell-linux-arm64-gnu
- strands-agents-shell-win32-x64-msvc
env:
V: ${{ needs.version.outputs.version }}
steps:
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu"
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc"
]
},
"engines": {
Expand Down
29 changes: 18 additions & 11 deletions src/vfs_kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,20 +121,27 @@ impl VfsKernel {
canon_base: &std::path::Path,
) -> io::Result<Fd> {
if flags.read && !flags.write {
// Open the file, then verify via /proc/self/fd that the
// opened fd still points within the bind mount. This
// eliminates the TOCTOU between canonicalize and read.
let file = std::fs::File::open(host_path)?;
use std::os::unix::io::AsRawFd;
let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
if let Ok(real) = std::fs::read_link(&fd_path)
&& !real.starts_with(canon_base)

// Defense-in-depth TOCTOU re-check (Linux-only): /proc/self/fd has
// no portable equivalent, and the canonical-path check above is the
// primary guard, so this extra layer is simply skipped elsewhere.
#[cfg(target_os = "linux")]
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"access denied: path escaped bind mount",
));
use std::os::unix::io::AsRawFd;
let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
if let Ok(real) = std::fs::read_link(&fd_path)
&& !real.starts_with(canon_base)
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"access denied: path escaped bind mount",
));
}
}
#[cfg(not(target_os = "linux"))]
let _ = canon_base; // only consumed by the Linux-only check above

use std::io::Read;
let mut data = Vec::new();
let mut file = file;
Expand Down
8 changes: 7 additions & 1 deletion tests/shell_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4277,6 +4277,9 @@ fn builder_bind_direct_readonly_host() {
}));
}

// Uses std::os::unix::fs::symlink — symlink creation differs on Windows
// (requires elevated privileges), so this escape test is Unix-only.
#[cfg(unix)]
Comment thread
mkmeral marked this conversation as resolved.
#[test]
fn bind_direct_symlink_escape_blocked() {
let (rt, local) = rt();
Expand Down Expand Up @@ -5349,7 +5352,7 @@ umask = "077"

[[bind]]
mode = "copy"
source = "{}"
source = '{}'
destination = "/workspace"
readonly = true

Expand Down Expand Up @@ -7381,6 +7384,9 @@ expect!(cmd_sleep_zero, "command sleep 0 && echo ok", "ok");

// ── dangling symlink escape prevention ──────────────────────────────

// Uses std::os::unix::fs::symlink — symlink creation differs on Windows
// (requires elevated privileges), so this escape test is Unix-only.
#[cfg(unix)]
Comment thread
mkmeral marked this conversation as resolved.
#[test]
fn bind_direct_dangling_symlink_blocked() {
let (rt, local) = rt();
Expand Down
Loading