Repository navigation
feat(tool): report what confines file operations, and refuse to weaken it - #129
Merged
Merged
Conversation
…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
This was referenced Oct 8, 2026
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
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.
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 — withcontainmentNote()and therequire_hard_boundarypolicy (ErrHardBoundaryUnavailable, checked first inCheckPath, 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 notkernel, 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 withELOOP, 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:/sandboxprints 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 -lempty;go vet ./...clean.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 throughapply_patchwith the kernel path disabled and reads the note out of the result.TestHardBoundaryPolicyFailsClosed— kernel host proceeds; userspace host refuses withErrHardBoundaryUnavailable; refused even with no project root; and with the policy off the weaker boundary stays usable.TestContainmentRoundTrips— the fact survivesFlush/Loadand is present in the raw JSON under its documented key.tui/testdata/scenarios/sandbox-command.scenario→ ok 13 steps locally; the rendered screen showscontainment: 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.palette_{40x12,80x24,120x40}.txtwere regenerated; the diff is exactly the new/sandboxentry. That scenario has no image fixture, so the byte-exact text golden is the artifact here — recorded rather than glossed.Honest scope
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.kernel. macOS has noopenat2but does refuse a swapped component, so "userspace" means no kernel mechanism at all (the!linux && !darwinbuild). 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.otherbuild is compile-checked by the cross-compile jobs but cannot run here.CI
TUI visual (screenshots + PTY)failed inpermission-allow-always.scenarioatwait-exit 45s— not in anything this PR touches.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.ad5a059; annotations at baseline (1 per job, 2 forbrowser+tui-visual), no new ones.