fix(sandbox): --with-claude never runs NET_ADMIN without its firewall#488
Merged
Conversation
`resolveClaudeNetworkArgs` called `applyNetworkPolicy("egress-allowlist", ...)`
and took only `.runArgs`, discarding `.firewallScript`. Its own comment said
"The firewall script must still be applied INSIDE the box by the caller" - and
no such caller exists. Nothing in production referenced `firewallScript` at all;
only tests did.
So selecting `anthropic-only` produced a container with `--cap-add NET_ADMIN
--cap-add NET_RAW` on a custom bridge and NO iptables rules: wide-open egress
PLUS the capability to manipulate its own networking, around a live OAuth token.
That is strictly worse than plain default networking, and it is the posture an
operator picks precisely when they want the box locked DOWN.
THE FIX, in two halves that have to go together:
* The flags and the rules now travel as one plan. Returning only the argv is
what let the script be dropped on the floor, so the resolver hands back
both, and `anthropic-only` throws rather than proceeding if network.ts ever
produces no script - an unenforced allowlist under NET_ADMIN is worse than
no allowlist.
* `runClaudeInside` installs the rules inside the box after start, and
DESTROYS the box if it cannot. Returning the id would tell the caller it is
locked down when it is not, which is the failure that actually matters. The
teardown is best-effort and deliberately not conditional on its own success:
if it also fails there is nothing further this process can do, and the
thrown error names the container so an operator can finish the job.
`offline` still execs nothing. It has no interface, so there is nothing to
firewall and an extra exec would be pure attack surface.
The script it installs is the one network.ts produced, asserted directly against
`applyNetworkPolicy(...).firewallScript` rather than re-derived - a second
derivation is a second thing that can drift from the allowlist.
WHY THIS WAS SAFE UNTIL NOW, AND WHY IT IS FIXED FIRST. `runClaudeInside` has
zero callers, and esbuild tree-shakes it out entirely: `grep -c runClaudeInside
sandbox.js` is 0, so the documented `--with-claude` feature is not merely
unwired, it is ABSENT from the shipped artifact. #377 is the issue to give it a
caller. Fixing the posture before it becomes reachable is the whole point, and
it is why sandbox.js is unchanged by this commit.
5 new tests. Verified red first: 3 failed against the old implementation, with
the box started and no exec performed. Then mutation-tested after: skipping the
firewall exec -> 3 failed, dropping the teardown -> 1 failed, discarding the
script again -> 3 failed. Every half has a test that notices its absence.
Full ca-sandbox suite 263 passed across 20 files; typecheck clean.
Refs #377 - this is the security half. The gated `claude-inside.js` entrypoint,
which is what gives this code a caller, follows separately.
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.
The defect
resolveClaudeNetworkArgscalledapplyNetworkPolicy("egress-allowlist", ...)and took only.runArgs, discarding.firewallScript. Its own comment said:There is no such caller. Nothing in production referenced
firewallScriptat all — only tests did.So selecting
anthropic-onlyproduced a container with--cap-add NET_ADMIN --cap-add NET_RAWon a custom bridge and no iptables rules: wide-open egress plus the capability to manipulate its own networking, around a live OAuth token.That is strictly worse than plain default networking — and it is the posture an operator picks precisely when they want the box locked down.
The fix, in two halves that have to go together
The flags and the rules travel as one plan. Returning only the argv is what let the script be dropped on the floor. The resolver now hands back both, and
anthropic-onlythrows rather than proceeding ifnetwork.tsever produces no script — an unenforced allowlist underNET_ADMINis worse than no allowlist.runClaudeInsideinstalls the rules inside the box, and destroys the box if it can't. Returning the id would tell the caller it is locked down when it is not, which is the failure that actually matters. The teardown is best-effort and deliberately not conditional on its own success: if it also fails there is nothing further this process can do, and the thrown error names the container so an operator can finish the job.offlinestill execs nothing — it has no interface, so there is nothing to firewall and an extra exec would be pure attack surface.The script it installs is the one
network.tsproduced, asserted directly againstapplyNetworkPolicy(...).firewallScriptrather than re-derived. A second derivation is a second thing that can drift from the allowlist.Why this was safe until now, and why it's fixed first
runClaudeInsidehas zero callers, and esbuild tree-shakes it out entirely —grep -c runClaudeInside sandbox.jsis0. The documented--with-claudefeature is not merely unwired; it is absent from the shipped artifact. That is also whysandbox.jsis unchanged by this commit.#377 is the issue to give it a caller. Fixing the posture before it becomes reachable is the whole point.
Verification
Red first: 3 failed against the old implementation — the box started and no exec was performed.
Mutation-tested after:
5 new tests. Full ca-sandbox suite 263 passed across 20 files; typecheck clean.
Refs #377 — this is the security half. The gated
claude-inside.jsentrypoint, which is what gives this code a caller, follows separately.