253 Commits

Author SHA1 Message Date
shawnm-anthropic
e15986b0f3 feat(terminating-tls): Add opt-in configuration for providing CA cert and key (#247)
* Add opt-in configuration for providing CA cert and key

* Wire tlsTerminate CA loader into SandboxManager.initialize()

When network.tlsTerminate is set, initialize() loads and validates the CA
(throws on unreadable/non-PEM). reset() clears the cache. No behavior
change when tlsTerminate is unset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add tests for tlsTerminate config and loadMitmCA

- test/fixtures/tls-terminate/: committed test-only RSA-2048 self-signed CA
  (CN=srt-test-ca DO NOT TRUST, valid to 2126). README documents the
  generating openssl command.
- test/sandbox/mitm-ca.test.ts: load/cache/reset plus all throw paths
  (missing file, non-PEM, swapped cert/key) against the fixture CA.
- test/config-validation.test.ts: schema cases for network.tlsTerminate
  (optional, both paths required, non-empty).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 16:29:11 -07:00
XTY
04baa776d6 docs(README): fix typo in section concerning security limitations (#167) v0.0.50 2026-05-05 15:19:51 -07:00
Dylan Conway
9e06e804cb fix(sandbox): set CLOUDSDK_PROXY_TYPE=http (was invalid "https") (#237)
* fix(sandbox): set CLOUDSDK_PROXY_TYPE=http (was invalid "https")

gcloud rejects CLOUDSDK_PROXY_TYPE=https with:
  Invalid value for property [proxy/type]: The proxy type property
  value [https] is not valid. Possible values: [http, http_no_tunnel,
  socks4, socks5].

The proxy/type setting names the protocol the proxy itself speaks, not
the traffic it tunnels. The sandbox proxy at httpProxyPort is an HTTP
CONNECT proxy (HTTPS_PROXY is already set to http://localhost:<port>
for the same reason), so "http" is the correct value.

Adds a unit test for generateProxyEnvVars covering this.

Fixes #151

* ci: revert bun to 1.3.1

The 1.3.13 bump exposed two new flakes (linux/x86-64 reset() bridge exit
delivery; linux/arm64 http.Server listen/address race in
configurable-proxy-ports). Reverting to 1.3.1 until those are addressed
upstream. The spawnAsync test changes from #243 remain — they are
correct on both versions.
2026-05-05 15:15:35 -07:00
Dylan Conway
06b5e5b87c fix(cli): make --debug flag set SRT_DEBUG (was setting DEBUG) (#238)
logForDebugging() reads SRT_DEBUG (intentionally not DEBUG, to avoid
clashing with the npm `debug` package and IDE tooling), but the --debug
CLI flag was setting process.env.DEBUG, so the flag had no effect.

Adds a CLI test that exercises the flag itself rather than the env var.

Fixes #174
2026-05-05 13:58:26 -07:00
Dylan Conway
c7e40bf04d fix(sandbox): only require ripgrep on Linux in checkDependencies() (#241)
ripgrep is used by linuxGetMandatoryDenyPaths() to expand glob deny
patterns into concrete paths for bwrap. The macOS path never invokes
it — seatbelt profiles take regex patterns directly (see the
"no ripgrep needed on macOS" comment in macos-sandbox-utils.ts).

checkDependencies() was checking for rg unconditionally, so on a macOS
host without rg on PATH, SandboxManager.initialize() would throw
"Sandbox dependencies not available: ripgrep (rg) not found" even
though rg is never used there.

Fixes #156
2026-05-05 13:58:00 -07:00
Dylan Conway
b16b240be4 fix(cli): shell-quote positional args instead of join(" ") (#239)
In positional mode (`srt cmd arg1 arg2`), the argv is reassembled into a
string and run via `bash -c`. Reassembling with `commandArgs.join(" ")`
discards argument boundaries, so any arg containing whitespace or shell
metacharacters is re-tokenised by bash:

    srt printf "%s\n" "hello world"
    # before: prints "hello\nworld\n" (two args)
    # after:  prints "hello world\n"

shell-quote is already a dependency (used by the macOS/Linux wrappers).
The -c path is unchanged — it receives a single string and is meant to
be parsed by the shell.

Fixes #157
2026-05-05 13:56:01 -07:00
Dylan Conway
1cb78b969c fix(sandbox): read CLAUDE_CODE_TMPDIR for TMPDIR (in addition to CLAUDE_TMPDIR) (#240)
generateProxyEnvVars() derives the sandboxed TMPDIR from CLAUDE_TMPDIR,
but callers now set CLAUDE_CODE_TMPDIR instead. With only the old name
checked, TMPDIR falls back to the hardcoded /tmp/claude — which is not
created and is not in the write allowlist — so tools that honour TMPDIR
(cmake, nvcc, ...) fail to write temp files.

Read CLAUDE_CODE_TMPDIR first, keep CLAUDE_TMPDIR as a fallback for
backwards compatibility, then default to /tmp/claude as before.

Fixes #141
2026-05-05 13:55:17 -07:00
Dylan Conway
703741b618 test(integration): use async spawn so the in-process proxy can respond; bump bun to 1.3.13 (#243)
* test(integration): use async spawn so the in-process proxy can respond

The integration tests run sandboxed curl commands against the HTTP/SOCKS
proxy that lives in the same process. Driving them with spawnSync blocks
this event loop for the duration of the wrapped command, so the proxy
request handler cannot run — curl is waiting on a server whose JS thread
is parked inside the very spawnSync that is waiting for curl. The suite
only passed because some bun versions happen to let the loop tick during
spawnSync; on linux/x86-64 that has been intermittent (the recent CI
flakes), and on newer bun it stops entirely.

Replace spawnSync with a small async spawnAsync helper that mirrors the
{stdout, stderr, status, signal} return shape. The event loop stays
alive, the proxy responds, and reset() between describes no longer hits
its 5s fallback.

Also bump CI bun to 1.3.13.

* test: move spawnAsync to shared helper, close stdin, convert remaining proxy tests

- Extract spawnAsync into test/helpers/spawn.ts so configurable-proxy-ports
  and update-config tests can share it (both drive curl through an
  in-process proxy and hit the same deadlock on bun >=1.3.2).
- Close child stdin so the child sees EOF immediately, matching spawnSync
  with no `input`. Without this, `su` in the privilege-escalation test
  waits on the open pipe.
- Convert the remaining proxy-hitting spawnSync call sites in those two
  files.
2026-05-05 13:14:19 -07:00
Dylan Conway
fb436f7f08 test(integration): swap allowedDomains via updateConfig instead of reset+initialize (#242)
The wildcard-domain test was the only test in its describe block that
tore down and rebuilt the entire proxy/bridge stack (twice) — every
other test reuses the bridge from beforeAll. On the linux/x86-64 CI
runner this test has been intermittently hitting the 5s test timeout,
and once it does, bun's dangling-process cleanup kills the freshly
spawned socat bridge, which cascades into the next test failing with
"HTTP bridge socket does not exist" and the following beforeAll hook
timing out.

The test only needs a different allowedDomains list. updateConfig()
swaps the singleton config in-place and the proxy filter reads it on
every request, so we get the same coverage without any lifecycle
churn. Restore the original config in a finally block.

Also tighten the one curl that actually reaches an upstream
(api.github.com) — the assertion only checks the proxy did not block
it, so the response body is irrelevant.
2026-05-05 11:56:42 -07:00
Octavian Guzu
ed9085ba73 chore: bump version to 0.0.50 (#235) 2026-05-05 10:54:52 -07:00
ant-kurt
0246170c9b Add bwrapPath and socatPath config overrides for Linux sandbox (#232)
Allows callers to specify absolute paths to bwrap and socat instead of
resolving them via PATH. Useful for managed installs where the binaries
live at fixed locations not on the default PATH.

- SandboxRuntimeConfigSchema: new optional bwrapPath / socatPath fields,
  validated as absolute paths to prevent PATH/CWD hijacking
- checkLinuxDependencies / getLinuxDependencyStatus: take an options
  object; explicit paths are checked via access(X_OK) with no PATH
  fallback (an override is a directive, not a hint)
- initializeLinuxNetworkBridge: optional socatPath for host-side bridges
- wrapCommandWithSandboxLinux: uses bwrapPath for the outer command and
  socatPath for the in-sandbox listeners

Co-authored-by: ant-kurt <209710463+ant-kurt@users.noreply.github.com>
2026-05-05 10:52:15 -07:00
ant-kurt
796aab7317 Invoke sandbox-exec by absolute path (#233)
sandbox-exec ships with macOS at a fixed, SIP-protected location.
Calling it by absolute path avoids depending on PATH and prevents an
earlier PATH entry from shadowing the system binary.

Co-authored-by: ant-kurt <209710463+ant-kurt@users.noreply.github.com>
2026-05-05 10:49:53 -07:00
Octavian Guzu
7a4172b08d fix(sandbox): deny file-write-create on protected ancestors in Seatbelt profile (#226)
* fix(sandbox): deny file-write-create on protected ancestors in Seatbelt profile

generateMoveBlockingRules() previously emitted only (deny file-write-unlink)
for protected paths and their ancestor directories. If a protected path's
ancestor did not yet exist, a sandboxed command could create it as a symlink
to attacker-controlled content — (allow file-write* (subpath <cwd>)) is not
overridden by an unlink-only deny on the ancestor literal.

Now emit (deny file-write-create ...) alongside every (deny file-write-unlink ...)
in generateMoveBlockingRules(), and re-allow file-write-create for write-allowed
paths in generateReadRules() so ordinary file creation in the project directory
keeps working.

* test: use canonical tmpdir so non-existent deny paths match Seatbelt's resolved syscall paths
2026-04-29 17:21:15 +01:00
Dylan Conway
7a725a314f Remove lodash-es dependency (#206)
* Remove lodash-es dependency

Replace the single cloneDeep() call with native structuredClone()
(available in Node >=17; project requires >=18). Drops lodash-es
and @types/lodash-es from dependencies.

* Bump version to 0.0.49
v0.0.49
2026-04-02 18:58:36 -07:00
Dylan Conway
bc3f0faa98 Bump to 0.0.48 and fix npm audit vulnerabilities (#205)
- Bump lodash-es to ^4.18.1 (fixes GHSA-r5fr-rjxr-66jc, GHSA-f23m-r3pf-42rh)
- Update transitive deps via npm audit fix: ajv, brace-expansion, flatted,
  minimatch, picomatch, yaml
- Bump package version to 0.0.48
v0.0.48
2026-04-02 18:38:11 -07:00
Dylan Conway
d3d27dd3a6 Add allowMachLookup config for additional macOS XPC services (#204) v0.0.47 2026-04-02 14:12:34 -07:00
Dylan Conway
2dc232be92 Add seccomp argv0 mode for multicall-binary invocation (#203)
When the caller has apply-seccomp compiled into its own executable
(busybox-style multicall dispatch), there is no standalone binary on
disk to resolve. seccompConfig.argv0 lets the caller supply the dispatch
name and a verbatim applyPath (e.g. an inherited-fd path like
/proc/self/fd/N); the wrapped invocation becomes

    ARGV0=<argv0> <applyPath> <shell> -c <cmd>

and the on-disk getApplySeccompBinaryPath lookup is skipped.

- sandbox-config: optional argv0 string on SeccompConfigSchema (parallel
  to ripgrep.argv0)
- linux-sandbox-utils: resolveApplySeccompPrefix helper returns a
  shell-ready prefix for both modes; non-argv0 output is byte-identical
  to before. Dependency checks short-circuit the disk lookup when argv0
  is set. Inline {applyPath?; argv0?} shapes replaced with the
  SeccompConfig type.
- sandbox-manager: use SeccompConfig type
- tests: argv0 dependency-check short-circuit, wrapped-command prefix
  format, shell-quoting of hostile inputs, argv0-without-applyPath error
2026-04-02 12:29:16 -07:00
Dylan Conway
7f650392ee Bake BPF filter into apply-seccomp, build in CI (#199)
* Bake BPF filter into apply-seccomp, build in CI

The unix-block BPF filter is now generated as a C header at build time
and compiled directly into apply-seccomp. The separate .bpf file is gone,
as is the TS machinery that found, loaded, and tracked it.

vendor/seccomp/build.ts compiles the BPF generator, runs it for both
x64 and arm64, writes the bytes into unix-block-bpf.h, then compiles
apply-seccomp with that header #included. An #if defined(__x86_64__) /
#elif defined(__aarch64__) block in the header picks the right filter
at compile time.

The built binaries are no longer committed. release.yml runs a matrix
job on both an x64 and an arm64 runner, each building apply-seccomp
for its own architecture, uploading the result as an artifact. The
publish job downloads both into vendor/seccomp/{x64,arm64}/ before
npm publish, keeping the tarball layout unchanged.

* Build seccomp binaries in docker-tests CI job

* Remove stale references to on-disk BPF filter file

The two fail-closed tests in pid-namespace-isolation now test execve
failure instead of filter-file validation, since apply-seccomp no longer
takes a filter argument. README still described .bpf files in
vendor/seccomp/.

* Bump version to 0.0.47
2026-04-02 10:58:33 -07:00
Alice T'Poteat
16867c6286 Add tests for rm in allowWrite under denyRead ancestor (issue #171) (#198)
Covers the macOS Seatbelt fix from #170: the read section's move-blocking
rules emit a broad (deny file-write-unlink) that blanket (allow file-write*)
does not override, so rm failed even where touch/write succeed. #170's
re-allow of file-write-unlink for allowWrite paths fixes this.

Three macOS cases verify:
- rm works inside allowWrite under a denyRead ancestor
- rm stays blocked outside allowWrite (no over-broadening)
- denyWithinAllow stays protected (write-section move-blocking wins)

One Linux case verifies #190's re-bind fix for the same scenario.
2026-03-31 18:26:27 -07:00
Alice T'Poteat
e4a34fefd9 Merge pull request #170 from carderne/fix-order-allow-read
fix ordering for allow read within deny
2026-03-31 16:17:06 -07:00
Dylan Conway
e94c5fd01d Run full test suite in CI and migrate platform skips to describe.if (#197)
* Run full test suite in CI and migrate platform skips to describe.if

CI was running test:unit + test:integration, a curated subset of 5 files.
Most test files were never run in CI. Switch to `npm test` which runs
everything. Drop the test:unit/test:integration scripts.

Migrate the inline `if (skipIfNotLinux()) return` pattern to bun's native
`describe.if()`/`it.if()`. The old pattern made wrong-platform tests show
as pass (zero assertions, green checkmark) instead of skip — CI's test
count looked the same regardless of what actually ran. New
test/helpers/platform.ts exports isLinux/isMacOS/isSupportedPlatform.

Delete ~310 lines of unreachable tests from seccomp-filter.test.ts:
- skipIfNotAnt() gate checked USER_TYPE env var that nothing sets
- Two tests called wrapCommandWithSandboxLinux() with no restrictions,
  which returns the command unwrapped at the early-return check —
  expect("echo test").not.toContain("apply-seccomp") was vacuously true

Pin allow-read root-deny tests to /bin/bash — EXEC_DEPS doesn't list
/opt/homebrew, so execvp failed on Macs with homebrew bash as SHELL.

Add docker-tests CI job: unprivileged container on both arches,
exercises enableWeakerNestedSandbox end-to-end.

Drop push trigger from '**' to 'main' — PRs were running the full
matrix twice (once for branch push, once for the PR event).

* Replace mock.module with spyOn in linux-dependency-error tests

mock.module patches bun's module cache globally and never unmocks.
With npm test running all files in one process (instead of the old
test:unit + test:integration split), the mock leaked: every file that
imported getApplySeccompBinaryPath after this one got () => null, so
pid-namespace-isolation.test.ts and integration.test.ts failed in
beforeAll.

spyOn swaps one export binding; mockRestore in afterEach puts it back.
The callee's own import binding routes through the same slot in bun, so
checkLinuxDependencies sees the spy without any module-level surgery.

Also spies on whichSync directly rather than overwriting Bun.which on
globalThis — same fix, closer to what's actually being tested.

Drop stale README reference to the deleted test:integration script.

* Replace docker test-suite job with srt end-to-end test

The full suite assumes bwrap --proc /proc works; an unprivileged
container doesn't have CAP_SYS_ADMIN for that. Only tests that set
enableWeakerNestedSandbox can pass there.

Instead of filtering which unit tests to run, test the thing the job
is for: build srt, run it with enableWeakerNestedSandbox, check that
allowed writes land, denied writes don't, and the seccomp filter blocks
AF_UNIX. Gated on SRT_E2E_DOCKER so host jobs skip it.

* Rename docker job to match other Tests jobs

* Add required network key to docker test config

SandboxRuntimeConfigSchema requires network (no .optional()). Without it
loadConfig returns null, srt falls through to getDefaultConfig, and the
sandbox enforces a different allowWrite than the test expects.

* Add explicit timeouts to update-config sandboxed-curl tests

The three it.if(isLinux) tests each run two spawnSync calls with curl
--max-time 3 then --max-time 5. When example.com responds slowly both
curls run to their limits and the body takes ~8s, but bun's default
test timeout is 5000ms. bun aborts mid-body; afterEach runs reset()
against an in-flight spawn and the next test sees stale state.

These were never in test:integration so they never ran on CI before
this branch. On fast responses they complete in under 200ms.
2026-03-31 15:36:39 -07:00
Dylan Conway
ed5a909983 Fix enableWeakerNestedSandbox after apply-seccomp namespace changes (#196)
* Fix enableWeakerNestedSandbox after apply-seccomp namespace changes

apply-seccomp now creates a nested userns and writes /proc/self/setgroups
and uid_map before applying the seccomp filter. That broke
enableWeakerNestedSandbox in two ways:

  1. Without --proc, bwrap's --ro-bind / / leaves /proc read-only.
     apply-seccomp's setgroups write dies with EROFS.

  2. In unprivileged Docker (the flag's target), apply-seccomp's proc
     remount fails the kernel domination check — Docker's /proc masks
     are MNT_LOCKED in the less-privileged nested userns.

And the reason bwrap never got that far in Docker: bwrap only auto-adds
--unshare-user when EUID != 0. Docker's default is EUID=0 without
CAP_SYS_ADMIN; bwrap assumes it has caps, tries direct clone(NEWPID),
and EPERMs before apply-seccomp runs.

Changes:

  - bwrap args for weak mode: --unshare-user (force userns even as
    EUID=0) and --bind /proc /proc (restore rw /proc for setgroups)
  - apply-seccomp: tolerate mount(/proc) EPERM. The nested userns is
    the isolation boundary; the proc remount only hides outer PIDs
    from `ls /proc`.

Fixes the two failing mandatory-deny-paths tests that exercise
enableWeakerNestedSandbox. No test changes required.

Bump version to 0.0.46.

* Run mandatory-deny-paths tests in CI

These exercise enableWeakerNestedSandbox — the two tests that broke
when apply-seccomp started nesting namespaces. Add explicitly until
the full-suite CI change lands.

* Remove CI step comment
v0.0.46
2026-03-31 12:56:06 -07:00
Alice T'Poteat
bc1ab82928 Merge pull request #195 from anthropic-experimental/atp/cc-1468-denywrite-unmasks-denyread-regression
fix: denyWrite unmasks denyRead when paths overlap (#190 regression)
v0.0.45
2026-03-31 11:30:10 -07:00
Alice Poteat
785809791c Sort denyRead paths shallow-first so file masks land after dir tmpfs
A file-deny listed before its ancestor dir-deny in the denyRead array
was wiped: the /dev/null mask landed first, then the ancestor tmpfs
replaced it, then allowRead re-bound the project dir — file readable.

Normalize then sort by segment count before the mount loop. Ancestors
process first (tmpfs + re-binds), descendant file masks layer on top.
User-specified order no longer matters.
2026-03-31 11:12:36 -07:00
Alice Poteat
ebf2912770 Don't let denyWrite unmask a denyRead /dev/null bind; file-deny survives dir-allow
Two fixes for Linux denyRead precedence.

The #190 reorder (denyWrite after denyRead, so .git/hooks ro-binds
survive a tmpfs over an ancestor) introduced a regression: when the same
file is in both denyRead and denyWrite, denyRead's --ro-bind /dev/null
mask now lands before denyWrite's --ro-bind <host> <host>, which undoes
the mask. Track masked files and skip those dests when emitting
denyWriteArgs — the /dev/null bind already makes them read-only.

Separately: a file-level denyRead was silently skipped when allowRead
covered its parent directory (startsWith check matched). Narrow the
skip to exact matches so denyRead: ['.env'] + allowRead: ['.'] keeps
the .env deny.

Thanks to kyuz0 (#194) for reporting both.
2026-03-31 10:59:45 -07:00
David Dworken
18f2668b44 Defer bwrap mount point cleanup until concurrent sandboxes finish (#184)
* Isolate seccomp workload in nested PID namespace and block io_uring

apply-seccomp now creates a nested user+PID+mount namespace before applying
the seccomp filter. The user command runs as PID 2 under a non-dumpable PID 1
reaper, with /proc remounted so only the inner process tree is visible. This
prevents the sandboxed command from ptracing or patching the unfiltered bwrap
init, bash wrapper, or socat helpers via /proc/N/mem, regardless of the host's
kernel.yama.ptrace_scope setting. Namespace setup failure aborts rather than
silently degrading.

The BPF filter now also blocks io_uring_setup/enter/register. IORING_OP_SOCKET
(Linux 5.19+) creates sockets without going through socket(), and seccomp
cannot inspect SQEs in the shared ring, so denying ring creation entirely is
the only safe option.

The filter generator now accepts an optional target-arch argument so a single
builder can emit both x64 and arm64 filters. Prebuilt binaries and filters are
regenerated for both architectures.

* Pass CAP_SYS_ADMIN to apply-seccomp and clear ambient caps before exec

apply-seccomp needs CAP_SYS_ADMIN to unshare PID+mount namespaces. The
original approach obtained it via unshare(CLONE_NEWUSER), but on hosts
where an LSM restricts unprivileged user namespaces (Ubuntu 24.04 with
AppArmor defaults), the nested userns is created without capabilities
and the setgroups write fails.

bwrap now passes --cap-add CAP_SYS_ADMIN (scoped to its user namespace)
so apply-seccomp can unshare directly. The nested-userns path remains as
a fallback for standalone invocation.

apply-seccomp clears the ambient capability set after remounting /proc,
so the sandboxed command's execve drops to zero capabilities and cannot
umount /proc to reveal the outer mount underneath. Two new tests cover
CapEff=0 and umount denial.

* chore: bump version to 0.0.44

* Add --unshare-user so --cap-add works with setuid bwrap

Setuid bwrap rejects --cap-add from non-root because it would grant
real host capabilities. --unshare-user forces user-namespace mode so
the capability is scoped to that namespace and the flag is accepted.

* Disable AppArmor userns restriction in CI instead of using setuid bwrap

Setuid bwrap rejects --cap-add from non-root, so that path is a dead end.
Instead, disable kernel.apparmor_restrict_unprivileged_userns in CI so
apply-seccomp's nested-userns path works without any bwrap cooperation.
This matches what production Ubuntu 24.04 users need to do anyway, now
documented in the README.

* Exit inner init as soon as the worker exits

reap_until was waiting for all children including orphaned background
processes reparented to PID 1, which hung the sandbox when the user
command backgrounded something long-running and then exited. Return
immediately when the worker terminates; PID 1 exiting tears down the
namespace and SIGKILLs any stragglers.

* Defer bwrap mount point cleanup until all concurrent sandboxes finish

When two sandboxed commands run concurrently and one finishes first,
cleanupBwrapMountPoints() was deleting mount point files that the
still-running sandbox still depended on. Deleting the mountpoint's
dentry on the host detaches the bind mount in the child namespace
(the dentry is unhashed, so path lookup no longer finds the mount),
so the deny rule stops applying inside the still-running sandbox.

Add an active-sandbox counter: wrapCommandWithSandboxLinux()
increments it, cleanupBwrapMountPoints() decrements it and defers
file deletion until the counter reaches zero. A {force: true} option
bypasses the counter for process-exit and reset().

Also bumps version to 0.0.45.

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
2026-03-30 17:18:35 -07:00
David Dworken
7ee4ac602d Isolate seccomp workload in nested PID ns and block io_uring (#183)
* Isolate seccomp workload in nested PID namespace and block io_uring

apply-seccomp now creates a nested user+PID+mount namespace before applying
the seccomp filter. The user command runs as PID 2 under a non-dumpable PID 1
reaper, with /proc remounted so only the inner process tree is visible. This
prevents the sandboxed command from ptracing or patching the unfiltered bwrap
init, bash wrapper, or socat helpers via /proc/N/mem, regardless of the host's
kernel.yama.ptrace_scope setting. Namespace setup failure aborts rather than
silently degrading.

The BPF filter now also blocks io_uring_setup/enter/register. IORING_OP_SOCKET
(Linux 5.19+) creates sockets without going through socket(), and seccomp
cannot inspect SQEs in the shared ring, so denying ring creation entirely is
the only safe option.

The filter generator now accepts an optional target-arch argument so a single
builder can emit both x64 and arm64 filters. Prebuilt binaries and filters are
regenerated for both architectures.

* Pass CAP_SYS_ADMIN to apply-seccomp and clear ambient caps before exec

apply-seccomp needs CAP_SYS_ADMIN to unshare PID+mount namespaces. The
original approach obtained it via unshare(CLONE_NEWUSER), but on hosts
where an LSM restricts unprivileged user namespaces (Ubuntu 24.04 with
AppArmor defaults), the nested userns is created without capabilities
and the setgroups write fails.

bwrap now passes --cap-add CAP_SYS_ADMIN (scoped to its user namespace)
so apply-seccomp can unshare directly. The nested-userns path remains as
a fallback for standalone invocation.

apply-seccomp clears the ambient capability set after remounting /proc,
so the sandboxed command's execve drops to zero capabilities and cannot
umount /proc to reveal the outer mount underneath. Two new tests cover
CapEff=0 and umount denial.

* chore: bump version to 0.0.44

* Add --unshare-user so --cap-add works with setuid bwrap

Setuid bwrap rejects --cap-add from non-root because it would grant
real host capabilities. --unshare-user forces user-namespace mode so
the capability is scoped to that namespace and the flag is accepted.

* Disable AppArmor userns restriction in CI instead of using setuid bwrap

Setuid bwrap rejects --cap-add from non-root, so that path is a dead end.
Instead, disable kernel.apparmor_restrict_unprivileged_userns in CI so
apply-seccomp's nested-userns path works without any bwrap cooperation.
This matches what production Ubuntu 24.04 users need to do anyway, now
documented in the README.

* Exit inner init as soon as the worker exits

reap_until was waiting for all children including orphaned background
processes reparented to PID 1, which hung the sandbox when the user
command backgrounded something long-running and then exited. Return
immediately when the worker terminates; PID 1 exiting tears down the
namespace and SIGKILLs any stragglers.
2026-03-30 17:07:40 -07:00
Alice T'Poteat
732a12a4c2 chore: bump version to 0.0.44 (#192) v0.0.44 2026-03-30 16:12:22 -07:00
Alice T'Poteat
41a57927c7 fix: allow filesystem root traversal when denyRead includes '/' (#190)
* Fix allowRead carve-outs when denyRead covers filesystem root

denyRead: ['/'] + allowRead: [<project>] denied everything on both
platforms (follow-up to #166, closes #10).

macOS: (deny file-read* (subpath "/")) blocks the root inode itself; no
allowWithinDeny subpath covers "/", so dyld SIGABRTs before exec. Emit
(allow file-read* (literal "/")) so path traversal through root works.
Exposes `ls /` dirent names but no subtree contents.

Linux: two issues. --tmpfs / wipes every prior mount (ro-bind /, write
binds, denyWrite ro-binds), and the carve-out prefix check
startsWith('/' + '/') never matches. Expand a root deny into its
children (minus /proc, /dev, /sys) so the existing per-dir tmpfs +
re-bind logic applies. Also re-bind any allowWrite paths that land
under a tmpfs'd deny dir (previously they went read-only), and buffer
denyWrite ro-binds until after denyRead processing so .git/hooks
protection survives a tmpfs over an ancestor.

* Dedup denyWrite entries post-normalization to prevent bwrap failure

Two denyWrite entries that converge to the same path after
normalizePathForSandbox() produced a duplicate
--ro-bind /dev/null <dest>. On the second bind, <dest> is a char device
(the first bind's mount); bwrap's ensure_file() only short-circuits on
S_ISREG, so it falls through to creat() and fails on the now-read-only
mount. Every sandboxed command errors out.

Common trigger: same path specified in two config surfaces that feed
into denyWrite, e.g. a file-edit permission deny and a
sandbox.filesystem.denyWrite entry for the same file.

linuxGetMandatoryDenyPaths() already does [...new Set(denyPaths)] but
that's pre-normalization — ~/.foo and the expanded absolute path differ
as strings there.

* Wire allow-read and wrap-with-sandbox tests into CI

test:integration only listed integration.test.ts explicitly; these two
files were never run despite containing sandbox-exec/bwrap integration
tests. The root-deny and dedup tests added in this PR need CI coverage.

* Fix Linux test assumptions: seccomp binary path, ro-bind src+dest count

Root deny hides the repo's vendor/seccomp/ dir, so apply-seccomp
can't load inside the sandbox and bash returns 127. Bypass seccomp
with allowAllUnixSockets: true — socket blocking is orthogonal here.

Dedup assertion was off by 2x: --ro-bind <p> <p> contains <p> twice.
The fix works (was 4 occurrences, now 2).

* Narrow allowRead skip check to writes actually re-bound under this tmpfs

The skip check introduced in 3cdb468 was too broad: it skipped any
allowPath under any allowWrite, not just writes that were re-bound in
the current tmpfs iteration. With allowWrite as an ancestor of denyRead
(e.g. allowWrite: [~], denyRead: [~/.ssh], allowRead: [~/.ssh/known_hosts])
the write path isn't wiped and isn't re-bound, but the skip check still
matched — known_hosts was left sitting in the empty tmpfs.

Narrow the predicate: only skip if the write path itself is under the
tmpfs'd deny dir (i.e. it was re-bound just above).
2026-03-30 15:42:43 -07:00
Samuel Attard
fd74a3f012 Add upstream/parent HTTP proxy support to sandbox (#187)
* Add upstream/parent HTTP proxy support to sandbox

When the sandbox runs in an environment that requires an HTTP proxy for
outbound internet access (e.g. corporate networks), the sandbox's own
HTTP and SOCKS proxies must chain through that upstream rather than
connecting directly.

- New parent-proxy module: config resolution (explicit config falling
  back to HTTP_PROXY/HTTPS_PROXY/NO_PROXY env), NO_PROXY matching with
  hostname-suffix and CIDR support, and a CONNECT-tunnel helper
- HTTP proxy: direct-path CONNECT and plain requests now tunnel through
  the parent when configured; NO_PROXY and loopback still bypass
- SOCKS proxy: custom connection handler routes through parent HTTP
  CONNECT instead of direct net.connect()
- Config schema: new network.parentProxy field with http/https/noProxy
- Tests: unit tests for resolution/NO_PROXY/URL selection plus an e2e
  tunnel test verifying requests chain through a recording parent proxy

* Address review: security fixes, net.BlockList, unify CONNECT paths

Security fixes:
- Validate destHost in openConnectTunnel to prevent CRLF injection via
  SOCKS5 DOMAINNAME (allowlist bypass + credential theft vector)
- Forward reconstructed absolute-URI (not raw req.url) to parent proxy,
  closing URL-parser differential bypass
- Strip hop-by-hop and proxy-authorization headers before forwarding
- Redact userinfo from parent proxy URLs in debug logs

Correctness fixes:
- Handle proxy close during CONNECT handshake (no longer hangs forever)
- Add 30s timeout and 16KB header cap on CONNECT negotiation
- Strip brackets from IPv6 URL.hostname before netConnect/tlsConnect
- Skip port-stripping for IPv6 literals in NO_PROXY entries
- Reject empty/malformed CIDR suffixes (10.0.0.0/ no longer becomes /0)
- Bracket IPv6 destHost in CONNECT authority-form
- Omit SNI servername when proxy host is an IP literal
- Anchor status-line regex (accept 2xx, reject 200 in reason-phrase)
- Extend loopback bypass to full 127/8 and v4-mapped ::ffff:127/104

Refactors:
- Replace hand-rolled CIDR matching (parseCidr/ipInCidr/bitsMatch/
  expandV6, ~70 lines) with net.BlockList
- Extract generic openConnectTunnel helper; mitm and parent CONNECT
  paths now share one implementation (~70 lines deduplicated)
- http-proxy.ts CONNECT handler reduced to a single try/await/pipe
  block for all three routes (mitm/parent/direct)

Tests: 33 unit + 2 integration, including regression tests for each
security fix.

* Second-round review fixes: null-byte bypass, socket leaks, IPv6 CONNECT

CRITICAL:
- Block null-byte allowlist bypass: SOCKS5 DOMAINNAME is raw bytes;
  'evil.com\x00.allowed.com' passed .endsWith('.allowed.com') but DNS
  truncated at the null and connected to evil.com. Now validated via
  isValidHost at both the SOCKS ruleset validator and filterNetworkRequest
  (defence in depth).

HIGH:
- Pause socket before removing data listener in openConnectTunnel: leaving
  the stream flowing meant unshift'd trailing bytes (TLS ServerHello) could
  be dropped before the caller's pipe() attached.
- Abort upstream dial on client disconnect (both HTTP and SOCKS): previously
  leaked in-flight sockets when the client RST'd mid-CONNECT. New dialDirect
  helper gives the direct path the same 30s timeout as tunnelled paths.
- Tear down proxyReq on client close in the HTTP request handler; destroy
  res on mid-stream error instead of leaving it hung.
- Parse IPv6 CONNECT targets ([::1]:443) correctly; split(':') was
  returning 400 for all IPv6.
- Forward the CONNECT head buffer to upstream (pipelined TLS ClientHello
  was being dropped).

MEDIUM:
- Strip bracketed IPv6 and store bare IPs in NO_PROXY as BlockList entries
  so they actually match.
- Accept schemeless HTTP_PROXY (proxy.corp:3128) like curl; reject
  non-http(s) schemes with a clear error.
- CONNECT is always treated as HTTPS for HTTPS_PROXY selection (was
  port==443 only).
- Plain HTTP no longer falls back to HTTPS_PROXY (matches curl).
- Drop unused _port param from shouldBypassParentProxy; document that
  NO_PROXY port suffixes are host-matched only.
- Add content-length to hop-by-hop strip list (TE.CL desync hardening).
- Strip headers named in Connection: per RFC 7230.

LOW:
- Use removeListener instead of removeAllListeners('close').
- Fix misleading comment in parseNoProxy CIDR branch.

Tests: +11 regression tests (null-byte, CRLF, schemeless URL, Connection
header stripping, content-length stripping).

* Third-round review: zone-ID bypass, CL header, error-on-abort race

CRITICAL:
- Block IPv6 zone-ID allowlist bypass: '::ffff:127.0.0.1%x.github.com'
  passed isIP() (Node accepts dotted zone IDs), passed
  .endsWith('.github.com'), then connected to 127.0.0.1 when the OS
  discarded the bogus scope. isValidHost now rejects '%' outright, and
  matchesDomainPattern refuses wildcard-match on IP literals as a second
  layer.

Correctness:
- Revert content-length from hop-by-hop set. It's end-to-end per RFC 7230;
  stripping it forced chunked encoding on all forwarded bodies, breaking
  HTTP/1.0 upstreams and CL-requiring servers. Node's llhttp already blocks
  the TE+CL smuggling vector this was meant to guard against.
- Attach error handler before upstream.destroy() on client-abort: the
  openConnectTunnel resolver removes its own error listener, so a late RST
  could fire unhandled and crash the process.
- proxyAuthHeader: catch malformed percent-encoding rather than throwing
  synchronously into the SOCKS callback (decodeURIComponent('%ZZ') throws).
- SOCKS now treats all tunnels as HTTPS for parent-proxy selection (was
  port==443 only), so SSH/git/etc route through HTTPS_PROXY correctly.
- dialDirect: handle 'close' event for parity with openConnectTunnel.
- NO_PROXY: parse '[v6]:port' form (bracket-strip was failing on ']' not
  being last char).
- isValidHost: accept underscore for real-world DNS records (_dmarc,
  _acme-challenge).

Tests: +2 regression tests for zone-ID bypass and underscore acceptance;
content-length test updated to assert preservation.

* Fourth-round review: inet_aton canonicalization, state lifecycle, response headers

MEDIUM:
- Add canonicalizeHost and apply before allowlist matching: WHATWG URL
  normalizes inet_aton shorthand (127.1, 2130706433, 0x7f.0.0.1) and IPv6
  compression so string comparisons agree with what getaddrinfo() will
  dial. Without this, '2852039166' dodged a denylist entry for
  '169.254.169.254' and obscured user prompts.
- Strip hop-by-hop headers on responses too (was request-only): prevents
  upstream Proxy-Authenticate/Connection/Transfer-Encoding leaking to
  the sandboxed client.

State lifecycle:
- updateConfig() now re-resolves parentProxy for consistency with config.
- reset() clears parentProxy alongside other module state.
- resolveParentProxy returns undefined when both URLs fail to parse,
  rather than a husk object that logs misleadingly.

Robustness:
- Check req.socket.destroyed after filter await in the request handler
  (client may have disconnected during the filter, leaking proxyReq).
- Guard res.writeHead(500) with headersSent check in the outer catch.
- SOCKS listen() now rejects on bind error instead of hanging.
- Wrap sendStatus('HOST_UNREACHABLE') in try/catch (socket-closed race).

Tests: +5 for canonicalizeHost covering inet_aton, IPv6, trailing dot.

* Apply review suggestions: dedupe ParentProxyConfig, handle password-only auth

- Import ParentProxyConfig from sandbox-config.ts instead of duplicating
  the interface locally.
- proxyAuthHeader: check both username and password before returning
  undefined — http://:secret@proxy is a valid URL with empty username.
v0.0.43
2026-03-27 20:27:10 -07:00
David Dworken
62e61c0e74 Sandbox hardening: TMPDIR write scope and seccomp arg comparison (#182)
* Harden macOS sandbox to only use the configured TMPDIR for writes

* Use 32-bit masked comparison for socket() domain argument in seccomp filter
2026-03-23 17:41:10 -07:00
Chris Arderne
62f306471a fix ordering for allow read within deny 2026-03-13 09:41:56 +00:00
Alice T'Poteat
20f5176a94 chore: bump version to 0.0.42 (#169) v0.0.42 2026-03-12 16:12:06 -07:00
Alice T'Poteat
b005744a84 Merge pull request #166 from carderne/config-allow-read
feat: add allowRead config option with precedence over denyRead
2026-03-12 16:00:21 -07:00
David Dworken
b3716c6d01 fix: set GIT_SSH_COMMAND on Linux so git over SSH resolves DNS via proxy (#168)
* fix: set GIT_SSH_COMMAND on Linux so git over SSH resolves DNS via proxy

On Linux, the sandbox runs inside an isolated network namespace
(--unshare-net) with no DNS. Previously GIT_SSH_COMMAND was only set
on macOS (using BSD nc), so git push/fetch over SSH on Linux failed with
"Could not resolve hostname".

Use socat's PROXY: address type (HTTP CONNECT) against the HTTP proxy
bridge on port 3128. socat is already a required Linux dependency, and
PROXY: works on all socat versions (unlike SOCKS5-CONNECT which needs
>= 1.8.0).

Also bump version to 0.0.41 (and sync package-lock.json which had
drifted to 0.0.39).

Fixes #161

* test: add integration tests for git over SSH through sandbox proxy

Adds two tests covering the GIT_SSH_COMMAND fix for #161:

1. Verifies GIT_SSH_COMMAND is set inside the Linux sandbox and routes
   through socat PROXY (HTTP CONNECT).

2. Runs git ls-remote over SSH against github.com and asserts DNS
   resolution succeeds. Uses /dev/null as the SSH identity so the
   expected outcome is 'Permission denied (publickey)' -- reaching
   that error proves TCP connect + SSH handshake worked, while
   'Could not resolve hostname' would indicate regression.
v0.0.41
2026-03-12 11:32:27 -07:00
Dylan Conway
abb4d80442 feat: support argv0 in RipgrepConfig (#163)
* feat: support argv0 in RipgrepConfig

Adds an optional argv0 field to RipgrepConfig for invoking multicall
binaries that dispatch based on argv[0]. When set, spawn() is used
instead of execFile() since execFile doesn't support overriding argv[0].

The existing execFile code path is unchanged when argv0 is not provided.

Also bumps version to 0.0.40.

* refactor: unify ripGrep on spawn + stream/consumers

Drop execFile and use spawn for both argv0 and non-argv0 paths:
- text() from node:stream/consumers collects stdout/stderr as Promise<string>
- spawn({ timeout }) handles the 10s timeout natively
- Promise.all([stdout, stderr, close]) reads naturally as async/await

Drops the 20MB maxBuffer cap — the only caller scans for dangerous files
at bounded depth, so runaway output is not a realistic concern.
v0.0.40
2026-03-09 11:38:32 -07:00
Chris Arderne
ee66d4ba88 fix: allow stat on all directories 2026-03-08 14:33:45 +00:00
Chris Arderne
f56ee92ad9 add tests 2026-03-08 10:41:32 +00:00
Chris Arderne
1f696a2eba improve corner-case handling 2026-03-08 10:41:22 +00:00
Chris Arderne
75944d896a feat: add allowRead config option with precedence over denyRead
Adds an optional allowRead field to the filesystem config that re-allows
read access within regions blocked by denyRead. allowRead takes
precedence over denyRead, which is intentionally the opposite of write
where denyWrite takes precedence over allowWrite. This enables
workspace-only filesystem access patterns like denyRead: ["/Users"],
allowRead: ["."] without breaking system paths.

macOS: emits additional (allow file-read*) rules after deny rules,
relying on Seatbelt's last-rule-wins semantics.

Linux: after mounting tmpfs over denied directories, re-binds allowed
subdirectories with --ro-bind so they become readable again.

The field is optional with no default behavior change — existing configs
work identically.
2026-03-08 10:27:28 +00:00
David Dworken
6d6b48d3f1 Merge pull request #155 from anthropic-experimental/dworken/bump-v0.0.39
Bump version to 0.0.39
v0.0.39
2026-02-25 19:13:33 -08:00
David Dworken
5ec63cb862 Bump version to 0.0.39 2026-02-25 17:09:02 -08:00
David Dworken
31a5b83ddd Merge pull request #153 from patrick-premont/patrick/fix-allowwrite-glob-stripping
fix: strip glob suffixes from allowWrite/denyWrite on Linux
2026-02-25 17:08:25 -08:00
Patrick Prémont
475b0c6670 fix: strip glob suffixes from allowWrite/denyWrite paths on Linux
wrapWithSandbox() and generateFilesystemArgs() did not strip trailing
/** from allowWrite and denyWrite paths before generating bubblewrap
--bind mounts. fs.existsSync() failed on the literal glob path (e.g.
"/home/user/project/**") and silently skipped the mount, leaving the
directory read-only under --ro-bind / /.

macOS was unaffected because generateWriteRules() converts globs to
Seatbelt regex patterns. The same config produced correct results on
macOS but broken results on Linux.

The fix applies removeTrailingGlobSuffix() and containsGlobChars()
filtering to allowWrite and denyWrite in both wrapWithSandbox() (the
SandboxManager API path) and generateFilesystemArgs() (defense in
depth), matching the existing treatment of denyRead paths and the
logic in getFsWriteConfig().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-25 15:02:29 -05:00
Dylan Conway
ef4afdef4d ci: add npm release workflow (#143) v0.0.38 2026-02-19 14:48:18 -08:00
Tom Ballinger
abd0b3bf2c fix: allow Unix domain socket creation in network-restricted sandbox (#140)
When allowedDomains is set, the sandbox enters restricted network mode.
The previous implementation used (allow network* (subpath "/")) to allow
Unix sockets, but socket(AF_UNIX, SOCK_STREAM, 0) is a system-socket
operation that doesn't reference a filesystem path, so (subpath ...) can't
match it. This caused Gradle (FileLockContentionHandler), Docker, and other
tools that create Unix domain sockets to fail with:
  java.net.SocketException: Operation not permitted

The fix uses three explicit Seatbelt rules instead:
1. (allow system-socket (socket-domain AF_UNIX)) - for socket() creation
2. (allow network-bind (local unix-socket ...)) - for bind() operations
3. (allow network-outbound (remote unix-socket ...)) - for connect() operations

This properly separates the socket creation syscall (which has no path
context) from the bind/connect operations (which reference paths).

Fixes: Gradle builds failing in sandbox with allowedDomains configured
Fixes: Docker socket failures in sandbox with allowedDomains configured
2026-02-19 13:24:54 -08:00
David Dworken
9f59523e12 security: warn and skip symlink write paths pointing outside boundaries (#138)
* security: warn and skip symlink write paths pointing outside boundaries

bwrap follows symlinks when doing bind mounts, so if a user configures
an allowWrite path that is a symlink pointing to an unexpected location,
that target location would become writable.

For example, if ./src is a symlink to /etc, configuring allowWrite: ['./src']
would make /etc writable through the symlink.

This change:
- Detects when a write path is a symlink pointing outside expected boundaries
- Prints a warning to inform the user
- Skips the path instead of making the unexpected target writable

Fixes potential symlink-based sandbox escape in write path configuration.

* test: add unit and integration tests for symlink write path detection, bump to 0.0.38

* fix: trim trailing slashes before symlink comparison in write path check

realpathSync never returns trailing slashes, but normalizedPath may have
one, causing a false mismatch that incorrectly treats the path as a
symlink and skips it. Strip trailing slashes before comparing.

Add test to reproduce the trailing slash issue.

---------

Co-authored-by: ollie-anthropic <ollie@anthropic.com>
2026-02-18 17:40:44 -08:00
Dylan Conway
96800ee98b Merge pull request #127 from anthropic-experimental/dylanc/allow-local-binding-ipv6-dual-stack
fix: use wildcard in allowLocalBinding seatbelt rules for IPv6 dual-stack compatibility
2026-02-10 13:53:35 -08:00
Dylan Conway
d9ed554fc6 test: add integration tests for allowLocalBinding IPv6 dual-stack support 2026-02-10 13:47:53 -08:00
David Dworken
169d9c2d5e Merge pull request #126 from anthropic-experimental/dworken/deny-path-mount-cleanup
Re-introduce non-existent deny path protection with mount point cleanup
2026-02-10 12:13:36 -05:00