Skip to content

fix(sandbox): --with-claude never runs NET_ADMIN without its firewall#488

Merged
SUaDtL merged 1 commit into
mainfrom
fix/377-with-claude-firewall
Jul 26, 2026
Merged

fix(sandbox): --with-claude never runs NET_ADMIN without its firewall#488
SUaDtL merged 1 commit into
mainfrom
fix/377-with-claude-firewall

Conversation

@SUaDtL

@SUaDtL SUaDtL commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

The defect

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 (network.ts owns it)

There is no such caller. 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 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-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, 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.

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's fixed first

runClaudeInside has zero callers, and esbuild tree-shakes it out entirelygrep -c runClaudeInside sandbox.js is 0. The documented --with-claude feature is not merely unwired; it is absent from the shipped artifact. That is also why sandbox.js is 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:

mutation result
skip the firewall exec 3 failed
drop the teardown on failure 1 failed
discard the script again (the original defect) 3 failed

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.js entrypoint, which is what gives this code a caller, follows separately.

`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.
@SUaDtL
SUaDtL merged commit c193807 into main Jul 26, 2026
32 checks passed
@SUaDtL
SUaDtL deleted the fix/377-with-claude-firewall branch July 26, 2026 10:02
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