Skip to content

fix(security): wire the permission bridge, sandbox, and worktree preservation into production - #3

Merged
vimoxshah merged 4 commits into
mainfrom
fix/adr-034-hitl-sandbox-wiring
Sep 5, 2026
Merged

fix(security): wire the permission bridge, sandbox, and worktree preservation into production#3
vimoxshah merged 4 commits into
mainfrom
fix/adr-034-hitl-sandbox-wiring

Conversation

@vimoxshah

Copy link
Copy Markdown
Owner

Three statements the project makes in public did not match the code. Each gap was a missing call site in front of working, tested code. ADR-034 records the decision; this PR is the wiring plus guards that read the sources, so the call sites cannot silently disappear again.

What was wrong

  • Approvals never fired. The docs say a gated tool call holds until a human answers. onPermissionRequest was called only by mock-runner.ts, so no production run ever raised one.
  • The sandbox was never applied. The Seatbelt profile generator, symlink resolution, credential-collision refusal, and the 13-check escape suite were all real and passing, but no production spawn passed a spec to the engine.
  • Interrupted worktrees were deleted. S-39 pruned a worktree whenever the agent had committed nothing, which includes a run killed by timeout, despite the docs promising the work is preserved.

What changed

  • The Claude CLI engine raises every gated tool call through a loopback HTTP MCP bridge and holds for the run's remaining wall-clock budget, not a fixed window. --permission-prompt-tool is present in CLI 2.1.261, which falsifies ADR-020's premise. The contract matrix was re-run against the installed version to prove it.
  • Every engine spawn routes through applySandbox(). CW_SANDBOX=off is the only way out, and it is logged, written to the safety journal, and stamped on the report.
  • Worktree pruning is limited to runs that ended cleanly with no git operation in flight. Otherwise the worktree is kept and the report says why.
  • Under acceptEdits the CLI runs Bash without consulting the prompt tool, so the deny-list floor moved to a PreToolUse hook, which the CLI invokes in every permission mode.

Testing

Suite green: 260 passed across 37 files. Lint and typecheck clean.

The cancel test was also made deterministic. The daemon marks a run running at spawn, before the child has executed a line, so cancelling on that signal alone raced the child's first message and sandboxed was legitimately null. The test now waits for the containment stamp before interrupting. That is a test-only change; sandboxedFor semantics are untouched.

Note for the reviewer

A stacked PR with the agent-workforce features is based on this branch. Merge this one first, then that one retargets to main.

🤖 Generated with Claude Code

vimox-shah-genea and others added 2 commits September 6, 2026 02:32
…preservation into production

Three public claims did not match the code (ADR-034):

- Approvals said they "hold until you approve". `onPermissionRequest` was
  only ever called by `mock-runner.ts`, so in production they never fired.
  The Claude CLI engine now raises every gated tool call through a loopback
  HTTP MCP bridge and holds for the run's remaining wall-clock budget.
  `--permission-prompt-tool` is present in CLI 2.1.261, which falsifies
  ADR-020's premise; the contract matrix was re-run to prove it.

- Docs said every run executes inside a macOS Seatbelt sandbox. The
  generator, symlink resolution, credential-collision refusal and the 13
  escape checks were all real, but no production spawn passed a spec.
  Every engine spawn now routes through `applySandbox()`. `CW_SANDBOX=off`
  is the only way out and it is logged, journaled, and stamped on the report.

- Docs said an interrupted run keeps its worktree. S-39 removed it whenever
  the agent had committed nothing, including on timeout. Pruning is now
  limited to runs that ended cleanly with no git operation in flight.

Under `acceptEdits` the CLI runs Bash without consulting the prompt tool, so
the deny-list floor is enforced by a PreToolUse hook, which fires in every
permission mode.

Also makes the cancel test deterministic. The daemon marks a run 'running'
at spawn, before the child has run a line, so cancelling on that signal
raced the child's first message and `sandboxed` was legitimately null. The
test now waits for the containment stamp, so it interrupts a run that is
genuinely under way. Test-only change; `sandboxedFor` semantics are
unchanged.

Suite: 260 passed, 37 files. Lint and typecheck clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The PreToolUse floor hook exited right after writing its deny reason to
stderr. On macOS a pipe-backed stderr write is asynchronous, so the reason
the model reads could be truncated (the exit code, and therefore the deny,
was never at risk). The hook now exits from the write callback with a
ref'd 1s fallback that still guarantees exit(2).

Docs catch up with what ships: docs/security.md closes the "policy floor
only sees what the CLI asks about" gap with the 2026-09-06 production
probe (npm view still prompts; git push --force is refused with the
deny-list reason; ~60 ms per Bash call), README gains the floor bullet,
the T-007 matrix gets the "floor closed" row, and ADR-035 records three
decisions: the fail-closed hook, exactly one Seatbelt layer for Codex
(macOS refuses to nest a profile inside a deny-default one), and
TERMINAL_CWD for Hermes whose oneshot path ignores --in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vimoxshah

Copy link
Copy Markdown
Owner Author

Addendum, f3b0654 — hardens the floor hook's deny path (stderr flushed before exit(2); the deny itself was never at risk) and lands the docs that describe what ships: ADR-035, the closed "Known gap" in docs/security.md, the README floor bullet, and the T-007 "floor closed" row.

