Skip to content

feat(infra): native network enforcement collector (B5 PR4) - #1939

Open
ai-mountain wants to merge 6 commits into
agent/coding-native-probe-bindingfrom
agent/coding-native-network-collector
Open

ai-mountain wants to merge 6 commits into
agent/coding-native-probe-bindingfrom
agent/coding-native-network-collector

Conversation

@ai-mountain

@ai-mountain ai-mountain commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1934 (agent/coding-native-probe-binding). B5 PR4: the network_enforcement collector only. Resource, pre-exec and cleanup collection stay in PR5.

Default-off. Nothing here has run on a host. No Terraform, Ansible apply, SSH, gcloud, secret, signing key or production write was used. The collector is not wired into coding-hosted-operate or any workflow step, and it never produces an approval.

What it adds

Probe runner agent (dittobench-coding-enforcement-probe)

  • net-agent answers one JSON request per line, on stdin/stdout or on one owner-only Unix socket.
  • net-once runs a fixed plan of connect attempts. It waits behind a collector gate (a FIFO read with a 120 s deadline).
  • Checks are handshake-only:
    • TCP connect then close, with no bytes written;
    • established flows checked with keepalive and TCP_USER_TIMEOUT only;
    • the refusing proxy gets one CONNECT line, and the candidate's resolver gets one DNS question.
  • Unknown errors are reported as probe_error. The agent never decides whether a probe matched.

Collector (infra/scripts/collect-coding-native-enforcement.py, the path the verifier already pins)

  • Usage: network --config FILE --confirm "COLLECT NATIVE NETWORK ENFORCEMENT EVIDENCE".
  • The resource, preexec and cleanup subcommands exit 2.
  • Runs the four catalog phases: active, stop_rollback, expiry, failed_start.
  • Writes the record to the store. Exits 3 if any probe did not match or residue remains.

Verifier

  • Network records now require a closed network_binding:
    • worker cgroup and nft table (constants);
    • normalized scoped and deny ruleset digests, which must differ;
    • scoped output and input rule counts, recomputed from the profile;
    • the refusing proxy unit and its script digest.
  • Other record kinds must not carry network_binding.

Role (coding_hosted_connectivity)

  • New coding_hosted_worker_mode: network_enforcement.
  • ExecStart runs the release probe runner as net-agent in the exact worker cgroup.
  • A second ExecStopPost runs net-once as the worker user, after revoke.
  • Asserts the runner path, a v2 profile and a window of at most 280 s.

Targets fixture

  • internal/codingenforcement/fixtures/network/targets.json holds the public, IPv6, metadata, DNS, proxy authority and port targets.
  • It is covered by fixtures_sha256.

Preflight v4: semantic nft ruleset digest (inspect-coding-native-host.py)

  • Preflight v3's nft_snapshot_sha256 hashed the raw nft -j listing. A worker start and stop always changes it, so the verifier's pre/post-collection comparison refused every real network record.
  • The preflight is now dittobench-coding-native-host-preflight-v4 and records nft_ruleset_semantic_sha256. The verifier requires v4 and refuses v3 as unknown.
  • What is stripped: metainfo, every handle, and counter packets/bytes (zeroed; the counter statement stays). Named set and map elements lose their timeout and expires.
  • What is pruned (only objects no verdict can depend on):
    • regular chains with no rules that no jump/goto names;
    • named sets and maps with no elements that nothing references as @name;
    • base chains with exactly the standard keys, type filter, policy accept and no rules.
  • What is kept: every rule, every referenced chain or set (even empty), every set with elements, rule-less base chains with policy drop, another type or extra attributes, and every other object.
  • Order: objects are sorted canonically. Rules stay in kernel order within each chain. Set element lists are sorted.
  • One implementation: the collector's normalize_ruleset now calls the preflight's nft_entries, loaded from the same reviewed checkout, where both files are already pinned. The scoped and deny ruleset digests are unchanged on the recorded listings.

How it maps to the owner decisions

