Skip to content

fix(sandbox): harden session containers, attest applied containment, add image digest pin - #337

Merged
IanFrelinger merged 2 commits into
masterfrom
fix/session-container-hardening
Aug 17, 2026
Merged

fix(sandbox): harden session containers, attest applied containment, add image digest pin#337
IanFrelinger merged 2 commits into
masterfrom
fix/session-container-hardening

Conversation

@IanFrelinger

Copy link
Copy Markdown
Owner

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 reference docker run would 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):

  • Every container: --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.
  • New SandboxSpec.ScratchPaths (Core port, mirrors AllowedEndpoints; hashed into the sandbox-spec input) declares the write surface; each path becomes a capped tmpfs rw,nosuid,nodev,exec,size=512m (exec explicit — docker's tmpfs default is noexec and 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.
  • SessionCandidateBuild clean-room reset now empties the work dir (mkdir -p X && find X -mindepth 1 -delete && mkdir -p X/refs) instead of rm -rf X: under a sealed rootfs the work dir IS a mountpoint and unlinking a mountpoint fails EBUSY. A forgotten scratch path fails loudly as read-only file system in the build tail — never a host-side fallback.
  • Non-root --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: InspectFormat also reads HostConfig.NetworkMode, ReadonlyRootfs, CapDrop, SecurityOpt (digest capture unchanged). ParseInspectLine returns a SessionInspection record. SessionAttestation gains EffectiveNetworkMode, EffectiveReadOnlyRootFilesystem, EffectiveDroppedCapabilities, EffectiveSecurityOptions — inside the hashed attestation input. The Docker backend refuses to attest a session that requested NetworkAccess.None but does not report none (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}}) before docker run and 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 as image-digest, so operators pin by copying it from a certificate. Validator rejects a non-sha256: pin at boot. Wired through AddNexoAutonomy.

Docs: certification-evidence.md known-limitation #4 updated; Configuration.md gains 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.Default covers 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: SessionImageDigest validation + config binding; composed ISandboxedSessionRunner enforces the configured pin (wiring test with a scripted process runner).

Verification

  • dotnet build Nexo.Infrastructure / Nexo.BackgroundAgents / Nexo.Tests.Infrastructure (net8.0 + net9.0) / Nexo.Tests.Application / Nexo.Tests.BackgroundAgents: 0 warnings, 0 errors.
  • Touched sandbox/session/attestation/composition tests: 59/59 on net9.0 and net8.0; Tests.Application Execution namespace 48/48; Tests.Infrastructure Execution+Autonomy+Certification namespaces 408/408 on net9.0.
  • No docker-tagged integration tests exist for sessions (all use fakes). Docker Desktop on this host crashed on start with an unrelated "Inference manager" error, so the read-only leg was reasoned against the SDK image's write paths, not flown; the first -SweepLive flight after merge is its proof (a failure would surface as an explained read-only file system build failure).

Notes for reviewers

  • spikes/autonomy-first-flight/FirstFlight/Program.cs (single-flight -SessionBuild mode) builds its own spec without ScratchPaths; under the sealed rootfs its in-session build would fail loudly until it adds ScratchPaths = SessionScratchPaths.Default. Sweep/campaign mode goes through AutonomyLoopService and is covered. Spike left untouched (out of scope) — flagged here.
  • --pull never means 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.
  • tmpfs pages are charged to the container's memory cgroup: build output now counts toward the 512m session cap.

🤖 Generated with Claude Code
Reviewer follow-up applied on top: the first-flight spike's single-flight SessionSpec now declares ScratchPaths = SessionScratchPaths.Default, so -SessionBuild/-SessionExecute keep working under the sealed rootfs.

PlzTouchGrassand others added 2 commits August 16, 2026 21:46
…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>
@cursor

cursorBot commented Aug 17, 2026

Copy link
Copy Markdown
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.

@IanFrelinger
IanFrelinger merged commit 2c9ae2a into masterAug 17, 2026
15 checks passed
@IanFrelinger
IanFrelinger deleted the fix/session-container-hardening branch August 17, 2026 02:20
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>
Sign up for freeto 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

@IanFrelinger@PlzTouchGrass