Skip to content

feat(tool): report what confines file operations, and refuse to weaken it - #129

Merged
yusiwen merged 1 commit into
masterfrom
feat/sandbox-enforcement-facts
Oct 8, 2026
Merged

yusiwen merged 1 commit into
masterfrom
feat/sandbox-enforcement-facts

Conversation

@yusiwen

@yusiwen yusiwen commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

What changed

  • tool/containment.go + containment_{linux,darwin,other}.go: ContainmentInfo() probes once per process for the mechanism the open layer will actually use — openat2 RESOLVE_BENEATH, the component walk, or in-process checks — with containmentNote() and the require_hard_boundary policy (ErrHardBoundaryUnavailable, checked first in CheckPath, including when no project root is configured).
  • tool/{filesystem,edit,apply_patch}.go: a mutation's success result carries the containment note only when the level is not kernel, so a degraded host is visible in the result the model reads and ordinary results are untouched.
  • tool/pathbeneath_walk.go: the comment claimed macOS has "no kernel-side containment at all". That is wrong — the walk refuses a swapped component with ELOOP, a kernel decision attached to the open — so it is corrected rather than carried into the new report.
  • tui/sandbox.go, tui/model.go, tui/update.go: /sandbox prints the level, mechanism, whether a hard boundary is required, and the effective writable roots; registered in the palette.
  • config/config.go: sandbox.require_hard_boundary (a bool overlay can only turn it on, never off).
  • session/session.go: the verdict is persisted with the session.
  • tui/testdata/scenarios/sandbox-command.scenario: black-box assertion of the report's shape.

Refs #123. Landing plan: #127.

Why

The containment layer degrades by platform and kernel. That degradation lived only in code comments and debug logs: nothing on screen or in a result said which mechanism applied, so a silent weakening could not be caught by a test, a platform change, or a reader. The facts are now reported, and a deployment that needs a kernel boundary can demand one and be refused rather than quietly weakened.

Verification

  • go test ./... -count=1 → 13 packages ok / 0 failed; -race → 13 packages ok / 0 failed; make staticcheck → exit 0; gofmt -l empty; go vet ./... clean.
  • New tests, each naming the acceptance item it covers:
    • TestContainmentProbeRunsOnce — the probe runs exactly once per process (5 calls, 1 probe).
    • TestContainmentNoteOnlyWhenDegraded — empty on a kernel host, names the degradation otherwise.
    • TestWriteResultCarriesTheDegradedFact — runs a real mutation through apply_patch with the kernel path disabled and reads the note out of the result.
    • TestHardBoundaryPolicyFailsClosed — kernel host proceeds; userspace host refuses with ErrHardBoundaryUnavailable; refused even with no project root; and with the policy off the weaker boundary stays usable.
    • TestContainmentRoundTrips — the fact survives Flush/Load and is present in the raw JSON under its documented key.
  • Scenario: tui/testdata/scenarios/sandbox-command.scenario → ok 13 steps locally; the rendered screen shows containment: kernel (openat walk, O_NOFOLLOW per component) on macOS, hard boundary required: no, the project root, and one writable root. The screenshot was read, not just produced.
  • Golden frames: the palette gained one line, so palette_{40x12,80x24,120x40}.txt were regenerated; the diff is exactly the new /sandbox entry. That scenario has no image fixture, so the byte-exact text golden is the artifact here — recorded rather than glossed.

Honest scope

  • The level is host-dependent by design, so the scenario asserts the report's shape (containment:, hard boundary required:, writable roots:) and not its value; pinning the value would fail for reasons unrelated to the code. The degraded value is asserted in Go, where the probe can be presented.
  • The walk counts as kernel. macOS has no openat2 but does refuse a swapped component, so "userspace" means no kernel mechanism at all (the !linux && !darwin build). The acceptance sentence in [security] Kernel enforcement can be absent without anyone noticing #123 said "a platform without openat2"; the honest reading is "without a kernel mechanism", and that is what the code and tests implement.
  • Nothing here is verified on a platform other than this host. The Linux probe is exercised by CI's Linux job; the other build is compile-checked by the cross-compile jobs but cannot run here.

CI

  • First run: 7/8 jobs green; TUI visual (screenshots + PTY) failed in permission-allow-always.scenario at wait-exit 45s — not in anything this PR touches.
  • Diagnosed before merging: two Ctrl+C presses that both land while the run is still streaming cancel the run and arm no quit, so the program never exits. That is a pre-existing state-machine race in five scenarios that press Ctrl+C right after stub-final-ok, filed as [tui] The permission-* scenarios hang on exit when both Ctrl+C land while the run is still streaming #130 with the mechanism, a capture recipe, and the attribution acceptance. The rerun of the same commit passed, and the scenario passes 5/5 locally.
  • Final state: 8/8 checks green on ad5a059; annotations at baseline (1 per job, 2 for browser + tui-visual), no new ones.

…n it

The containment layer degrades by platform and by kernel: openat2 on Linux,
the component walk on macOS, and in-process checks where neither exists. That
degradation was recorded only in code comments and debug logs, so nothing on
screen or in a result said which one applied, and a silent weakening could not
be caught by a test, a platform change or a reader.

Report it, once, from a probe of the mechanism the open layer will actually
use:

- ContainmentInfo() probes once per process and names the mechanism behind the
  level. A host that lost openat2 reports the walk rather than the stronger
  form; a platform with no kernel walk reports in-process checks.
- A mutation's success result carries a containment note only when the level is
  NOT kernel, so a degraded host is visible in the result the model reads while
  ordinary results stay unchanged.
- /sandbox prints the level, the mechanism, whether a hard boundary is
  required, and the effective writable roots — the fact was previously never
  drawn on screen.
- sandbox.require_hard_boundary refuses an operation that would be enforced by
  in-process checks alone. It fails closed, including when no project root is
  configured, because no approval can grant a capability the host lacks.
- The session record carries the same verdict, so a later reader can tell how
  the session's file operations were enforced.

The walk's doc comment claimed macOS has "no kernel-side containment at all".
That is wrong — the walk refuses a swapped component with ELOOP, which is a
kernel decision attached to the open — so it is corrected rather than carried
into the new report.

Refs #123
@yusiwen yusiwen added enhancement New feature or request security Security hardening, sandbox or SSRF residual labels Oct 8, 2026
@yusiwen
yusiwen merged commit 67397cb into master Oct 8, 2026
15 of 16 checks passed
@yusiwen
yusiwen deleted the feat/sandbox-enforcement-facts branch October 8, 2026 03:15
yusiwen added a commit that referenced this pull request Oct 8, 2026
Five scenarios pressed Ctrl+C twice immediately after wait --text
"stub-final-ok". That line is rendered by the last StreamMsg, before the
terminal StreamDone leaves "streaming", and two presses that both land in that
window cancel the run without arming a quit — the program then never exits and
the step reports a bare timeout that names an exit step instead of the state it
was in (issue #130, seen once in CI on PR #129 and not reproducible by
repetition alone).

Wait for the screen to settle first, which is a real idle signal because the
spinner animates while a run streams, and assert the confirmation prompt
between the presses so a wrong state fails at a named line.

The prompt's plus is escaped: --text is a regexp, so the unescaped "Ctrl+C"
means "one or more l" and never matches the literal text. Caught by running the
change rather than by reading it.

Refs #130
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request security Security hardening, sandbox or SSRF residual

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant