Repository navigation
[security] Sandbox hardening: landing plan and progress #127
Description
Purpose
Sequencing, definition of done, and the progress log for the sandbox hardening batch. Each linked issue is one PR-sized unit; this issue holds the order and the record.
Dependency shape
#120 shared writable roots ─┬─> #121 kernel boundary for bash ──> #124 grant lifetime
├─> #122 per-call policy (needs #126 for the second-start scenario)
└─> #123 enforcement facts ──> #125 unified refusal vocabulary
#126 tuiprobe restart/mark step — parallel track, needed only by #124
Order and why
- S0 — decision and feasibility (no product code). Done; the backend/launcher decision and the CI feasibility evidence are recorded in the [security] bash has no kernel-enforced file boundary — the guard is a substring denylist #121 decision comment (Landlock preferred, re-exec launcher, structured exit-125 channel; the runner's LSM list was printed by the job later).
- S1 — [security] Derive the writable roots once and share them between the file fence and command execution #120 shared writable roots. Landed in PR refactor(tool): derive the writable roots in one place #128 (
394d017); 13 packages ok plain and under -race, 8/8 CI checks, no new annotations. - S2 — [security] Kernel enforcement can be absent without anyone noticing #123 enforcement facts and startup probe. Landed in PR feat(tool): report what confines file operations, and refuse to weaken it #129 (
67397cb); 13 packages ok plain and under -race, staticcheck clean, scenariosandbox-commandok 13 steps, CI 8/8, annotations baseline. - S3 — [security] bash has no kernel-enforced file boundary — the guard is a substring denylist #121 kernel-enforced boundary for bash. Landed in PRs feat(tool): add the confinement launcher and the Landlock boundary #132, feat(tool): run shell commands under the kernel boundary when asked #133, test(tui): assert the command boundary on the filesystem #134 (through
4a8d857); the CI job proves it on a real runner (CONFINE inside=yes outside=no), 9/9 checks, annotations baseline. - S4 — [agent] The sandbox policy is a package-level singleton; plan mode is a boolean on the context #122 per-call policy on the context. Landed in PR feat(sandbox): carry one frozen policy per run instead of a plan flag #136 (
71854b2); one frozenSandboxPolicyper run, 13 ok plain and-race, 27 scenarios ok, CI 9/9, annotations baseline. - S5 — [security] "Always allow" silently turns a dialog answer into permanent global policy #124 grant lifetime and persistence. Landed in PR feat(config): make a persistent grant say what it is, and let it be revoked #137 (
ed99d9b); grants carry path/time/project,--list-grants/--revoke-grant, the dialog names the file; 13 ok plain and-race, 27 scenarios, CI 9/9. - S6 — [tool] Three different refusal markers tell the model the same thing differently #125 one refusal vocabulary. Landed in PR feat(sandbox): one refusal vocabulary instead of four prefixes #138 (
b703428); one marker, one shape, one hint per ask value; 13 ok plain and-race, 27 scenarios, CI 9/9. - S7 (parallel) — [tuiprobe] A scenario cannot tell text from an earlier process run from the current one #126 tuiprobe step, release, and pin bump. Landed in tuiprobe v0.1.4 (PRs tuiprobe: mark a point in the output, and wait only for text drawn after it #154, tuiprobe: a scenario timeout is reported as a timeout #156, tuiprobe: the v0.1.4 changelog #157) and pinned by PR tuiprobe v0.1.4: the pin that carries the four module fixes #158 (
3525ff6); the "second start honours the grant without a dialog" half is asserted from the app side by PR Honour the persisted "Always allow" grant on the next start (#155) #163 ([tui] Pin the persisted grant on the next start, now that a scenario can tell one run from the next #155), which is what forced the fix that nothing readsandbox.allowed_path_grantsback. - Follow-up filed — [security] Command confinement is opt-in, so the default posture is still the string denylist #139 command confinement is opt-in, so the default posture is still the string denylist (deferred in [security] bash has no kernel-enforced file boundary — the guard is a substring denylist #121, now tracked rather than left in a comment).
- Follow-up filed — [security] A repository-local config can widen or remove the sandbox fence #162 a repository-local config could widen or remove the fence through
sandbox.project_root/sandbox.allowed_paths; found while landing [tui] Pin the persisted grant on the next start, now that a scenario can tell one run from the next #155, measured, and fixed in PR A project-local config can no longer widen the sandbox fence #164 (not part of this plan's steps — a defect the S7 scenario exposed).
Definition of done, per step
- Branch created before the first commit; one concern per commit.
- Local evidence: the issue's acceptance run as command → result, with counts rather than adjectives.
- The PR references its issue and carries the verification plus the honest scope of what was not verified.
- CI green and every job's
annotations_countat or below the documented baseline. - Rebase-merge, branch deleted, default branch hard-synced.
- Closing comment on the issue: what landed, the measurements, and what was handed off.
Progress log
Append one line per merged step: issue, PR, commit, one measurement.
-
[security] Derive the writable roots once and share them between the file fence and command execution #120 — PR refactor(tool): derive the writable roots in one place #128 —
394d017—go test ./... -count=113 packages ok,-race13 ok, CI 8/8, annotations baseline. -
[security] Kernel enforcement can be absent without anyone noticing #123 — PR feat(tool): report what confines file operations, and refuse to weaken it #129 —
67397cb—go test ./... -count=113 ok,-race13 ok,sandbox-command.scenario13 steps, CI 8/8; first TUI run flaked on [tui] The permission-* scenarios hang on exit when both Ctrl+C land while the run is still streaming #130, filed and fixed separately. -
[tui] The permission-* scenarios hang on exit when both Ctrl+C land while the run is still streaming #130 — PR test(tui): make the exit step deterministic in the permission scenarios #131 —
1ffcd04—make test-tui-scenarios27 scenarios ok, five files 3× each 15/15, CI 8/8. -
[security] bash has no kernel-enforced file boundary — the guard is a substring denylist #121 part 1 — PR feat(tool): add the confinement launcher and the Landlock boundary #132 —
3e13a3d+9d8f476— Linux CI runs the real Landlock acceptance test; first run caught the add_rule size/flags bug; 8/8 checks. -
[security] bash has no kernel-enforced file boundary — the guard is a substring denylist #121 — PRs feat(tool): add the confinement launcher and the Landlock boundary #132/feat(tool): run shell commands under the kernel boundary when asked #133/test(tui): assert the command boundary on the filesystem #134 —
4a8d857— jobSandbox confinement (Landlock + PTY)green with the filesystem verdict; three real bugs caught by the gate; flake filed as [tuiprobe] TestRunDrivesAScriptedSession kills the program before reading its exit code #135. -
[agent] The sandbox policy is a package-level singleton; plan mode is a boolean on the context #122 — PR feat(sandbox): carry one frozen policy per run instead of a plan flag #136 —
71854b2— frozen per-run policy; 80 interleaved policy checks under-race; confinement job still green end to end. -
[security] "Always allow" silently turns a dialog answer into permanent global policy #124 — PR feat(config): make a persistent grant say what it is, and let it be revoked #137 —
ed99d9b— persistent grants auditable and revocable; scenario asserts the config the dialog wrote; CI 9/9. -
[tool] Three different refusal markers tell the model the same thing differently #125 — PR feat(sandbox): one refusal vocabulary instead of four prefixes #138 —
b703428— refusal vocabulary intypes/refusal.go; source-level test fails any call site that formats its own marker; CI 9/9. -
Docs — PR docs: record the sandbox design and what it does not promise #140 —
872bb8e—docs/sandbox.md(design, protocol, grants, refusals, limits) plus the README section and links. -
S7 — [tuiprobe] A scenario cannot tell text from an earlier process run from the current one #126 — PRs tuiprobe: mark a point in the output, and wait only for text drawn after it #154/tuiprobe: a scenario timeout is reported as a timeout #156/tuiprobe: the v0.1.4 changelog #157 — tuiprobe v0.1.4:
mark+wait --text --since, a scenario timeout reported as a timeout, the changelog; 31 scenarios 27 ok / 4 skipped by design, CI 9/9, annotations baseline. -
S7 — [tuiprobe] A scenario cannot tell text from an earlier process run from the current one #126 → [tui] Pin the persisted grant on the next start, now that a scenario can tell one run from the next #155 — PR tuiprobe v0.1.4: the pin that carries the four module fixes #158 —
3525ff6— the pin that carries the four module fixes; the app-side half of the second start is PR Honour the persisted "Always allow" grant on the next start (#155) #163. -
[tui] Pin the persisted grant on the next start, now that a scenario can tell one run from the next #155 — PR Honour the persisted "Always allow" grant on the next start (#155) #163 —
b02a2b0+ff17686—sandbox.allowed_path_grantsis read back on the next start, from the user's own config only;permission-allow-always.scenario35 steps (run 2 reachesstub-final-ok (since the mark)with no dialog answered), suite 27 ok / 4 skipped, mutation red at line 71 with the dialog on screen, CI 9/9, annotations baseline. -
[tui] A submitted command is dropped silently while a run is active #119 — PR Say why a submitted command was refused while a run is active #159 —
1be0fda— a submission refused while a run is active now names itself on the status line, from one constant shared with theChatMsgpath; 13 packages ok plain and-race, CI 9/9, annotations baseline. -
[ci] The confinement scenario times out at its verdict wait, intermittently #152 — PR The confine scenario waits for the run to be over before /exit, and keeps the app log #160 —
dcfc939+d351ee8— the confinement job's intermittent timeout attributed to stage 2 from the CI dump at1be0fda(the/exitlanded inside the run and the refusal was on screen); the scenario waits for a settled screen before/exit, and the job preserves the app log on failure (probe-verified, artifactconfine-debug-log); post-merge master run 37866189661 9/9. -
[security] A repository-local config can widen or remove the sandbox fence #162 — PR A project-local config can no longer widen the sandbox fence #164 —
aafa9dc— the fence's widening keys (project_root,allowed_paths) come from the user's own config, with a logged warning when a project file asks for them; the measured table flips, 13 packages ok plain and-race, 27 scenarios ok.
Activity
feat(tool): add the confinement launcher and the Landlock boundary
feat(tool): run shell commands under the kernel boundary when asked
test(tui): assert the command boundary on the filesystem
feat(sandbox): carry one frozen policy per run instead of a plan flag
feat(config): make a persistent grant say what it is, and let it be r…
feat(sandbox): one refusal vocabulary instead of four prefixes
docs: record the sandbox design and what it does not promise
yusiwen commentedon Oct 9, 2026
The batch is complete — S0 → S7 all landed
Every step in this plan is done and recorded in the progress log above:
Three defects were found and fixed through the batch's own gates rather than left in comments:
1be0fdadated it to stage 2 (the/exitlanded inside the run; the refusal [tui] A submitted command is dropped silently while a run is active #119 added is on the screen, and no run-in-flight spinner is), reproduced deterministically with a widened window, and fixed in PR The confine scenario waits for the run to be over before /exit, and keeps the app log #160 by waiting for a settled screen before/exit. That job now preserves the app debug log on failure, verified by a probe run whose artifact landed.sandbox.project_root/sandbox.allowed_paths; measured ond351ee8, fixed in PR A project-local config can no longer widen the sandbox fence #164 (the widening keys now come from the user's own file, with a logged warning when a project file asks for them).Definition of done, per step, as this issue set it: branch before the first commit; local evidence as command → result with counts; the PR references its issue and carries the honest scope; CI green with every job's
annotations_countat the documented baseline (1 per job, 2 each forbrowserandtui-visual); rebase-merge, branch deleted,masterhard-synced; a closing comment with the measurements and the hand-offs.One honest note on signatures, since it applies to every step: the branch commits were signed (
%G?=G), but a rebase-merge makes GitHub re-create each commit, so what lands onmasteris unsigned. That is the repository's merge strategy, not a property of the change.What is left
#139 — command confinement is opt-in, so the default posture for shell commands is still the string denylist. Deliberately deferred in this session: it needs two decisions first (the cache-root policy for a confined command, and the per-platform default on a host with no confinement mechanism), and its verification needs a Landlock host, so CI's
Sandbox confinement (Landlock + PTY)job is the gate. It stays open as the only security item from this batch still outstanding; #162 is closed.