Skip to content

fix(net): close #236 — route-level door reachability in TCP mode - #261

Open
bdelanghe wants to merge 2 commits into
mainfrom
claude/next-kp573h
Open

fix(net): close #236 — route-level door reachability in TCP mode#261
bdelanghe wants to merge 2 commits into
mainfrom
claude/next-kp573h

Conversation

@bdelanghe

@bdelanghe bdelanghe commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #236 (and the tracker for its remaining half, #257). Carries #264 (ADR record correction, #263), merged into this branch.

The hole

On macOS (TCP mode, the platform default) a box holding any door other than net got podman's default network so it could reach host.containers.internal:PORT — and that network carries full outbound NAT. Granting door reachability incidentally granted internet egress: a --keeper-only box could reach any host, with no netd allowlist and no netd audit entry.

#237 closed the "the manifest actively lies about this" half. This closes the hole it was reporting.

The mechanism

The one ADR-NETWORK-POSTURE.md named first: a per-launch --internal podman network (no gateway, no NAT) plus a launcher-owned relay, dual-homed on that network and the default one, running one socat listener per granted door port and forwarding each to the same port on the host. The box joins only the internal network, with host.containers.internal pinned to the relay — so resolveDoor, planDoorMounts and NETD_TCP_PROXY are untouched; the name simply resolves somewhere else.

The forwarded set is derived from the grants (relayPorts), which is what makes this an OCAP mechanism rather than a firewall: what the box is told it holds and what it can reach are the same list by construction.

The truth table

Two cells move to boundary: "route":

netOpen net door transport doors before after
no yes tcp any policed / proxy policed / route
no no tcp ≥1 open / ambient none / route

NetworkPosture gains a mechanism field (netns | relay | default) so networkArgv stays a total function of the one derivation now that route has two realizations. --pod is derived separately — it shares one netns with its in-pod netd on the default network, so it reports default/proxy/ambient rather than inheriting a boundary claim that would be #236 in a new place.

Fail-closed

A relay that cannot be brought up tears down what it built and aborts the launch. Falling back to the default network is exactly the silent widening being fixed, and the manifest has by then already promised a route boundary. DOORS_TCP_RELAY=0 is the deliberate opt-out for a host that cannot provide an internal network — it restores the old posture and reports it as proxy/ambient, so the escape hatch cannot become a quiet lie.

Tests

tests/network-posture.test.ts asserted that the hole exists — that was the thing that had to change. It now asserts the opposite by default, and keeps the old assertion under the opt-out. tests/door-relay.test.ts covers the planners and fail-closed bring-up with an injected runner (no podman needed).

bun test — 467 pass, 0 fail. bun x tsc --noEmit clean.

What is NOT proven here

Per docs/agentic-code-hygiene.md rule 3, and now recorded in ADR-NETWORK-POSTURE.md itself rather than only here — the ADR is what outlives the PR. Three platform properties are unproven:

item host that settles it
--internal really removes the route any Linux host with rootless podman
socat present in the relay image same
--add-host vs podman's own host.containers.internal in /etc/hosts needs a mac — podman there is already inside a VM

Correcting an earlier version of this section, which said the branch "was developed in a Linux container with no podman and no macOS" in a way that implied all three needed a mac. Two of the three do not. isTcpMode() is pure env and the darwin branch in main() only supplies the default, so DOORS_TCP=1 on a Linux podman host — the setup HOSTING.md already specifies — runs this exact path, including the internal network, the relay and the relayPorts derivation. The #236 repro (curl from a --keeper-only box reaching example.com) runs there and should now fail to connect. A Lima VM does not close the third item; it is Linux, so it moves the question rather than answering it.

The live check has not been run. This PR left draft with it outstanding, so it remains the thing to do before merging; the green checks above are unit-level and cannot stand in for it.

Why no session in this org could simply run it: .github-private#613/#614 — the org has a session floor and no room floor, and the devcontainer floor is itself a Docker container with no podman. The session that implements platform-specific work structurally cannot be the one that verifies it.