Decision Implementation
Same machine and boot, within 6 h, post-collection preflight Machine, boot and kernel are checked against the config and the pre-collection preflight (at most 15 min old); boot is rechecked at the end. The verifier's post-collection preflight and 6 h rules are unchanged.
Hashed endpoint labels, endpoint-set pin, full profile digest The record carries only the verifier's _endpoint_sha256 labels and inputs.connectivity_profile_sha256. Raw addresses, including source addresses and container IPs, stay in the collector. A test checks this.
Refusing proxy included Requires the #1899 proxy unit to be active, in its own cgroup and running exactly python3 -I -B egress-proxy.py <listed endpoint>. Profile candidate_tcp must be exactly the router plus the proxy. Proxy connect and CONNECT refusal are probed, and the script digest is recorded.
Handshake-only positive checks worker.trusted.handshake is connect-and-close. Expiry flows are checked with zero-payload keepalive. A test asserts that trusted endpoints only ever see handshake, connect or establish.
Hash the executed runner from outside; measure from outside the candidate; pin socket and config; carry daemon identity The installed runner must equal probe_runner_sha256. /proc/<pid>/exe is hashed for every agent (worker main PID, daemon agent, container agents, each net-once before its gate), before and after hello. A self-report that differs is refused. Docker always runs as the daemon user via unix:///run/ditto-coding-hosted/docker.sock and the empty DOCKER_CONFIG. The daemon identity digest must equal the preflight's before and after. Identity, host IDs and interfaces come from /proc. Router source is observed at the worker-cgroup listener. Host-loopback and sibling denials only count if the outside listener saw nothing.
Same-boot only Records claim same_boot and list daemon restart and reboot recovery as not covered, unchanged.

nft binding

  • The loaded inet ditto_coding_hosted table is normalized: handles, counter values and set timeouts are dropped.
  • It must equal, entry for entry, what connectivity-policy.py policy compiles for the installed profile and daemon UID.
  • A test loads the real policy into an unprivileged kernel namespace and compares. The comparison holds on this workstation's kernel (nft 1.0.9).
  • After stop and after the failed start, the table must be exactly the deny guard.

Tests

  • Go: go test -p 2 ./internal/codingenforcement/... ./cmd/dittobench-coding-enforcement-probe/ passes. It covers:
    • outcome classification and closed request decoding;
    • handshake writes 0 bytes, proxy refused vs forwarded, DNS answer vs refusal;
    • listen/accept/establish/check, net-once gate and exclusive report.
  • Python: 736 passed, 1 skipped (the opt-in local Docker import test). The live nft namespace test ran, with DITTOBENCH_REQUIRE_LIVE_NFT=1. Files run:
    • test_coding_native_network_collector.py, test_coding_native_enforcement_evidence.py, test_coding_hosted_connectivity.py, test_coding_native_host_preflight.py;
    • test_coding_native_release.py, test_coding_native_matrix.py, test_coding_qualification.py, test_coding_hosted_image_import.py, test_coding_hosted_runtime_bundle.py.
  • Semantic digest tests use real kernel listings, including the new nft-deny-after-expiry.json (deny guard, scoped policy, deny guard, element expiry):
    • The initial deny guard and the post-expiry listing have different raw bytes and an equal digest. The live test re-records both in a namespace.
    • The digest ignores metainfo, handles, counter values, element timeouts, entry order, an unreferenced empty chain and an unreferenced empty set.
    • The digest changes for: a changed rule or verdict, reordered rules, a changed base-chain policy (with and without rules), a changed chain type or device, a new or changed element in a referenced set, a removed deny rule, an added accept rule, and a new empty drop base chain.
    • Referenced empty sets, jump targets and verdict-map targets are kept. Malformed listings are refused.
    • Collector and preflight produce identical entries for every fixture.
    • Verifier end to end: the accept-path record verifies against preflights carrying the initial and post-expiry digests. It is refused, on the nft digest, for an added accept rule, unexpired scoped sets or a still-loaded scoped policy. Preflight v3 and the old field name are refused.
  • Collector tests run against a simulated host (units, deny guard, connectivity window, agents) with nft listings recorded from the real kernel:
    • Accept path: the collected record passes coding-native-evidence.py verification, with no raw address and no approval key.
    • Each enforcement failure produces a truthful unmatched record that the verifier refuses: public, router source host_address, proxy forwarding, DNS, daemon trusted, worker handshake, sibling and host loopback seen by the outside listener, established flow surviving expiry, trusted reachable after stop.
    • Refusals, with cleanup of containers, networks, worker and agent directory: tampered exe for each agent position, lying self-report, ruleset differing from the profile, non-deny restored ruleset, daemon identity change, expired start not failing.
    • Pre-start refusals that start nothing: runner digest, worker active, stray container, proxy down, stale preflight, profile mismatch, window and prerequisites.
    • Verifier tamper refusals: rule counts, scoped equal to deny, cgroup, table, proxy digest, missing binding, binding on a resource record, runner digest, boot, coverage, misreported match.
  • Ansible: ansible-playbook --check tests/coding-hosted-connectivity.yml passes, including the new mode's render.
  • Lint: ruff check and format on all touched files, and mypy on the evidence tool, the collector and ditto/.
  • CI: the offline coding-native-release.yml job now also runs the collector and role tests, and lints and type-checks the collector. On the disposable runner it installs nftables and relaxes the AppArmor user-namespace sysctl, so the live listing test is required (DITTOBENCH_REQUIRE_LIVE_NFT=1). No workflow step executes the collector. The guard test now allows its name in that job only as a path filter or a ruff/mypy target.

