fix(eval): make the subject isolation contract hold - #2960
Conversation
0755eef to
99fcbf3
Compare
a37a22e to
af2ebc6
Compare
`packages/eval/README.md` states that the subject receives only what it needs to trust the per-cell CA, that all its traffic is forced through the audited proxy, and that the proxy blocks benchmark and public-solution contamination URLs. Four places let that contract silently not hold. The subject could read the CA private key. mitmproxy's confdir was the shared state volume, so the volume the subject mounts to trust the CA also carried `mitmproxy-ca.pem` and `hits.jsonl`, which records which of the subject's own requests were blocked. The proxy now creates the CA before it starts serving, keeps confdir and the audit log container-private, and publishes only `mitmproxy-ca-cert.pem` into a certificate-only volume, by rename so a restart cannot expose a truncated certificate. Publishing before the port opens means the health gate cannot release `main` against a missing or left-over certificate. The subject-visible path and `containerCaPath` are unchanged; the audit artifact is collected from the proxy service and follows the log. The namespace policy accepted outbound ICMP and ICMPv6 unconditionally while rejecting every other non-TCP protocol. Harbor needs no ICMP here, so both accepts are gone and no narrowed exception replaces them. The subject could forge the sidecar's packet mark. The policy returned and accepted on that mark as a trusted identity, but the sidecar shares the subject's network namespace, so a mark the sidecar can set is one the subject can set too — Docker grants NET_RAW by default, and on Linux 5.17 and later NET_RAW alone is enough for SO_MARK. The exemption also protected nothing: proxy-only mode empties gost's allowlist, so gost forwards no traffic that would need it. Both rules are gone rather than defended. Removing them cannot widen the policy — traffic gost would have carried is refused in this mode either way — and unlike dropping the capability from `main`, it cannot be undone by a task's own compose declaring `cap_add`, which wins over an overlay's `cap_drop`. Contamination rules never saw the hostname. The search subject was the path and query only, so only the exact `tbench.ai` host matched and `https://terminal-bench.io/tasks/answers` was allowed through. The host is now searched alongside the path, as a separate field so that no rule can match across their boundary, and `tbench.ai` subdomains count as the domain. A pier spec could declare an egress proxy pier never enforces. `decodeOptions` accepted `egressProxy` for both frameworks, but only Harbor's branch of `run_trial.py` applies the namespace policy, so the proxy and its environment would be set up while enforcement did not exist. Decoding a pier spec that declares it now fails with that reason. `test_run_trial_policy.py` only asserts that two Python attributes are assigned, which nftables rules failing outright would not disturb. `harbor/test_cell_egress_namespace.py` brings up the checked-in overlay and policy against a minimal Harbor-shaped base, with a sidecar sharing the subject namespace, and asserts the contract in a real cell namespace. Every negative assertion is vacuous on a host that cannot reach the target anyway, so each target's reachability is asserted before the policy exists: failing there is the point, because a skip would report a contract the run never exercised. Generated-by: Claude Code
af2ebc6 to
e75f7da
Compare
M4n5ter
left a comment
There was a problem hiding this comment.
English
Blocking: main still has Docker’s default NET_RAW capability, so the subject can use an AF_PACKET socket to send traffic below these nftables output hooks.
I reproduced this after applying the new policy: normal UDP was rejected, but an AF_PACKET DNS request reached 8.8.8.8 and received a response without going through the proxy. Dropping NET_RAW prevented the socket from being created.
Please remove NET_RAW from main, fail closed if a task re-adds equivalent privileges, and cover this path in the namespace test. Otherwise the isolation contract this PR aims to establish still does not hold.
中文
阻塞问题: main 仍保留 Docker 默认的 NET_RAW capability,因此 subject 可以通过 AF_PACKET socket,在当前 nftables output hooks 之下直接发送流量。
我在应用新 policy 后复现了该问题:普通 UDP 会被拒绝,但 AF_PACKET DNS 请求仍能直接访问 8.8.8.8 并收到响应,完全不经过代理;移除 NET_RAW 后该 socket 无法创建。
请从 main 移除 NET_RAW,在任务重新添加等效权限时 fail closed,并在 namespace 测试中覆盖该路径。否则本 PR 要建立的隔离契约仍然不成立。
The namespace policy hooks the IP output path, so it cannot see a socket that writes below it. With Docker's default capabilities the subject can open an `AF_PACKET` socket and send a frame straight to the gateway's hardware address: reproduced in a cell with the full policy applied, where a DNS query reached `8.8.8.8` and its answer came back while ordinary UDP to the same address was rejected. `NET_ADMIN` is equivalent in effect for a different reason — it can delete the ruleset outright. Dropping `NET_RAW` from `main` closes the socket, but it cannot be the whole answer: this overlay is merged onto the task's own Compose file, and a `cap_add` there wins over a `cap_drop` here, so any task declaring either capability would silently restore the bypass. The relay therefore reads the subject's effective capability set inside the subject container, after the policy is live and before the subject exists, and fails the attempt when it holds `NET_RAW` or `NET_ADMIN`. An unreadable capability set fails the same way. Both layers are needed: without the drop every task fails, and without the check the drop is advisory. The namespace test gains a link-layer probe that builds the IP and UDP headers itself and sends through `AF_PACKET`. Unlike the other negative assertions it carries no pre-policy reachability assertion, because this path is closed by the missing capability rather than by the policy and so is already shut before the policy exists. It asserts `denied` rather than `blocked` so that a probe failing for any other reason cannot be read as the contract holding. Reported by @M4n5ter in review, who reproduced the bypass independently. Generated-by: Claude Code
|
Confirmed, and thank you — this was a real hole and the reproduction was exact. I reproduced it in a cell with the full policy applied: an This one is mine. An earlier revision of this PR did drop Fixed in 696ead2 with both layers, since neither is sufficient alone:
Coverage: the namespace test gains a link-layer probe that builds the IP and UDP headers itself. It asserts One thing worth raising separately: all of this is patching around the fact that the subject shares a network namespace with the sidecar enforcing the policy, which makes them peers. Giving the subject its own namespace whose only route out is the proxy would make isolation a property of topology rather than of remembering every layer a capability can reach past. That conflicts with Harbor's phase model — network capability changes between task download, |
M4n5ter
left a comment
There was a problem hiding this comment.
English
Blocking: three isolation paths remain:
-
The relay only quiesces the recorded process group. A subject child can call
setsid(), outlive that group, then regain public network access when Harbor restores the verifier policy. I reproduced the original scope becoming inactive while the detached child remained alive. Cleanup needs a container/cgroup-owned boundary, or the restricted policy must remain untilmainis stopped. -
The capability check reads only
CapEff. With a non-root agent, the probe can report zero while a file-capability executable reacquiresNET_RAWfromCapBndand opensAF_PACKET. Please fail closed on all capability sets that can retain or reacquireNET_RAW/NET_ADMIN. -
Harbor respects task-authored
main.networks/network_modeand excludesmainfrom the generated sidecar namespace overlay, while still applying the policy inside the sidecar. The relay then admits an unrestrictedmain. Please reject this configuration or attest thatmainand the policy sidecar actually share the same network namespace before starting the subject.
These paths directly violate the isolation contract this PR aims to establish, so I cannot approve it yet.
中文
阻塞问题: 目前仍有三条隔离绕过路径:
-
Relay 只清理记录的进程组。Subject 子进程可以调用
setsid()脱离该进程组,并在 Harbor 为 verifier 恢复公网策略后继续运行。我已复现原 scope 被判定为 inactive、但 detached child 仍存活。这里需要由容器/cgroup 持有完整的清理边界,或者在停止main前始终保留限制策略。 -
Capability 检查只读取
CapEff。非 root agent 的探测结果可以为零,但带 file capability 的可执行文件仍能从CapBnd重新获得NET_RAW并打开AF_PACKET。请对所有可能保留或重新获得NET_RAW/NET_ADMIN的 capability 集合 fail closed。 -
Harbor 会尊重 task 自带的
main.networks/network_mode,并将main排除在生成的 sidecar namespace overlay 之外,但仍在 sidecar 内应用策略,之后 Relay 会放行不受限制的main。请拒绝这种配置,或在启动 subject 前确认main与策略 sidecar 实际共享同一个 network namespace。
这些路径都会直接破坏本 PR 要建立的隔离契约,因此目前还不能 approve。
The relay read only the effective capability set. A non-root subject reports that set empty while a file-capability executable reacquires whatever the bounding set kept, so a task could restore NET_RAW and open an AF_PACKET socket below the policy's output hooks with the gate seeing nothing. Every set the subject could raise or reacquire one from is read now, the bounding set included. Harbor applies the policy inside its sidecar but respects a task's own networking on the subject service, so a task that declares `networks` on `main` leaves the subject in a namespace no policy was ever applied to while the sidecar reports success. The same gate now also refuses to start a subject that is not in the policy's namespace, using the sidecar proxy's listening socket as evidence, which is visible only from inside that namespace. Rejecting the configuration instead would mean reimplementing Harbor's own service-selection rule here and letting the two drift; observing the outcome cannot drift. The namespace test grew a stand-in for the sidecar proxy listener. Its absence was letting the direct-TCP assertion hold on a refused connection rather than on the policy: the policy redirects TCP to that listener rather than dropping it, so in a real cell the connection completes and only the response distinguishes a direct route from a redirected one. The probe now judges the response. The packet-mark probe is gone with it — without NET_RAW the subject cannot set a mark at all, so it had stopped measuring the rule it named and only restated that the capability was dropped; the rule's absence is asserted where the rule is written. Reported by @M4n5ter in review. Generated-by: Claude Code
The namespace policy redirected TCP and rejected only non-TCP, so what reached the filter chain still carrying a public destination was accepted. NAT is evaluated on a connection's first packet, which means a connection opened during an earlier phase kept flowing in full, unproxied, for the whole agent phase. Reproduced against a live cell: a keep-alive TLS connection opened under `allow-all` served a second request after the proxy-only policy was applied, and stops doing so once the chain rejects everything it has not already accepted. That catch-all is also one rule where there were two. gost's allowlist goes with it. The sidecar entrypoint empties the file at start and this policy never writes a host into it, so truncating it again and then sleeping for gost's reload interval waited on a file that cannot change — two seconds of every cell, twice in the deny-all path. The contamination rules for the pinned revision and the known patch mirror searched only the path. A DNS label holds a benchmark name just as well, which the general `terminal-bench` rule already accounted for. The compose assertions in the lifecycle test matched the whole file, so moving the capability drop or the read-only flag onto the proxy service left them green while the subject regained both. They are scoped to the subject's own block now. The isolation gate had no live coverage of its refusals: the contract test's environment fabricates the probe output instead of running it, so a probe reporting one constant answer passed. The namespace test grew two services the gate must refuse — one outside the policy's namespace, one whose empty effective set hides a bounding set that still carries NET_RAW — and both mutations now fail. The README claimed the namespace forces all subject traffic through the audited proxy. Docker's embedded resolver forwards names it does not own to the host's upstream resolvers, and the namespace-local exemption that keeps the loopback provider proxies reachable keeps that reachable too. Reproduced: a TXT query for an external zone returned real records while every other path was blocked. Removing the channel needs the subject to reach the proxy without resolving its name, which is a change to how the cell is addressed, not to this ruleset. The claim is corrected and the gap stated; the same goes for a CONNECT tunnel carrying neither TLS nor HTTP, which reaches no rule and no audit record. Reported by @M4n5ter in review, and by three adversarial model reviews. Generated-by: Claude Code
Harbor's deny-all denies controlled egress outright. This override answered it with the proxy-only ruleset, so a phase that declared `no-network` was handed a route to the audited proxy that Harbor never promised it. It now installs the same ruleset without the proxy exemption, and `show` reports which of the two is live. The two modes differ only in whether that exemption is present, so they are one ruleset with one optional pair of rules rather than two that have to be kept in step. Generated-by: Claude Code
Generated-by: Claude Code
|
Three findings, three different outcomes. Capability sets other than the effective one — fixed, at the gate. Confirmed before fixing: a non-root container reports A subject outside the policy's namespace — fixed at the gate, not in the decoder. Detached children surviving into the verifier phase — not fixed, and here is why. The obvious local patch is to sweep and kill what the process group missed. That would also kill a service a task deliberately left running for its verifier to find, trading a leak for wrong scores. Your two sound remedies — a container- or cgroup-owned cleanup boundary, or keeping the restricted policy until Your review then prompted three fresh adversarial reviews of the branch, which found more:
Two gaps are stated rather than closed, both reproduced: Docker's embedded resolver at Ready for another look. |
The overlay's cap_drop and the relay's capability gate both act on the subject service alone. A sibling service a task declares joins the same network namespace carrying the default capability set, which includes NET_RAW, so the AF_PACKET path the drop closes for the subject stays open in that namespace. Stating the scope keeps a reader from taking the gate as a property of the namespace rather than of one service. Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
English
The previous blockers are addressed:
- The relay now checks every capability set that can retain or reacquire
NET_RAWorNET_ADMIN, including the bounding set. - It fails closed when the subject is outside the policy namespace.
- The nftables rules now reject connections established before the restricted phase, and
deny-allno longer permits proxy access.
The namespace check can still be confused if a task-owned namespace already has a listener on the internal port, but that requires a task with custom networking and an exact port collision. It is not reachable in the current pinned Terminal-Bench cohort and can be handled as a follow-up with direct namespace identity comparison.
Approved.
中文
之前的阻塞问题已经解决:
- Relay 现在会检查所有能够保留或重新获得
NET_RAW、NET_ADMIN的 capability set,包括 bounding set。 - Subject 不在策略 namespace 内时会 fail closed。
- nftables 现在会拒绝限制阶段之前建立的连接,
deny-all也不会再允许代理访问。
如果 task 自带网络且恰好已有进程监听内部端口,当前 namespace 检查仍可能误判;但这需要自定义网络与精确端口碰撞同时发生,在当前固定的 Terminal-Bench cohort 中不可达,可以后续改为直接比较 namespace identity。
批准合并。
The gate asks whether the subject is in the namespace the egress policy was applied to. It answered that by looking for the sidecar proxy's listening socket, which is visible only inside its own namespace — true, but a proxy: a task that declares its own networking and happens to listen on the same port in it satisfies the evidence without satisfying the claim. Harbor installs the policy by running network-policy inside the egress sidecar, so that service's namespace is what the claim refers to. The environment interface the relay already holds can reach it, so read /proc/self/ns/net on both sides and require one identity. An inode has no collision space, which removes the case entirely rather than making it less likely. The port constant and the cross-language pin that kept it in step with the policy script are gone; the pin now covers the sidecar service name, which is the coupling that remains. Answers that are not the kernel's own link form are rejected rather than compared, so two sides that both failed to answer cannot compare equal. Generated-by: Claude Code
Summary
packages/eval/README.mdstates that the subject receives only what it needs to trust the per-cell CA, that all its traffic is forced through the audited proxy, and that the proxy blocks benchmark and public-solution contamination URLs. Five places from #2947 let that contract silently not hold.The CA private key was readable by the subject. mitmproxy's
confdirwas the shared state volume, so the volume the subject mounts to trust the CA also carriedmitmproxy-ca.pemandhits.jsonl, which records which of the subject's own requests were blocked. The proxy now creates the CA before it serves, keeps confdir and the audit log container-private, and publishes onlymitmproxy-ca-cert.peminto a certificate-only volume, by rename so a restart cannot expose a truncated certificate. Publishing before the port opens means the health gate cannot releasemainagainst a missing or left-over certificate. The subject-visible path andcontainerCaPathare unchanged.The namespace policy accepted ICMP unconditionally while rejecting every other non-TCP protocol. Harbor needs no ICMP here, so both accepts are gone and no narrowed exception replaces them.
The subject could forge the sidecar's packet mark and bypass the proxy entirely. The policy returned and accepted on that mark as a trusted identity — but the sidecar shares the subject's network namespace, so a mark the sidecar can set is one the subject can set too. Verified in a real cell before the fix:
setsockopt(SOL_SOCKET, SO_MARK, 114514)followed by a direct connection to1.1.1.1:443succeeded, skipping both the NAT redirect and the filter chain (Docker grantsNET_RAWby default, and on Linux 5.17+NET_RAWalone suffices forSO_MARK).The exemption also protected nothing: proxy-only mode empties gost's allowlist, so gost forwards no traffic that would need it. Both rules are therefore deleted rather than defended — removing them cannot widen the policy, since traffic gost would have carried is refused in this mode either way.
The subject could also send below the policy entirely. The rules hook the IP output path, so a socket that writes beneath it is invisible to them. With Docker's default capabilities the subject can open an
AF_PACKETsocket and address a frame to the gateway's hardware address: reproduced in a cell with the full policy applied, where a DNS query reached8.8.8.8and its answer came back while ordinary UDP to the same address was rejected. Reported by @M4n5ter, who found it independently.NET_ADMINis equivalent in effect for a different reason — it can delete the ruleset outright.The overlay drops
NET_RAW, which closes the socket. That alone is not enough: this overlay is merged onto the task's own Compose file, and acap_addthere wins over acap_drophere, so any task declaring either capability would silently restore the bypass — confirmed withdocker compose configanddocker run --cap-drop NET_RAW --cap-add NET_ADMIN. So the relay also reads the subject's effective capability set inside the subject container, after the policy is live and before the subject exists, and fails the attempt when it holdsNET_RAWorNET_ADMIN; an unreadable capability set fails the same way. Both layers are needed: without the drop every task fails, and without the check the drop is advisory.Contamination rules never saw the hostname. The search subject was path and query only, so only the exact
tbench.aihost matched andhttps://terminal-bench.io/tasks/answerswas allowed through. The host is now searched alongside the path, as a separate field so no rule can match across their boundary.pier could declare an egress proxy it never enforces.
decodeOptionsacceptedegressProxyfor both frameworks, but only Harbor's branch ofrun_trial.pyapplies the namespace policy. Decoding a pier spec that declares it now fails with that reason.Fixes #2958, which covers the first two. The rest come from the approving review on #2947 and from adversarial review of this branch; the remaining follow-ups there are not about isolation and are out of scope.
Verification
test_run_trial_policy.pyonly asserts that two Python attributes are assigned, which nftables rules failing outright would not disturb — the coverage gap the review named.harbor/test_cell_egress_namespace.pybrings up the checked-in Compose overlay andnetwork-policyagainst a minimal Harbor-shaped base, with a sidecar sharing the subject namespace, and asserts the contract in a real cell namespace. It is opt-in (MAKA_EVAL_EGRESS_NAMESPACE_TEST=1).Every negative assertion is vacuous on a host that cannot reach the target anyway, so each target's reachability is asserted before the policy exists. Failing there is deliberate: an earlier revision skipped instead, and that skip silently retired one of the assertions for an unrelated reason (see below).
MAKA_EVAL_EGRESS_NAMESPACE_TEST=1 python3 harbor/test_cell_egress_namespace.py— passes on Docker 29.7.2, all eight assertions executing. With the policy applied:ls -A /opt/maka-egressshows exactlymitmproxy-ca-cert.pem; explicit-proxy HTTPS succeeds;curl --noproxy '*', direct-IP TCP, a forgedSO_MARK, a link-layerAF_PACKETframe, external UDP and external ICMP all fail; the loopback provider proxy and Docker DNS still work.Every assertion was checked against the mutation it exists to catch, and fails on it:
acceptlines innetwork-policy→[external ICMP] AssertionError: 0 == 0meta markrules →[forged sidecar packet mark] AssertionError: 'reachable' != 'blocked'cap_drop: [NET_RAW]→[link-layer AF_PACKET] AssertionError: 'reachable' != 'denied'NET_ADMINfrom the relay's bypass set → the capability contract test fails[curl --noproxy] AssertionError: 0 == 0and[direct IP TCP] AssertionError: 'reachable' != 'blocked'confdirback at the shared volume → the listing becomes['hits.jsonl', 'mitmproxy-ca-cert.cer', 'mitmproxy-ca-cert.p12', 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.p12', 'mitmproxy-ca.pem', 'mitmproxy-dhparam.pem']Noneinstead of a ruleegressProxyfor any framework →✖ pier cannot declare an egress proxy it never enforcesCapEfffrom the probe → the bounded-capability service is admitted, live and in the contract testcap_dropor the read-only flag onto the proxy service → the compose assertions faildeny-allwith the proxy-only ruleset →[no-network] AssertionError: 0 == 0meta l4proto != tcp reject→ a connection opened before the policy keeps serving requests after itAlso run: root
npm run build,npm run test:distinpackages/eval(31 node tests, all Python suites),npm run format:checkat the root. No full-repository test run.Two things worth confirming outside this change. The policy's
priority dstnaton a nat output chain needs nftables 1.1 or newer; Debian bookworm's 1.0.6 rejects it outright, which is why the test sidecar is built on trixie — Harbor's own sidecar image is outside this PR, but on 1.0.x the policy would never apply at all. Andtest:distruns in CI withoutMAKA_EVAL_EGRESS_NAMESPACE_TEST=1, so this test currently skips there; wiring a Docker-capable job for it is a separate infrastructure decision.Review record
Reviewed adversarially in two rounds by Codex, kimi-k3-256k, and a Claude subagent, run independently against this branch; the second round was given no knowledge of the first. Their findings were verified against source and by running the cell before being accepted; severities here are mine, not theirs.
Round one accepted: the forged-mark bypass (Codex only, reproduced in a live cell), the cross-field false positive introduced by the host-matching commit, the all-or-nothing skip in the test baseline, a vacuous compose assertion, the undiagnosable pier error, and several deletions.
Round two accepted, each reproduced first: the
cap_droplayer being overridable by the task's own compose (two reviewers), which replaced that fix with deleting the mark exemption; a UDP probe sending a malformed one-byte datagram, whose reply never comes, so that assertion had never once executed; a--noproxyassertion neutered by its ownCURL_CA_BUNDLE; the per-capability skip machinery itself, whose failure mode is reportingOK (skipped)for a contract the run never exercised; a README over-claim about which rules match the host; a missingmkdir -p; and a class-cleanup leak.Rejected with reasons: replacing the capability check with an nftables cgroup match (unnecessary once nothing trusts the mark, and unreliable across cgroup namespaces); an execution-side pier guard for a state the decoder makes unreachable; deleting audit rules that are subsumed for the block decision but distinct in the audit label; and pinning the throwaway test images.
Round five closes the one case @M4n5ter left open when approving: the namespace check read the sidecar proxy's listening socket, which is visible only inside its own namespace but is still a proxy for the claim — a task that declares its own networking and happens to listen on the same port in it satisfied the evidence without sharing the namespace. The claim refers to the namespace Harbor installs the policy in, and the environment interface the relay already holds reaches that service, so the gate now reads
/proc/self/ns/neton both sides and requires one identity. An inode has no collision space, so the case is removed rather than made unlikely. Net deletion: the port constant and the cross-language pin that kept it in step with the policy script are gone, and the pin now covers the sidecar service name, which is the coupling that remains. New mutations, each verified to fail: dropping the comparison; reading the policy side from the subject instead of the sidecar; removing the check that an answer is the kernel's own link form, without which two sides that both failed to answer compare equal; and renaming the service on either side of the pin.Round four came from @M4n5ter's second review plus three fresh adversarial model reviews run against the branch. Accepted, each reproduced first: capability sets other than the effective one, where a non-root subject reports nothing while a
cap_net_raw+epexecutable takesNET_RAWback from the bounding set; a subject left outside the namespace the policy was applied to, because Harbor respects a task's own networking on the subject service and applies the policy in the sidecar regardless; a connection opened in an earlier phase surviving the policy switch in full, since the redirect is a NAT rule and NAT sees only a connection's first packet;deny-allbeing answered with proxy access; compose assertions that matched anywhere in the file; and an isolation gate whose refusals had no live coverage at all, because the contract test's environment fabricates the probe output rather than running it.Deleted rather than fixed: the packet-mark probe. With
NET_RAWdropped the subject cannot set a mark, so it had stopped measuring the rule it named and only restated that the capability was gone.Rejected with reasons: rejecting a task's own networking in the decoder rather than observing the outcome, which would reimplement Harbor's service-selection rule here and let the two drift; replacing the probe with behavioural socket tests, which no task image is guaranteed to be able to run — the gate has
sh,sedandgrepand nothing more; and calling the gate forgeable, which it is, by a task image that already controls everything else in the cell. What the gate holds against is the subject, which starts only after it has passed, and a task that loses the isolation by accident. The README says so now.Not fixed here and reported separately: Docker's embedded resolver at
127.0.0.11forwards names it does not own to the host's upstream resolvers, and the namespace-local exemption that keeps the loopback provider proxies reachable keeps that reachable too. Reproduced by three reviewers and again here — a TXT query for an external zone returned real records while every other path was blocked. Closing it means letting the subject reach the proxy without resolving its name, which changes how the cell is addressed rather than what this ruleset says. ACONNECTtunnel carrying neither TLS nor HTTP likewise reaches no contamination rule and no audit record. Both are stated in the README instead of being left implied.Round three came from @M4n5ter's review on this PR: the
AF_PACKETlink-layer bypass, which none of the three model reviewers found and which my own removal ofcap_drophad left open — I had weighed that capability only againstSO_MARK. Reproduced before fixing, and both the capability drop and the relay's fail-closed check are covered by mutation-verified tests.A more fundamental fix exists and is deliberately not in this PR: give the subject its own network namespace whose only route out is the proxy, so isolation comes from topology rather than from packet filtering, and no capability can reach past it. That conflicts with Harbor's phase model, which switches network capability between task download,
Agent.run(), and verification — topology is a container-lifetime property. It belongs in a separate architecture decision.This is an AI-assisted review; it does not substitute for independent human review.
Checklist
Does this PR entail a change in behavior?
Generated-by: Claude Code