Skip to content

M14.8: fail closed — a per-domain allowlist is refused, not approximated - #36

Merged
codeitlikemiley merged 3 commits into
mainfrom
m14.8-fail-closed-egress
Aug 23, 2026
Merged

M14.8: fail closed — a per-domain allowlist is refused, not approximated#36
codeitlikemiley merged 3 commits into
mainfrom
m14.8-fail-closed-egress

Conversation

@codeitlikemiley

Copy link
Copy Markdown
Owner

docs/14 §policy specifies a per-domain egress allowlist served by a proxy. The proxy does not exist, so no tier can tell api.github.com from anything else — and both T2 tiers resolved that by opening the gate.

Tier What a non-empty net.allow did
T2 Linux --unshare-net not passed → the host's entire network
T2 macOS (allow network-outbound) + (allow network-bind) → full egress and inbound bind

So a policy naming one host granted every host, the loopback services sitting beside the sandbox, the LAN, and the cloud metadata endpoint — while the field it came from read like a restriction. docs/14 called this "an all-or-nothing switch"; the "on" position was everything.

Never reachable, so this is hardening rather than an incident: every caller in the tree passes NetPolicy::default(), and plugin.toml's net never reached SandboxPolicy. One caller away on two tiers is not a margin worth keeping.

What ships

NetPolicy::enforceable() refuses a non-empty allow at create — wired into T0 and both T2 tiers — with a PolicyViolation naming the field and the reason. Both permissive branches are deleted rather than left unreachable behind the check, so removing the check cannot resurrect them. --unshare-net and (deny network*) are unconditional.

Refused rather than downgraded to full deny, deliberately. Asking for one host and getting every host is the bug above. Asking for network and silently getting none fails later, somewhere less obvious, as a timeout with no reason attached. An error is the only answer that is neither, and it arrives before anything is spawned.

Falsified, both directions

Per the brief — an unfalsified test proves nothing. Restoring enforceable() to a no-op and putting the macOS allowlist branch back:

  • net_policy_tests::a_named_host_is_refused_rather_than_approximated → red on the message.
  • t2_macos_escape::a_named_host_in_the_allowlist_is_refused_not_granted → red with a live SandboxHandle { id: "t2m-0" }, i.e. the sandbox cheerfully creating a session under a policy it cannot honour.

Both green again after restoring: 30/30 lib, 16/16 escape suite.

Not verified locally

t2_linux.rs is #[cfg(target_os = "linux")] and does not compile on this machine. CI is the authority for that file (brief rule 12). The macOS half is verified locally.

Verification

fmt · clippy --workspace --all-targets -D warnings · 1092 workspace tests, 0 failed · schemas · ts-sdk · sbom (609 components) · deny.

Second commit is RUN-REPORT.md, kept out of the milestone commit.

https://claude.ai/code/session_017kFpYDqvz6sKGSkM4YKaRf

docs/14 §policy specifies a per-domain egress allowlist served by a proxy. The
proxy does not exist, so no tier can tell `api.github.com` from anything else —
and both T2 tiers resolved that by opening the gate.

T2 Linux read a non-empty allowlist as "do not pass `--unshare-net`". T2 macOS
emitted `(allow network-outbound)` **and `(allow network-bind)`**. So a policy
naming one host granted the host's entire network, the loopback services sitting
beside the sandbox, the LAN, the cloud metadata endpoint — and, on macOS,
inbound bind — while the field it came from read like a restriction. docs/14
described this as "an all-or-nothing switch", which understated it: the "on"
position was everything.

Never reachable — every caller in the tree passes `NetPolicy::default()`, and
`plugin.toml`'s `net` never reached `SandboxPolicy`. One caller away on two
tiers is not a margin worth keeping.

`NetPolicy::enforceable()` now refuses a non-empty `allow` at `create`, wired
into T0 and both T2 tiers, with a `PolicyViolation` naming the field and the
reason. Both permissive branches are **deleted** rather than left unreachable
behind the check, so removing the check cannot resurrect them; `--unshare-net`
and `(deny network*)` are unconditional.

Refused rather than downgraded to full deny, deliberately. Asking for one host
and getting every host is the bug above; asking for network and silently getting
none fails later, somewhere less obvious, as a timeout with no reason attached.
An error is the only answer that is neither, and it arrives before anything is
spawned.

Falsified, both directions, per the brief: restoring `enforceable()` to a no-op
and the macOS allowlist branch turns
`net_policy_tests::a_named_host_is_refused_rather_than_approximated` red on the
message, and the escape-suite case red with a live `SandboxHandle` — the sandbox
cheerfully creating a session under a policy it cannot honour. Green again after
restoring.

docs/14 says the old description was wrong and why, rather than quietly
correcting it.

Not verified locally: `t2_linux.rs` is `#[cfg(target_os = "linux")]` and does not
compile on this machine. CI is the authority for that file.

Verified: fmt, clippy -D warnings, 1092 workspace tests, schemas, ts-sdk, sbom,
deny.

Claude-Session: https://claude.ai/code/session_017kFpYDqvz6sKGSkM4YKaRf
CI's Linux `check` job caught this; nothing on this machine could. Making
`--unshare-net` unconditional left `Session.net` unread and `NetPolicy` imported
only for a comment, both of which are errors under `-D warnings`.

The field is deleted rather than silenced. It existed so `build_args` could
decide whether to unshare the network namespace; that decision is gone — the
answer is always yes — and `NetPolicy::enforceable` guarantees at `create` that
`allow` is empty, so a stored copy would record a constant. When the egress
proxy exists, what belongs there is the proxy's endpoint, not the policy.

Worth recording for the next platform-conditional change: `cargo check --target
x86_64-unknown-linux-gnu` does not work here either, and not for the reason
handover.md gives. It is `zstd-sys` (pulled in through wasmtime) whose build
script needs `x86_64-linux-gnu-gcc`, so even a link-free `check` fails in a
build script. There is no local Linux verification of any kind for this crate;
CI is the whole authority.

Verified as far as this machine allows: `clippy -p panday-sandbox --all-targets
-- -D warnings` clean.

Claude-Session: https://claude.ai/code/session_017kFpYDqvz6sKGSkM4YKaRf
@codeitlikemiley
codeitlikemiley marked this pull request as ready for review August 23, 2026 18:45
@codeitlikemiley
codeitlikemiley merged commit 54d94ce into main Aug 23, 2026
6 checks passed
@codeitlikemiley
codeitlikemiley deleted the m14.8-fail-closed-egress branch August 23, 2026 18:45
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