Skip to content

feat(tool): run shell commands under the kernel boundary when asked - #133

Merged
yusiwen merged 1 commit into
masterfrom
feat/confine-bash-commands
Oct 8, 2026
Merged

yusiwen merged 1 commit into
masterfrom
feat/confine-bash-commands

Conversation

@yusiwen

@yusiwen yusiwen commented Oct 8, 2026

Copy link
Copy Markdown
Owner

What changed

The second half of the command boundary: bash now goes through the launcher that landed in #132.

  • config/config.go — sandbox.confine_commands; a bool overlay can only turn it on.
  • tool/containment.go — the policy switch plus CommandConfinementAvailable(), which is a different question from ContainmentInfo(): a host can enforce the agent's own opens (the macOS component walk) and still have no way to confine a subprocess.
  • tool/sandboxexec.go — bashInvocation wraps the same ['bash','-c',cmd] argv; SandboxLauncherDiagnostics returns only the launcher's own lines.
  • tool/bash.go — uses the wrapped argv; a launcher failure is reported as "the command did not run", never as a command failure.
  • tool/confinement_{linux,other}.go — the launcher-capability probe, answered once per process.
  • tui/sandbox.go — /sandbox reports the switch and whether the host can honour it.

Refs #121. Landing plan: #127.

Why this shape

  • The mode follows the run. Plan mode is read-only and grants no path; build mode is workspace-write over exactly WritableRoots() — the same list the file fence consumes, which is what [security] Derive the writable roots once and share them between the file fence and command execution #120 exists for.
  • A launcher failure is not a command failure. The command never ran, so the result says so, shows only the launcher's own diagnostic lines, and adds nothing the command printed. Both the exit status and the prefix are required, so a command that exits 125 by itself is not read as a sandbox report.
  • Off by default, deliberately. A confined command can write only under the session's writable roots, so toolchains that write their own caches (GOCACHE, ~/.npm, …) fail. Turning it on is a choice that says what it costs; on a host with no mechanism, every command is refused rather than run unconfined.

Verification

  • go test ./... -count=1 → 13 packages ok / 0 failed; -race → 13 ok / 0 failed; make staticcheck exit 0; GOOS=linux GOARCH=arm64 go vet ./... clean; gofmt -l empty.
  • New tests, each naming what it pins:
    • the argv with confinement off is byte-for-byte the old one;
    • plan mode ⇒ --mode read-only with no --allow, build mode ⇒ --mode workspace-write with one --allow per writable root, both keeping the original command after the separator;
    • no launcher path ⇒ an error, so the command is not run at all;
    • a failing launcher ⇒ the result says the command did not run, carries the launcher's diagnostic, and does not carry the output the command would have produced;
    • a succeeding launcher ⇒ the command runs and its output arrives unchanged;
    • SandboxLauncherDiagnostics keeps only prefixed lines, so a command that prints words resembling our diagnostic contributes nothing.
  • CI: below.

Honest scope

  • The wrapping is proven with a stand-in launcher, on this host. The real boundary is exercised by the Linux acceptance test from feat(tool): add the confinement launcher and the Landlock boundary #132; this PR proves the wiring — argv shape, mode/root mapping, and the failure classification — which is platform-independent and runs locally.
  • No scenario yet, and the confine-* CI gate does not exist. That is the last piece of [security] bash has no kernel-enforced file boundary — the guard is a substring denylist #121 and must land before the issue closes: a stub mode whose command writes one file inside the project and one outside, with the wrapper shell printing the verdict, so the assertion is selective and a fail-closed refusal cannot masquerade as a working boundary.
  • No default-on story. Making confinement the default needs the cache-root question answered (which roots a toolchain may write), which is a policy change of its own.

The second half of the command boundary: `bash` now goes through the launcher
that landed with the mechanism, behind a config switch.

- `sandbox.confine_commands` turns it on; the policy lives in the tool package
  next to the other capability facts.
- `bashInvocation` wraps the same ['bash','-c',cmd] argv the tool always ran.
  The mode follows the run: plan is read-only and grants no path, build is
  workspace-write over exactly WritableRoots(). Nothing else about the command
  changes, and with the switch off the argv is byte-for-byte what it was.
- A launcher failure is reported as "the command did not run", not as a command
  failure, and only the launcher's own diagnostic lines are shown: the command
  never ran, so its output must not be attributed to it. Both the exit status
  and our prefix are required, so a command that exits 125 by itself is not read
  as a sandbox report.
- `/sandbox` reports the switch and whether the host can honour it. Confining a
  subprocess is a different capability from confining our own opens, so it gets
  its own verdict: a host can have one and not the other.
- Off by default, deliberately: a confined command can write only under the
  session's writable roots, so toolchains that write their own caches break. It
  is a choice that says what it costs, not a silent default.

Refs #121. Landing plan: #127.
@yusiwen yusiwen added enhancement New feature or request security Security hardening, sandbox or SSRF residual labels Oct 8, 2026
@yusiwen
yusiwen merged commit ba245db into master Oct 8, 2026
8 checks passed
@yusiwen
yusiwen deleted the feat/confine-bash-commands branch October 8, 2026 03:49
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