Skip to content

fix: the sandbox's last unverified control was the one that wasn't there - #210

Merged
vicenteliu merged 3 commits into
mainfrom
fix/the-sandbox-actually-contains-things
Aug 20, 2026
Merged

fix: the sandbox's last unverified control was the one that wasn't there#210
vicenteliu merged 3 commits into
mainfrom
fix/the-sandbox-actually-contains-things

Conversation

@vicenteliu

Copy link
Copy Markdown
Owner

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

declared observed
--cap-drop=ALL CapEff and CapBnd both 0000000000000000
--security-opt=no-new-privileges NoNewPrivs: 1
--read-only writing outside /work blocked
--tmpfs=/work:size=64m dd asked for 80M, wrote exactly 67108864 bytes
--network=none no routes, only lo addressed, egress fails
--pids-limit=128 pids.max=128
--memory=512m memory.max=536870912
the project's seccomp profile never applied

CapBnd being zero matters as much as CapEff: a setuid binary has nothing to
regain. The tmpfs number is #209's fix visible from the other side.

The eighth

_SECCOMP_PROFILE resolved 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 silent
fallback to Docker's default profile. redaction.py, schemas.py and
kb/storage_init.py all resolve docs/specs correctly with an
OPSPILOT_SPECS_DIR override for the installed case; this was the fourth site
and the only wrong one.

Fixing the path alone breaks the sandbox. That is why the profile itself had
never been looked at:

/bin/sh: can't fork: Operation not permitted

Its allowlist has fork and vfork and not clone. libc implements fork()
with clone, and on aarch64 the fork syscall does not exist at all — so
nothing 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.

clone is now allowed with the namespace flags masked off, clone3 returns
ENOSYS 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, chroot and mount are refused
by 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.py runs real commands through the real engine
and reads the kernel back. It is requires_docker and self-skips without a
daemon 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_it is the one that fails
there, and it runs everywhere.

Not verified, and left open

  • 32-bit sub-architectures. archMap claims SCMP_ARCH_ARM and
    SCMP_ARCH_X86, where modern libc uses clock_gettime64; it was missing and
    is added on that reasoning alone. There is no 32-bit host here. Everything
    else was verified on aarch64 against alpine:3.19.
  • The command runs as root inside the container. There is no --user flag,
    though --tmpfs=…,uid=1000 says 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 --user
    could break a diagnostic that expects to read something root-only. A decision,
    not something to slip into this diff.
  • _note_default in the profile claimed it "tightens things further on top of
    the default". Docker takes one profile and defaultAction: SCMP_ACT_ERRNO
    replaces 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_docker 12 passed. ruff, ruff format, mypy(154) clean.

🤖 Generated with Claude Code

vicenteliu and others added 3 commits August 19, 2026 12:56
… 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>
@vicenteliu
vicenteliu merged commit b2204a2 into main Aug 20, 2026
4 checks passed
@vicenteliu
vicenteliu deleted the fix/the-sandbox-actually-contains-things branch August 20, 2026 01:58
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.

1 participant