* 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
* 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
* 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.
* 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
* 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>
* 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).
* 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.
* 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.
* 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>
PR #80 hardened the sandbox by mounting /dev/null over non-existent deny
paths to prevent their creation, but this caused bwrap to leave empty
"ghost dotfiles" on the host (issue #85), which PR #91 reverted. This
re-introduces the protection with proper cleanup: mount points are
tracked and removed via cleanupBwrapMountPoints(). A new lightweight
cleanupAfterCommand() API is exposed on SandboxManager for callers to
invoke after each command, and the srt CLI calls it on child exit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously, non-existent paths in the deny list were skipped since
bwrap cannot ro-bind a file that doesn't exist. This change adds
defense-in-depth by mounting /dev/null at the first non-existent
path component, which prevents creation of the denied path.
- Add findFirstNonExistentComponent helper to locate mount point
- Mount /dev/null at first missing component to block path creation
- Add tests for non-existent deny path protection
Bump version to 0.0.24
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Install husky v9.1.7 for git hook management
- Install lint-staged v16.2.6 for efficient staged file checking
- Configure pre-commit hook to run ESLint and Prettier on staged TypeScript files
- Add prepare script to ensure hooks are installed after npm install
This setup automatically enforces code quality standards before each commit,
running linting and formatting only on staged files for optimal performance.
- Add early dependency validation in initialize() before starting any services
- Provide platform-specific error messages listing required dependencies
- Linux: ripgrep (rg), bubblewrap (bwrap), and socat
- macOS: ripgrep (rg)
- Refactor checkDependencies() for cleaner code structure
- Bump version to 0.0.7
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>