Skip to content

feat(jail): cross-platform local write-jail for spawned CLIs (bwrap + sandbox-exec) - #64

Merged
drewstone merged 10 commits into
mainfrom
feat/local-jail-executor
Jun 28, 2026
Merged

feat(jail): cross-platform local write-jail for spawned CLIs (bwrap + sandbox-exec)#64
drewstone merged 10 commits into
mainfrom
feat/local-jail-executor

Conversation

@drewstone

Copy link
Copy Markdown
Owner

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.

  • Linux: unprivileged bubblewrap (read-only host root, writable jail bound last, HOME/XDG/TMPDIR redirected in). Free, no copy, runs as the real uid.
  • macOS: sandbox-exec (SBPL) — allow reads, deny file-write* except the jail root + temp; /private realpath handled.
  • Lives in the spawn seam (execution.jail + BRIDGE_JAIL_MODE/ROOT), byte-identical to today when off. Composes with the runs registry.

Modules

src/jail/: types (JailSpec/JailWrap/JailBackend + canonicalizing resolveJailRoot + jailEnv/prepareJailHome), linux-bwrap, macos-seatbelt, auth-preserve (per-backend credential preservation), index (selector + Noop fallback), resolve-spec. Single wrap point executors/jail-support.applyJail; backends thread the spec via SpawnOpts.jail (+1-2 lines each).

Security model (hardened across review)

  • Fail-closed: a requested jail that can't be enforced refuses to run (warn-fallback via BRIDGE_JAIL_FALLBACK=warn); NoopJail reports unavailable so unsupported platforms fail closed.
  • Containment: root canonicalized (symlink-resolved) and required to be a STRICT descendant inside the dedicated .agent-home scratch namespace — rejects root===cwd, repo-local symlink escapes, and arbitrary repo subtrees.
  • Auth preserved per backend (claude/claude-code/claudish, kimi/kimi-code, opencode, gemini, codex): host creds made available in-jail (ro-bind on Linux, copy on macOS) so the confined CLI still authenticates as the operator; copies removed on cleanup; jail root gitignored so neither artifacts nor creds can be committed.
  • Policy floor: BRIDGE_JAIL_MODE=write-jail is an operator floor a request cannot weaken.

Verified

  • typecheck clean; 20 unit tests (composition, fail-closed, containment incl. symlink/self-root, auth mapping, env redirect).
  • e2e on Linux (bwrap 0.9.0): writes inside the jail succeed; writes outside + a subprocess bash -c escape are blocked (read-only fs); host reads + DNS work; runs as the real uid; ~/.claude creds readable in-jail while confinement holds.
  • Note: on hosts that restrict unprivileged user namespaces (Ubuntu default), enable once: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 or sudo chmod u+s /usr/bin/bwrap.

Known follow-up (non-blocking)

  • Parsed config.jailMode/jailRoot is logged but the request path still reads process.env; thread the resolved config through route deps so buildApp(loadConfig(customEnv)) is consistent.

🤖 Generated with Claude Code

drewstone added 10 commits June 27, 2026 23:09
… 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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant