feat(jail): cross-platform local write-jail for spawned CLIs (bwrap + sandbox-exec) - #64
Merged
Conversation
… sandbox-exec)
Confine an agent/eval run's writes to a scratch root it cannot escape, so running a
CLI in a project dir "acts like a sandbox". Reads (node, the CLI, the project) and
network stay intact; writes anywhere except the jail root hit a read-only filesystem.
- src/jail/: small focused modules — types (JailSpec/JailWrap/JailBackend +
resolveJailRoot containment), linux-bwrap (unprivileged bubblewrap), macos-seatbelt
(sandbox-exec SBPL, modeled on holtwick/bx-mac, with /private realpath handling),
index (platform selector + NoopJail fallback). No god object; the facade just selects.
- src/executors/jail-support.applyJail: the single wrap point both host + scoped-host
spawners call. Pass-through (byte-identical) when no jail; warns LOUDLY (not silently)
when a jail is requested but the backend can't run here.
- Config: per-request execution.jail { mode, root } + env BRIDGE_JAIL_MODE/ROOT,
default root <cwd>/.agent-home, clamped inside cwd (fail-closed on escape attempts).
- Backends thread the resolved spec into SpawnOpts.jail (+1-2 lines each).
Linux runs UNPRIVILEGED (real uid, no sudo/chmod) and requires the host to permit
unprivileged user namespaces; on restricted hosts (Ubuntu default) isAvailable() is
false and we warn with the one-time enable command.
Verified: typecheck clean; 8/8 composition tests; e2e on linux (bwrap 0.9.0) proves
writes-inside OK, writes-outside + subprocess-escape blocked (read-only fs), host
reads + DNS OK, runs as the real uid.
…direct Three high-severity findings from the pre-push codex-review: - SECURITY (fail-closed): a requested write-jail that cannot be enforced now THROWS instead of silently running unconfined. Explicit opt-out: BRIDGE_JAIL_FALLBACK=warn. - CORRECTNESS (macOS HOME): sandbox-exec's -D only parameterizes the profile, it does not rewrite the child env, so the seatbelt backend now returns env so the child actually sees HOME pointing into the jail. - REGRESSION (XDG): jailed processes inherited host XDG_* dirs (read-only in the jail), breaking common CLIs. Both backends now redirect HOME + XDG_CONFIG/CACHE/DATA/STATE/ RUNTIME into the jail root via a shared jailEnv(); prepareJailHome() pre-creates them. applyJail takes an injectable backend for testability. Tests added: fail-closed throws + warn-fallback passthrough, bwrap XDG --setenv, seatbelt HOME/XDG env. Verified: typecheck clean, 11/11 tests, e2e via the real module (HOME + XDG writes land in the jail, outside + subprocess writes blocked read-only).
…vailable) codex review: NoopJail.isAvailable() returned true, so on a non-Linux/macOS host the auto-selected noop backend slipped past applyJail's fail-closed check and ran a requested write-jail unconfined. NoopJail now reports unavailable (a no-op backend cannot confine), so applyJail fails closed there too; wrapInJail still uses it as the explicit pass-through fallback (which does not consult the flag). Regression test added.
…est cannot weaken
codex review: redirecting HOME/XDG into the jail hid the operator's credentials, so auth-dependent CLIs (claude/kimi/opencode/gemini) could not authenticate. Now each backend's host auth/config paths are made available inside the jail at their $HOME-relative location: read-only bind on Linux (free), copy on macOS (sandbox-exec cannot bind). auth-preserve.ts holds the per-backend registry (generalizing what codex.ts does for CODEX_HOME) and surfaces only paths that exist on the host. The route attaches authSourcesFor(backend.name) to the resolved spec. Verified: typecheck clean, 16/16 tests, e2e on linux — with claude's ~/.claude.json + ~/.claude bound in, HOME=jail finds the creds readable+nonempty while writes outside the jail stay blocked (read-only fs).
codex review (2 high): - Auth registry keyed by 'claude'/'kimi' but the server registers 'claude-code', 'claudish', 'kimi-code' (claude.ts/kimi.ts harness names), so jailed runs got no credentials. AUTH_PATHS now lists every registered alias explicitly (exact, auditable mapping, no fuzzy matching). - The Linux backend tmpfs'd /tmp, hiding the MCP/config files the backends materialize under the host tmpdir (--mcp-config, --mcp-config-file, OPENCODE_CONFIG, kimi config.toml). Dropped the tmpfs so those stay readable (read-only via the root bind); the CLI's own temp writes go to TMPDIR=<root>/.tmp (added to jailEnv + pre-created). macOS already permits the temp dirs via SYSTEM_WRITABLE. Tests: alias mapping (claude-code==claude, kimi-code==kimi), TMPDIR redirect, no tmpfs. typecheck clean, 16/16.
codex review:
- SECURITY (high): macOS copies host auth into the project-local jail root and only
cleaned the SBPL profile, leaving real credentials under the repo. Now: the jail root
gets a .gitignore ('*') so nothing there is ever committed, and copyAuthIntoJail
returns the copied paths which macOS cleanup() removes after the run.
- DOCS (medium): the execution.jail schema claimed 'off' disables jailing even under
BRIDGE_JAIL_MODE; corrected to state the env write-jail is an operator floor a request
cannot weaken (matches resolveJailSpec). Test: jail root .gitignore present.
typecheck clean, 16/16.
codex review (2 high, containment): resolveJailRoot used lexical resolve()+relative()
only, so (1) root === projectDir ('.' or the cwd) was accepted, making the entire
worktree the writable jail, and (2) a repo-local symlink (scratch -> /tmp) passed the
check then got realpath'd/bind-mounted to the external target. Now resolveJailRoot
canonicalizes both paths (resolving symlinks on the existing prefix, tolerating a
not-yet-created tail) and requires the root to be a STRICT descendant of the base.
resolveJailSpec already clamps a rejected root to the in-cwd default. Regression tests:
root='.'/root===cwd rejected, repo-local symlink-to-/tmp rejected, normal nested ok.
typecheck clean, 19/19.
codex review: - SECURITY (high): the jail root could be set to an arbitrary repo subtree (e.g. src/), making tracked files writable AND clobbering their .gitignore. The root is now restricted to the dedicated <cwd>/.agent-home scratch namespace — anything outside it clamps to the default; .gitignore is written only if absent (never clobbered). - AUTH (medium): auth sources resolved from os.homedir(), but backends read from process.env.HOME; now honor the cli-bridge HOME override. codex dropped from the registry (codex.ts self-preserves auth via its synthetic CODEX_HOME). Tests: nested root under .agent-home honored, arbitrary subtree (src) clamped. typecheck clean, 20/20.
codex review (high): codex.ts synthesizes a CODEX_HOME with copied auth only when MCP passthrough is active; in the common no-MCP case codex reads ~/.codex, which the jail hid (HOME redirected). Re-added codex to AUTH_PATHS so ~/.codex is bound into the jail on every jailed codex run; the env CODEX_HOME (MCP case) still takes precedence. Test asserts codex auth is preserved. typecheck clean, 20/20.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A local write-jail for the CLIs cli-bridge spawns: run an agent/eval in a project dir and confine all of its writes to a scratch root (
<cwd>/.agent-home) it cannot escape, while reads (the project, the CLI, node) and network stay intact. "Acts like a sandbox" without a VM or container.bubblewrap(read-only host root, writable jail bound last, HOME/XDG/TMPDIR redirected in). Free, no copy, runs as the real uid.sandbox-exec(SBPL) — allow reads, denyfile-write*except the jail root + temp;/privaterealpath handled.execution.jail+BRIDGE_JAIL_MODE/ROOT), byte-identical to today when off. Composes with the runs registry.Modules
src/jail/:types(JailSpec/JailWrap/JailBackend + canonicalizingresolveJailRoot+ jailEnv/prepareJailHome),linux-bwrap,macos-seatbelt,auth-preserve(per-backend credential preservation),index(selector + Noop fallback),resolve-spec. Single wrap pointexecutors/jail-support.applyJail; backends thread the spec viaSpawnOpts.jail(+1-2 lines each).Security model (hardened across review)
BRIDGE_JAIL_FALLBACK=warn); NoopJail reports unavailable so unsupported platforms fail closed..agent-homescratch namespace — rejectsroot===cwd, repo-local symlink escapes, and arbitrary repo subtrees.BRIDGE_JAIL_MODE=write-jailis an operator floor a request cannot weaken.Verified
bash -cescape are blocked (read-only fs); host reads + DNS work; runs as the real uid;~/.claudecreds readable in-jail while confinement holds.sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0orsudo chmod u+s /usr/bin/bwrap.Known follow-up (non-blocking)
config.jailMode/jailRootis logged but the request path still readsprocess.env; thread the resolved config through route deps sobuildApp(loadConfig(customEnv))is consistent.🤖 Generated with Claude Code