Floor proven end to end through the production runner-child, 2026-09-06 (CLI 2.1.261, acceptEdits):

  • sandbox enabled=true v2 at 0.3s
  • npm view left-pad version → permission raised at 29.7s, held 8s, allowed, executed inside the profile (bridge intact)
  • git push --force origin mainfloor at 95.8s: "force-push to protected branch 'main' is blocked by global deny-list"; never executed; recorded as policy_deny + deny_list_hit
  • echo done ran; outcome completed, exit 0
  • Hook cost: 60 ms median per Bash call (10 allow calls 55–72 ms; deny 59 ms with the full reason on stderr)

In-sandbox engine probes (production applySandbox wrap): OpenCode, Hermes (TERMINAL_CWD) and Codex (-s danger-full-access under our profile — macOS refuses to nest a Seatbelt inside a deny-default one, every allow bisected) each wrote ok.txt in the worktree; /tmp escape denied; ~/.zsh_history read denied.

Release worktree at this head: typecheck 0 · runner 116/116 · daemon 127/127 · UI build 0.

Surfaced, not decided: LICENSE §12's audit set should list permission-server.ts and floor-hook.ts; runs inherit HOME so a developer's permissions.allow rules and SessionStart/SessionEnd hooks apply unattended (the floor hook holds regardless).

vimox-shah-genea and others added 2 commits September 6, 2026 04:58
…ff the environment

Two findings from the pre-release review, both reproduced on this Mac.

A repo committing {"disableAllHooks": true} in .claude/settings.json switched
the PreToolUse floor hook off, so under acceptEdits a floor-listed command
ran unasked while the report still said sandboxed: true. The --settings
payload now pins disableAllHooks: false; CLI-flag settings outrank project
settings, so the pin wins. Probed on CLI 2.1.261: unpinned payload, hook
never ran and the command ran; pinned payload, hook ran and the command was
blocked. Unit test pins the key.

The BYOK provider key travelled in runner-child's environment and was
deleted after being read. macOS keeps a process's exec-time env readable by
any same-user process through sysctl KERN_PROCARGS2, and the profile must
allow sysctl-read, so a sandboxed agent read the key from a sibling child
after the delete. The daemon now writes {t:'credential'} to the child's
stdin pipe right after spawn, only for BYOK runs; the env never contains it.
The child races the message against a 15s timeout and fails with the same
auth text either way (verified against the built child: 0.1s with an empty
message, 15.1s with none).

Also from review: codex/opencode/hermes/api-agent runners now remove their
per-run cw-sb-* profile dir on close, error and spawn failure (the api agent
leaked one per tool call), and the three CLI runners gain the child 'error'
handler they lacked. docs/security.md names the remaining gaps (Bash-only
hook matcher vs repo MCP servers, no bearer token on the loopback bridge);
ADR-035 records both findings.

Runner 124/124, daemon 130/130, typecheck and build clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…YOK env claim

Review follow-ups on the previous two commits. The generated PreToolUse
hook's watchdog now marks the outcome settled before denying, so a bridge
reply landing in the one-second window after a watchdog deny can no longer
turn into exit(0). docs/security.md qualifies the BYOK statement: a config
that reads its key from an environment variable has that key in the
daemon's own exec-time environment by construction, which the same sysctl
path can read; keychain-backed configs are fully closed. Four comments and
test titles that cited ADR-034 for the credential channel and the profile
cleanup now point at ADR-035, where those findings are recorded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vimoxshah

Copy link
Copy Markdown
Owner Author

Review outcome and fixes (067dda2, 9bb01b4). An Opus read-only pass on the whole branch diff initially refuted it on two findings, both reproduced on this machine; both are now closed and re-verified by the same reviewer (VERIFIED WITH CAVEATS, caveats folded in).

  1. Floor bypass via repo settings — closed. A repo committing {"disableAllHooks": true} in .claude/settings.json switched the PreToolUse floor hook off; under acceptEdits a floor-listed command ran unasked while the report still said sandboxed: true. The --settings payload now pins disableAllHooks: false (CLI-flag settings outrank project settings). Re-probed on CLI 2.1.261 against the strongest hostile combination the bundle offers (disableAllHooks, allowManagedHooksOnly, hooksDisabled, an empty hooks.PreToolUse, plus settings.local.json): hook fired, command blocked. Unit test pins the key.
  2. BYOK key readable through the exec-time env — closed for keychain configs. Deleting CW_BYOK_KEY from process.env was not a boundary: macOS keeps a process's exec-time env readable via sysctl KERN_PROCARGS2, and the profile must allow sysctl-read. The credential now travels over the daemon⇄child stdin pipe as a credential message, only for BYOK runs; the env never contains it. Verified on the built child: explicit empty message fails in 0.1 s, silence fails at 15.1 s, same auth text. Caveat documented: auth: 'env' configs keep the key in the daemon's own exec-time env by construction; keychain-backed configs are fully closed.

Also from review: codex/opencode/hermes/api-agent runners now remove their per-run cw-sb-* profile dir on close/error/spawn-failure (the API agent leaked one per tool call) and the three CLI runners gained the child error handler they lacked; the hook watchdog marks itself final; docs/security.md names the remaining gaps (Bash-only matcher vs repo-declared MCP servers; no bearer token on the loopback bridge yet); ADR-035 records everything.

Reviewer's own re-run on this checkout: 39 files, 271/271. Mine: typecheck 0, build 0, runner 124/124, daemon 130/130; production floor E2E passed again on the final dist.

@vimoxshah
vimoxshah merged commit 5a7f9a8 into main Sep 5, 2026
2 checks passed
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.

2 participants