fix: the sandbox's last unverified control was the one that wasn't there - #210
Merged
Merged
Conversation
… it were Verifying the sandbox boundary after #209 made it possible to start a container at all. Reading the kernel's own view from inside — `/proc/self/status`, cgroup limits, routes — seven of the eight declared controls are in effect: `CapEff`/`CapBnd` both zero, `NoNewPrivs: 1`, read-only rootfs, `pids.max=128`, `memory.max=536870912`, no routes and no egress, and `dd` asking for 80M into /work writing exactly 67108864 bytes. The eighth was not. `_SECCOMP_PROFILE` resolved to `<repo>/../sandbox/policies/` — one level above the repo root, at a directory that has never existed. It is used behind `if …exists()`, so the miss was silent and Docker's default profile applied instead. redaction.py, schemas.py and kb/storage_init.py already resolve `docs/specs` with an `OPSPILOT_SPECS_DIR` override for the pip-installed case; this was the fourth site and the only one that got it wrong. **Fixing the path alone breaks the sandbox**, which is why the profile itself had never been examined. Its allowlist carries `fork` and `vfork` but not `clone`, and libc implements `fork()` with `clone` — on aarch64 the `fork` syscall does not exist. Every command came back: /bin/sh: can't fork: Operation not permitted `clone` is now allowed with the namespace flags masked off, and `clone3` returns ENOSYS so libc falls back to it. That is what Docker's default profile does and it is the only enforceable arrangement: clone3 passes its flags in a struct a seccomp filter cannot dereference, so the mask cannot be applied to it. With the profile actually loaded, `unshare`, `chroot` and `mount` are refused by the filter rather than only by the dropped capabilities. Two smaller things in the same file. `_note_default` claimed the profile "tightens things further on top of the default" — Docker takes one profile, and with `defaultAction: SCMP_ACT_ERRNO` this one replaces the default rather than layering on it, so a reader would have assumed protections that are not there. And `clock_gettime64` was missing: `archMap` claims SCMP_ARCH_ARM and SCMP_ARCH_X86, where modern libc uses it. Added on that reasoning alone — there is no 32-bit host here and it is not verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every existing sandbox test asserts on the argv handed to `docker`. Argv is a claim, not a control, and two defects lived their whole lives in that gap: a tmpfs size the kernel rejects, so no container ever started (#209), and a seccomp path pointing at a directory that has never existed. `tests/test_sandbox_containment.py` runs commands through the real `SandboxEngine` and reads the kernel's view back: capabilities, NoNewPrivs, the seccomp filter, the read-only rootfs, the tmpfs cap actually truncating an 80M write at 64M, no routes and no addressed interface but `lo`, and `unshare` / `chroot` / `mount` refused. Marked `requires_docker` and self-skipping where the daemon or `alpine:3.19` is absent, so CI keeps its current selection and simply skips the container tests. **One of them needs no daemon and is the one that matters most.** The containment probes cannot tell our profile from Docker's default — both deny what they probe, so all of them passed while the profile was silently missing. `test_the_seccomp_policy_is_where_the_code_looks_for_it` covers that: it asserts the policy is where the loader looks, denies by default, and allows `clone`. It runs everywhere, and it fails on the pre-fix path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0005 makes the sandbox the real boundary and the approval gate an admitted heuristic. Until #209 it had never started a container, so nothing about that boundary had ever been observed. Records the control-by-control result, the seccomp defect behind the one gap, and two things deliberately left open: the profile is unverified on the 32-bit sub-architectures archMap claims, and the command still runs as root inside the container because no `--user` is ever passed — defanged by everything else, weaker than the argv implies, and a decision rather than an oversight. Co-Authored-By: Claude Opus 5 (1M context) <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 free
to 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.
ADR-0005 makes the sandbox the real boundary — the approval gate is a heuristic
denylist and says so in its own docstring. #209 made it possible to start a
container for the first time, which made the boundary observable for the first
time. This is what it looks like.
Method: read the kernel's own record of what is applied, from inside —
/proc/self/status, cgroup limits, routes — rather than attempt to break out.If a limit is not in force, the cost of discovering that by hitting it is paid
by the host.
Seven of eight, in effect
--cap-drop=ALLCapEffandCapBndboth0000000000000000--security-opt=no-new-privilegesNoNewPrivs: 1--read-only/workblocked--tmpfs=/work:size=64mddasked for 80M, wrote exactly 67108864 bytes--network=noneloaddressed, egress fails--pids-limit=128pids.max=128--memory=512mmemory.max=536870912CapBndbeing zero matters as much asCapEff: a setuid binary has nothing toregain. The tmpfs number is #209's fix visible from the other side.
The eighth
_SECCOMP_PROFILEresolved to<repo>/../sandbox/policies/seccomp.template.json— one level above the repo root, at a directory that has never existed. It is
applied behind
if …exists(), so this was not an error, it was a silentfallback to Docker's default profile.
redaction.py,schemas.pyandkb/storage_init.pyall resolvedocs/specscorrectly with anOPSPILOT_SPECS_DIRoverride for the installed case; this was the fourth siteand the only wrong one.
Fixing the path alone breaks the sandbox. That is why the profile itself had
never been looked at:
Its allowlist has
forkandvforkand notclone. libc implementsfork()with
clone, and on aarch64 theforksyscall does not exist at all — sonothing could ever have started a process. The path bug had been hiding a
profile that could not run anything, and shipping the path fix on its own would
have turned a silently-inert control into an outage.
cloneis now allowed with the namespace flags masked off,clone3returnsENOSYS so libc falls back to it. That is Docker's own arrangement and the only
enforceable one: clone3 passes flags in a struct a filter cannot dereference, so
the mask cannot apply to it.
With the profile genuinely loaded,
unshare,chrootandmountare refusedby the filter and not only by the dropped capabilities — 262 allowed syscalls,
deny by default, against Docker's default denylist of ~44.
Why nothing caught any of this
Every sandbox test asserts on the argv we hand
docker. Argv is a claim.tests/test_sandbox_containment.pyruns real commands through the real engineand reads the kernel back. It is
requires_dockerand self-skips without adaemon or the image, so CI's selection is unchanged.
One test in it needs no daemon, and it is the one that matters. The
containment probes cannot distinguish our profile from Docker's default — both
deny what they probe, which is exactly why all of them passed for the profile's
entire silent life. I checked: the whole suite is green on the pre-fix code.
test_the_seccomp_policy_is_where_the_code_looks_for_itis the one that failsthere, and it runs everywhere.
Not verified, and left open
archMapclaimsSCMP_ARCH_ARMandSCMP_ARCH_X86, where modern libc usesclock_gettime64; it was missing andis added on that reasoning alone. There is no 32-bit host here. Everything
else was verified on aarch64 against
alpine:3.19.--userflag,though
--tmpfs=…,uid=1000says someone intended one. With no capabilities,no new privileges, a read-only root and a deny-by-default filter this is
heavily defanged — but it is weaker than the argv implies, and adding
--usercould break a diagnostic that expects to read something root-only. A decision,
not something to slip into this diff.
_note_defaultin the profile claimed it "tightens things further on top ofthe default". Docker takes one profile and
defaultAction: SCMP_ACT_ERRNOreplaces the default outright, so a reader would have assumed protections that
are not there. Corrected.
Verification
pytest -m "not slow and not requires_ollama"— CI's selection — 1372 passed.-m requires_docker12 passed. ruff, ruff format, mypy(154) clean.🤖 Generated with Claude Code