fix(sandbox): harden session containers, attest applied containment, add image digest pin - #337
Merged
Merged
Conversation
…add image digest pin M26. The certification session container -- the attested sandbox candidates are built and executed in -- ran with --network=none, no mounts, memory/pids caps and nothing else: full Linux capabilities as root, privilege escalation permitted, a writable root filesystem, and an image reference that `docker run` would happily pull from a registry if the tag was not present. Attestation captured the image digest and the resource caps but not the network mode or any of the containment actually applied, so a certificate could say "asked for no network" but never "was given none"; and the captured digest could not be pinned, so a retagged session image was recorded, not refused. Docker backend (shared one-shot + session translation, so they cannot drift): - --cap-drop ALL, --security-opt no-new-privileges, --pull never (the image must already be present -- a session never triggers the fetch of an unattested image), --read-only. - SandboxSpec.ScratchPaths (new, hashed into the sandbox-spec input) declares the write surface; each path comes back as a capped tmpfs (rw,nosuid,nodev,exec,size=512m -- exec explicit because docker's tmpfs default is noexec and the runtime must map the built assembly). SessionScratchPaths .Default = /nexo-candidate, /nexo-exec, /tmp, /root (the SDK image's toolchain home: first-run sentinel, telemetry, NuGet user config/caches -- written even by an offline, package-free restore). AutonomyLoopService declares it. - SessionCandidateBuild's clean-room reset empties the work dir instead of unlinking it: under a sealed rootfs the work dir IS a mountpoint and rm -rf of a mountpoint fails EBUSY. A forgotten scratch path now fails loudly as "read-only file system" in the build tail, never as a host-side fallback. - Non-root --user deliberately NOT applied: the SDK image ships no unprivileged user and a numeric uid cannot own the toolchain's scratch without staging work this pass does not earn. Follow-up, noted in the docs. Attestation: docker inspect now also reads HostConfig.NetworkMode, ReadonlyRootfs, CapDrop, SecurityOpt; ParseInspectLine returns a SessionInspection record; SessionAttestation carries EffectiveNetworkMode / EffectiveReadOnlyRootFilesystem / EffectiveDroppedCapabilities / EffectiveSecurityOptions inside the hashed attestation input. The backend refuses to attest a session that requested NetworkAccess.None but does not report "none" -- same fail-closed shape as the resource-cap shortfalls. Digest pin: Nexo:Autonomy:SessionImageDigest (default null = capture only). When set, DockerSandboxedSessionRunner resolves the image ID before `docker run` and refuses on absent/mismatch, and attestation re-checks the running container's image so a tag retargeted in between is still caught before any work runs. The pin is the engine image ID -- the same value certificates already carry as image-digest, so an operator pins by copying it from a certificate they read. Validation rejects a pin that is not sha256:<hex> at boot. Docs: known-limitation bullet 4 updated; Configuration.md gains an Autonomy sessions section with the new option. Verified: Nexo.Infrastructure + Nexo.Tests.Infrastructure build on net8.0 and net9.0; Docker sandbox/session/attestation/composition tests green on both TFMs (59 + 48 in Tests.Application), Execution+Autonomy+Certification namespaces green on net9.0 (408). No docker-tagged integration tests exist for sessions and the local Docker Desktop crashed on start (unrelated Inference-manager error), so the read-only leg is reasoned against the SDK image's write paths, not yet flown; the first live flight after this lands proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ootfs is sealed read-only The single-flight SessionSpec (the -SessionBuild / -SessionExecute / -Proposed / -Live flights behind the P3/P5 ledger rows) had no ScratchPaths; under the now-unconditional --read-only the in-session candidate build's 'mkdir -p /nexo-candidate' would fail. Use the same SessionScratchPaths.Default the sweep/campaign path (AutonomyLoopService) declares. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
Uh oh!
There was an error while loading. Please reload this page.
IanFrelinger pushed a commit
that referenced
this pull request
Aug 17, 2026
….md stops citing a release that does not exist CHANGELOG.md carried a single generic [Unreleased] block untouched by ~50 merges (119 conventional commits) since the file was added on 2026-08-13. The block is now Keep-a-Changelog grouped (Added / Changed / Fixed / Security) from `git log e668215..master`, with PR numbers where a change landed as one PR, and names the wave-1/2 readiness PRs #325-#337. No version is cut and the header sentence the docs/repo-map-refresh branch edits is untouched. SECURITY.md said "only the most recent release and master are supported" while no release has been tagged; it now says master is the only supported line until one is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Production-readiness audit, M26. Ledger: https://claude.ai/code/artifact/15fa4198-97b8-4da6-bea0-0c24d364a7f3
Finding (M26)
The certification session container — the attested sandbox candidates are built and executed in — ran with
--network=none, no mounts, memory/pids caps and nothing else: full Linux capabilities as root, privilege escalation permitted, a writable root filesystem, and an image referencedocker runwould pull from a registry if the tag was absent. Attestation captured the image digest and resource caps but not the network mode or any containment actually applied, so a certificate could say "asked for no network" but never "was given none"; and the digest could only be captured, never pinned — a retagged session image was recorded, not refused.What changed
Docker backend (
DockerSandboxedCommandRunner.AppendSpecArguments, shared by one-shot runs and sessions so they cannot drift):--cap-drop ALL,--security-opt no-new-privileges,--pull never(image must already be present — a session never triggers the fetch of an unattested image),--read-only.SandboxSpec.ScratchPaths(Core port, mirrorsAllowedEndpoints; hashed into thesandbox-specinput) declares the write surface; each path becomes a capped tmpfsrw,nosuid,nodev,exec,size=512m(execexplicit — docker's tmpfs default isnoexecand the runtime must map the built assembly).SessionScratchPaths.Default=/nexo-candidate,/nexo-exec,/tmp,/root(SDK image toolchain home: first-run sentinel, telemetry, NuGet user config/caches — written even by an offline package-free restore).AutonomyLoopService(the only production spec builder) declares it.SessionCandidateBuildclean-room reset now empties the work dir (mkdir -p X && find X -mindepth 1 -delete && mkdir -p X/refs) instead ofrm -rf X: under a sealed rootfs the work dir IS a mountpoint and unlinking a mountpoint fails EBUSY. A forgotten scratch path fails loudly asread-only file systemin the build tail — never a host-side fallback.--user: deliberately not applied (skipped with reason). The SDK image ships no unprivileged user; a numeric uid cannot own the toolchain's scratch/home without more staging work than this pass earns. Documented as follow-up.Attestation:
InspectFormatalso readsHostConfig.NetworkMode,ReadonlyRootfs,CapDrop,SecurityOpt(digest capture unchanged).ParseInspectLinereturns aSessionInspectionrecord.SessionAttestationgainsEffectiveNetworkMode,EffectiveReadOnlyRootFilesystem,EffectiveDroppedCapabilities,EffectiveSecurityOptions— inside the hashed attestation input. The Docker backend refuses to attest a session that requestedNetworkAccess.Nonebut does not reportnone(same fail-closed shape as the cap shortfalls).Digest pin:
Nexo:Autonomy:SessionImageDigest(default null = capture only). When set, the runner resolves the image ID (docker image inspect --format {{.Id}}) beforedocker runand refuses on absent/mismatch; attestation re-checks the running container's image so a tag retargeted in between is caught before any work runs. The pin is the engine image ID — the same value certificates already carry asimage-digest, so operators pin by copying it from a certificate. Validator rejects a non-sha256:pin at boot. Wired throughAddNexoAutonomy.Docs:
certification-evidence.mdknown-limitation #4 updated;Configuration.mdgains an Autonomy sessions section with the new option.Tests
SandboxedCommandRunnerTests: hardening flags always present + ordering; scratch paths → capped tmpfs.DockerSandboxSessionTests: session start argv hardened identically;SessionScratchPaths.Defaultcovers the legs' constants and becomes scratch mounts.DockerSessionAttestationTests: parser reads containment / treats missing fields as unknown; inspect format asks for containment; attestation carries containment; no-network refusal (bridge / unreported) and no refusal when egress was requested; digest pin refusal before start (mismatch, unresolvable), pass-through with trimmed pin, refusal at attestation on swapped image, capture-only unchanged; environment-input hashes cover containment and write surface.AutonomyCompositionTests:SessionImageDigestvalidation + config binding; composedISandboxedSessionRunnerenforces the configured pin (wiring test with a scripted process runner).Verification
dotnet buildNexo.Infrastructure / Nexo.BackgroundAgents / Nexo.Tests.Infrastructure (net8.0 + net9.0) / Nexo.Tests.Application / Nexo.Tests.BackgroundAgents: 0 warnings, 0 errors.-SweepLiveflight after merge is its proof (a failure would surface as an explainedread-only file systembuild failure).Notes for reviewers
spikes/autonomy-first-flight/FirstFlight/Program.cs(single-flight-SessionBuildmode) builds its own spec withoutScratchPaths; under the sealed rootfs its in-session build would fail loudly until it addsScratchPaths = SessionScratchPaths.Default. Sweep/campaign mode goes throughAutonomyLoopServiceand is covered. Spike left untouched (out of scope) — flagged here.--pull nevermeans the session image must be pre-pulled (docker pull mcr.microsoft.com/dotnet/sdk:9.0); the spike's "pulled by the host daemon on first use" comment is now stale.🤖 Generated with Claude Code
Reviewer follow-up applied on top: the first-flight spike's single-flight
SessionSpecnow declaresScratchPaths = SessionScratchPaths.Default, so-SessionBuild/-SessionExecutekeep working under the sealed rootfs.