What this does not cover (residual assumptions)

  • Semantic digest pruning is an argument, not a host measurement. Dropping rule-less accept filter base chains relies on netfilter semantics (an accept from one base chain does not end the hook's other base chains). It is exercised in a throwaway namespace only, not on the host kernel.
  • Post-collection preflight timing. Until the scoped set elements expire the sets are kept (they hold elements), so a post-collection preflight taken before the profile expiry refuses. The verifier already requires it after the last record, which ends after expiry.
  • Router namespace is host only. rootless-netns (the held in-namespace router fd hand-off) is unsupported. Under slirp4netns candidate.router.source will record host_address and the verifier will refuse, which is the known rootless router blocker.
  • Most candidate-side denials are self-reports of the measured binary running as the candidate: DNS, metadata, public, IPv6, trusted and Docker API. Only router, host-loopback and sibling have outside listeners. nft reject counters are not used.
  • The candidate container is started by the collector, with the hosted harness hardening mirrored from sandbox.runArgsForNetwork and a released language image. It is not started by sandbox.LocalDocker and does not use a screened miner image. The executor interface check uses a --network none container, not the production executor launch.
  • Assumed rootless details, not verified on the host:
    • RootlessKit does not unshare the PID namespace, so State.Pid and its docker-init child are host PIDs.
    • The slirp4netns host-loopback alias is 10.0.2.2.
    • systemd-run --user --machine=ditto-coding-hosted@.host --pipe works as root with lingering.
    • The worker unit's hardening permits /run/ditto-coding-hosted-enforcement.
  • closed is ambiguous for established_after_expiry: a server closing an idle connection just after expiry looks like a cut flow.
  • Operator steps are not automated:
    • installing the role in network_enforcement mode (it replaces the canary worker unit; reinstall single after);
    • installing a short-lived probe profile;
    • starting the proxy;
    • retaining the pre- and post-collection preflights.
  • Not covered, as before: reboot and daemon-restart recovery. PR5 kinds are untouched and their coverage rows stay "not implemented".

🤖 Generated with Claude Code

Note

Add native network enforcement collector with net-agent and net-once probe subcommands

  • Adds probe.NetAgent session protocol in network.go supporting connect, handshake, proxy, DNS, listen/accept, and established-flow checks over a bounded JSON-lines protocol, plus probe.RunNetPlan for one-shot plan execution.
  • Adds net-agent and net-once subcommands to dittobench-coding-enforcement-probe in network.go; net-agent serves over stdio or a single owner-only Unix socket, net-once runs a gated one-shot plan and writes an exclusive report.
  • Adds standalone Python collection tool collect-coding-native-enforcement.py with fixed hosted-environment identities, schemas, and timing limits.
  • Adds network_enforcement mode to the Ansible coding_hosted_connectivity role in main.yml and worker.service.j2, installing connectivity policy, starting the probe runner as net-agent, and running a gated net-once stop hook.
  • Replaces raw nft snapshot digests with semantic nft ruleset digests across inspect-coding-native-host.py and coding-native-evidence.py, bumping host preflight schema from v3 to v4.
  • Risk: parse_preflight now rejects v2/v3 preflights and the former nft_snapshot_sha256 field; verification compares nft_ruleset_semantic_sha256 instead, so any out-of-tree consumer still expecting raw snapshot digests or v3 preflights will fail.

Macroscope summarized 8399631.

ai-mountain and others added 2 commits September 16, 2026 12:55
Adds net-agent (line protocol on stdin/stdout or one owner-only Unix
socket) and net-once (fixed plan behind a bounded collector gate) to
dittobench-coding-enforcement-probe. Attempts are handshake-only: TCP
connect and close without payload, keepalive-only checks of established
flows, one CONNECT line to the refusing proxy and one DNS question to the
candidate's resolver. Outcomes are catalog names; unknown errors are
probe_error. The agent never decides a match or writes a record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Default-off root collector for network_enforcement records. It measures
the release-recorded probe runner and every agent's /proc/<pid>/exe from
outside, pins the Docker socket and empty client config, binds the daemon
identity, compares the loaded nft table entry for entry with the profile's
compiled connectivity policy, and records normalized scoped and deny
ruleset digests, the worker cgroup and the refusing proxy in a new
network_binding the offline verifier checks. The connectivity role gains a
network_enforcement worker mode. Tests use a simulated host and nft
listings recorded in a throwaway user and network namespace; CI re-records
them. Nothing runs on a host and no approval is produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

No dashboard preview for this commit. This PR resolved to preview profiles stack with no dashboard profile: it changes no apps/platform/dashboard/ path, so there is no dashboard build to publish. Profile selection fails closed rather than guessing, which is why a PR touching no recognized runtime path still reads as stack.

Stack previews are never automatic. A maintainer can provision one for this PR:

gh workflow run preview-stack.yml -f pr=1939                    # provision
gh workflow run preview-stack.yml -f pr=1939 -f action=retire   # free the slot

It runs on the default branch, resolves the head commit from the API, and comments dashboard, Platform, and isolated Backroom URLs once readiness succeeds. profile defaults to auto, which picks stack-copy when the PR touches apps/platform/alembic/. Eight slots cap every active preview; a re-dispatch reuses the slot this PR already holds. Reconciliation keeps it while the PR is open, up to a 24-hour lease cap, and retires it four hours after the PR closes.

Exact SHA: 83996319261f47ddb26db314b5ea5efa3039e1b8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Host preflight v3 hashed the raw `nft -j list table` output, which every
worker start and stop changes (new handles, leftover scoped base chains and
sets), so the verifier's pre/post-collection comparison refused every real
network enforcement record.

Preflight v4 records nft_ruleset_semantic_sha256 instead: the listing without
metainfo, handles, counter values and named-element timeouts/expiries, pruned
of provably inert objects (unreferenced empty regular chains, unreferenced
empty sets and maps, rule-less accept filter base chains), with objects sorted
and rules kept in chain order. The collector now uses the preflight's own
stripping from the same reviewed checkout instead of a copy. The verifier
requires v4 and refuses v3.

A recorded kernel listing after deny, scoped policy, deny and element expiry
has the same semantic digest as the initial deny guard, and the collector's
accept-path record now verifies against preflights carrying those digests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ai-mountain added a commit that referenced this pull request Sep 16, 2026
…ace checks in #1939's network_enforcement connectivity mode

network_enforcement reads no worker configuration, so #1919's attempt
binding dereferenced a skipped slurp and refused the mode. Enforcement
collection also requires the host router namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Peyton-Spencer Peyton-Spencer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open Code Review pass completed at 0f6b9e194b3a476996a24835ef8d41332af8e478 using z-ai/glm-5.3-flash through Ditto’s Code Review endpoint. Coverage: 28/28 added, modified, renamed, or type-changed files in the PR diff.

Model candidates were checked against the source; independently confirmed actionable findings, if any, are posted separately as inline review comments. This was a static review; no PR code or test suite was executed. This comment is not a merge approval.

ai-mountain added a commit that referenced this pull request Sep 16, 2026
…e runtime image smoke check (#1939 net-agent/net-once, #1941 workload/resource-agent/reconcile-launch-journal)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mage probe smoke check

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ve-network-collector

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ai-mountain added a commit that referenced this pull request Sep 16, 2026
# Conflicts:
#	infra/ansible/roles/coding_hosted_connectivity/defaults/main.yml
#	infra/ansible/roles/coding_hosted_connectivity/tasks/main.yml
ai-mountain added a commit that referenced this pull request Sep 16, 2026
…ace checks in #1939's network_enforcement connectivity mode

network_enforcement reads no worker configuration, so #1919's attempt
binding dereferenced a skipped slurp and refused the mode. Enforcement
collection also requires the host router namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ai-mountain
ai-mountain marked this pull request as ready for review September 17, 2026 19:48
@ai-mountain

Copy link
Copy Markdown
Contributor Author

Ready for review. Merge-time note: inherits #1927's grading-profile fixture pin, and #1919's five attempt-config and router-namespace tasks need when: coding_hosted_worker_mode != 'network_enforcement' plus the matching assert. Worth confirming during review that enforcement collection needs the host router namespace. Queue: #1940 (comment)

@ditto-code ditto-code Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto Review · Beta

Partial review for 83996319261f47ddb26db314b5ea5efa3039e1b8.

No actionable findings in the reviewed files. This is not an approval or proof that the PR is bug-free.

Some coverage was incomplete. See the run in the developer console before relying on this review.

Configure Ditto Review. Inference is billed through your Ditto Router endpoint; targeted verification is included during beta.

This branch was successfully deployed

1 active deployment
preview — 83996319 Deployed Sep 16, 2026 by ai-mountain via dashboard-publish / retire #2624
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.

2 participants