Also updated

  • The net door's deny rulebook text — it can now truthfully say "no egress door ⇒ no route to any host" on both transports, without naming --network=none as if it were the only mechanism.
  • CAPABILITIES.md's transport caveat and NETD.md's opening claim, for the same reason.
  • ADR-NETWORK-POSTURE.md: status, truth table, the follow-up section rewritten as the record of what landed, and (via docs(adr): record the live platform check as outstanding, not done (#263) #264) the explicit ACCEPTED, UNPROVEN entry plus the host-to-item table above.

[settings] / [org]

None. Related: .github-private#616 adds the missing ci-green-claude-box ruleset — this repo currently has no required check, so nothing mechanical would stop this PR merging red.

On macOS (TCP mode, the platform default) a box holding any door other than
`net` got podman's default network so it could reach `host.containers.internal`
— and that network carries full outbound NAT. Granting DOOR REACHABILITY
incidentally granted INTERNET EGRESS: a `--keeper`-only box could reach any
host, with no netd allowlist and no netd audit entry. #237 made the manifest
report this honestly; this closes the hole it was reporting.

The mechanism is the one ADR-NETWORK-POSTURE.md named first: a per-launch
`--internal` podman network (no gateway, no NAT) plus a launcher-owned relay,
dual-homed on that network and the default one, listening on exactly the
granted doors' ports and forwarding each to the same port on the host. The box
joins only the internal network, with `host.containers.internal` pinned to the
relay — so `resolveDoor`, `planDoorMounts` and `NETD_TCP_PROXY` are untouched;
the name simply resolves somewhere else.

The forwarded set is derived from the grants (`relayPorts`), which is what makes
this an OCAP mechanism rather than a firewall: what the box is told it holds and
what it can reach are the same list by construction.

Two ADR truth-table cells move to `boundary: "route"`: a non-`net` TCP box now
has no egress at all, and a `--net` TCP box's netd is a real boundary rather
than an advisory proxy a raw socket escapes. `NetworkPosture` gains a
`mechanism` field (`netns` | `relay` | `default`) so `networkArgv` stays a total
function of the one derivation now that `route` has two realizations.

Fail-closed throughout: a relay that cannot be brought up tears down what it
built and aborts the launch, because falling back to the default network is
exactly the silent widening being fixed, and the manifest has by then promised a
route boundary. `DOORS_TCP_RELAY=0` is the deliberate opt-out for a host that
cannot provide an internal network — it restores the old posture and reports it
as proxy/ambient, so the escape hatch cannot become a quiet lie.

`--pod` is derived separately: it shares one netns with its in-pod netd on the
default network, so it reports default/proxy/ambient rather than inheriting a
boundary claim that would be #236 in a new place.

tests/network-posture.test.ts asserted that the hole EXISTS; it now asserts the
opposite by default and keeps the old assertion under the opt-out.
tests/door-relay.test.ts covers the planners and fail-closed bring-up without
podman. What those tests do not prove — that podman's `--internal` really
removes the route on a given host — is stated as such in the ADR.

Closes #236.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzZqqmQiSPydKZmvTMqHgH
@bdelanghe
bdelanghe marked this pull request as ready for review August 18, 2026 18:55

Copy link
Copy Markdown
Contributor Author

What a floor would and would not retire from "What is NOT proven here"

Recorded from .github-private#613, which exists because this PR is the bill for a gap in the floors handoff: the org has a session floor (a host a session runs on, #373) and no room floor (a host that can run boxes). The devcontainer floor installs node, github-cli and sshd and is itself a Docker container — no podman — so a session on it is in the same position this branch was developed in. The session that implements platform-specific work structurally cannot be the one that verifies it.

The useful part is that the residue is smaller than "no podman and no macOS" suggests, and it splits cleanly.

TCP mode is forceable off macOS

isTcpMode() is pure env (claude-box.ts:168) and claude-box.ts:3636 only defaults DOORS_TCP=1 on darwin, explicitly so tests stay deterministic on any OS. So DOORS_TCP=1 on a Linux host with rootless podman — the setup HOSTING.md already specifies — runs the exact code path this PR changes, including the --internal network, the relay, and relayPorts derivation.

Against the three unproven claims

claim Linux + podman host
--internal really removes the route proves it. A netavark property; nothing macOS-shaped about it. The #236 repro (curl from a --keeper-only box reaching example.com) runs here and should now fail to connect.
socat present in the relay image proves it. Same image, same bring-up path; the fail-closed abort is observable.
--add-host vs podman's own host.containers.internal in /etc/hosts does not. On macOS podman is already inside a VM, so who writes the entry and which wins is a different fact there.

Two of three plus the live repro, from a host that already has a spec in this repo. The third genuinely needs a mac — worth deciding whether that is "accepted, unproven" and said so in ADR-NETWORK-POSTURE.md, or an owner, rather than left to be rediscovered when someone reads the truth table as fully verified.

Note that a Lima VM does not close the macOS half — it is Linux, so it moves the question rather than answering it.

One thing not to reach for

Adding a docker-in-docker feature to the devcontainer floor would not quietly fix this. Nested podman under a devcontainer is its own network platform, which is the one property a proof about --internal routes cannot afford to be approximate about. Same reason a probe on a managed cloud session would not count even if a nested runtime came up there.

No change requested to the diff — this is about what has to happen before it leaves draft, and where.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Follow-up to the note above, now that this left draft with the live check outstanding: #264 is stacked on this branch and records that fact in ADR-NETWORK-POSTURE.md, which currently says the platform properties were "checked once against a live macOS box" — present tense, for a check that has not run. The PR body and the ADR disagree, and the ADR is what outlives the PR.

It also moves the /etc/hosts arbitration from the PR body onto the ADR's not-proven list, and adds the host-to-item table so the residue is re-enterable: Linux + rootless podman with DOORS_TCP=1 settles the --internal route removal and the relay image's socat; only the /etc/hosts question needs a mac.

32 lines of markdown, no mechanism change, bun test still 467/0. Merge it in before this one, or cherry-pick the single commit onto claude/next-kp573h — whichever you prefer. Tracked as #263.


Generated by Claude Code

)

ADR-NETWORK-POSTURE said the platform properties were "checked once against a
live macOS box" — the intended posture in the present tense, for a check that
has not run. #261's body and the ADR disagreed about whether the mechanism is
verified, and the ADR is what outlives the PR.

- The not-proven bullet gains the /etc/hosts arbitration, which until now lived
  only in the PR body.
- An explicit ACCEPTED, UNPROVEN entry states the live check is outstanding and
  that the #236 curl should now fail to connect.
- A table names which host settles which item, so the residue is re-enterable:
  Linux + rootless podman with DOORS_TCP=1 covers the --internal route removal
  and the relay image's socat; only /etc/hosts needs a mac, because podman
  there is already inside a VM. A Lima VM does not close it.

Markdown only; no source, test or flag changes. bun test 467 pass, 0 fail.

Closes #263.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

security: TCP mode grants full unrestricted internet egress to any box holding a non-net door

1 participant