Skip to content

feat(sandbox): make command confinement the per-platform default - #165

Merged
yusiwen merged 1 commit into
masterfrom
fix/139-confinement-default
Oct 9, 2026
Merged

yusiwen merged 1 commit into
masterfrom
fix/139-confinement-default

Conversation

@yusiwen

@yusiwen yusiwen commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Closes #139.

What changed

  • config.ResolveConfineCommands decides sandbox.confine_commands per platform: on where the host can confine a subprocess (Landlock ABI present), off where it cannot — the launcher fails closed, so a default of true there would refuse every command. ConfineCommands becomes *bool so "unset" is distinguishable from an explicit false, and UserConfineCommands() reads the off direction from the user layer alone.
  • tool.WritableRoots() gains SandboxConfig.CacheRoots, and the composition grants the platform user cache directory (os.UserCacheDir()) only when confinement is on. One list feeds the path fence and the command boundary — the shared-root rule from [security] Derive the writable roots once and share them between the file fence and command execution #120 — so a root added to one is added to both.
  • tool/cacheroots.go (new): PlatformCacheRoots(), PlatformTempDir(), confinedEnv(). A confined command's TMPDIR points at <cache>/tinycode/tmp (created before the command starts), so the shared temp area stays outside the fence while go build/go test still get scratch.
  • /sandbox now prints the effective answer and why (tool.CommandConfinementStatus), so a host with no mechanism says so instead of leaving commands silently unconfined.
  • The confine TUI scenario drops confine_commands: true and asserts the default instead of an opt-in.
  • docs/sandbox.md / README.md: the per-platform table, the granted cache root, and the layer rule.

Why

The boundary from #121 was proven but opt-in, so the default posture for shell commands was still CheckCommand's substring denylist — defeatable by sh -c 'rm -fr …', variable-assembled paths, or base64 -d | sh. The two blockers named in the issue are handled rather than flipped blind:

  • Cache roots. The platform user cache directory is granted only when confinement is on. It is acceptable because it holds toolchain caches (GOCACHE and most build tools), not source — losing it costs a rebuild, not data — and it is already the user's own per-user writable area. No temp area is granted; the toolchain is pointed at per-user scratch under the cache root instead.
  • Hosts without a mechanism. The default is per platform, not true: on such a host the launcher refuses every command, and refusing every command is not a boundary anyone can keep. The fact is stated in /sandbox and the startup log.

A project-local on-request is vetoed where the host cannot apply it. That layer arrives with the checkout, and merge() keeps the narrowing "on" direction from any layer, so without the veto a repository shipping confine_commands: true would refuse every command in a session that merely cloned it. The user's own file is not vetoed: false on a capable host is the documented escape hatch, and true where the default is off is an explicit request the launcher then answers by refusing, with the reason on /sandbox.

Verification

  • darwin: go test ./... -count=1 → 13/13 ok; go test ./... -count=1 -race → 13/13 ok; gofmt -l clean; GOOS=linux go vet ./... clean (this compiles the Linux-only acceptance test).
  • nuc12, Linux 6.8 with Landlock (lockdown,capability,landlock,yama,apparmor):
    • TestRepoBuildAndTestRunUnderTheDefaultBoundary → PASS, run with a redirected GOCACHE (/tmp/dsh-go-redirect/build), i.e. the env that used to make it skip. It derives its roots through PolicyFromConfig (the one derivation both consumers use), points GOCACHE inside the granted cache root, and runs go build ./... plus go test ./config/ ./types/ under the boundary.
    • TestLandlockBoundaryIsEnforced → PASS; make test-tui-confine → CONFINE inside=yes outside=no, ok: 15 steps — with the scenario now on the default.
    • Config tests: TestResolveConfineCommandsDefaultIsPerPlatform, TestResolveConfineCommandsUserMayTurnItOff, TestResolveConfineCommandsProjectCannotForceItOnWithoutAMechanism, TestMergeConfineCommandsOnlyNarrows → PASS.
  • Mutation evidence for the drift guard. Dropping CacheRoots from WritableRoots() makes TestCacheRootIsSharedByTheFenceAndTheCommandBoundary fail at the fence assertion (the fence refuses the cache root the command boundary grants), so the guard can fail; reverted immediately. The guard compares the launcher's --allow argv against PolicyFromConfig(...).Roots in both directions, so a second derivation on either side shows up as a count or membership mismatch; CheckPath derives from the same policy, which is what makes the two structurally unable to disagree.

Honest scope

  • macOS cannot confine a subprocess, so the acceptance assertion and make test-tui-confine run on Linux only; make test-tui-confine there skips with a printed reason rather than passing silently. The macOS contribution is the unit suite (13/13) and the compile of the Linux test file.
  • The acceptance test runs the repository's whole build (go build ./...) but two test packages (./config/, ./types/), not go test ./...: a test that runs the suite it belongs to would recurse. The end-to-end wiring assertion is the TUI scenario, which runs the real binary on a PTY and checks the filesystem afterwards.
  • Only os.UserCacheDir() is granted. ~/.npm, ~/.cargo and GOMODCACHE are not; a go mod download on a clean checkout would need a granted root. The issue names go build/go test, which pass.

Follow-ups

  • The per-platform decision is the "off where there is no mechanism" half of the issue's option (1); a Linux-only on-default with an explicit refusal elsewhere remains a defensible alternative if reviewers prefer it.

The kernel boundary from #121 only ran when sandbox.confine_commands was true,
and that defaulted to false, so the default posture for shell commands was
still CheckCommand's substring denylist. This flips the default on where the
host can actually apply a boundary, and states the per-platform answer
everywhere else.

Default (config.ResolveConfineCommands):
- on where the host can confine a subprocess (Landlock ABI present);
- off where it cannot, because the launcher fails closed and a default of on
  there would refuse every command. /sandbox and the startup log say so rather
  than leaving commands silently unconfined.

The user's own config decides when it names the key, in both directions: false
on a capable host is the documented escape hatch, true where the default is off
is an explicit request the launcher then answers by refusing each command.
A project-local config may turn confinement on (narrow) but never off (widen) —
and where the host has no mechanism that on-request is **vetoed**, because
merge() keeps the narrowing direction from any layer and a repository that
ships confine_commands:true would otherwise refuse every command in a session
that merely cloned it.

Granted cache roots — both the path fence and the command boundary read the one
WritableRoots list, per #120:
- the platform user cache directory, os.UserCacheDir(). Acceptable because it
  holds toolchain caches (GOCACHE and most build tools), not source — losing it
  costs a rebuild, not data — and it is the user's own per-user writable area. A
  confined command needs it or `go build` and `go test` fail. It is granted only
  when confinement is on, so a host without a mechanism does not widen the fence
  for a boundary it cannot apply.
- no temp area is granted. Instead a confined command's TMPDIR is pointed at
  <cache>/tinycode/tmp, created before the command starts, because the go
  command makes its work directory under $TMPDIR; the shared temp area stays
  outside the fence.

Tests: a drift test asserts the fence's writable set and the command's --allow
set are the same list, cache root included; a Linux acceptance test derives its
roots through PolicyFromConfig (the one derivation both consumers use), points
GOCACHE inside the granted cache root rather than skipping, and runs
`go build ./...` plus the config and types test packages under the boundary;
config tests pin the per-platform default, the user's escape hatch, the
project-layer veto and the on-only merge rule; the confine TUI scenario now
relies on the default instead of opting in.

Verified: darwin `go test ./... -count=1` and `-count=1 -race` clean, GOOS=linux
vet clean; on the Landlock host the acceptance test passes with a redirected
GOCACHE, /sandbox states the effective answer, and make test-tui-confine still
reports CONFINE inside=yes outside=no.
@yusiwen yusiwen added feature A new capability rather than an improvement to existing behaviour security Security hardening, sandbox or SSRF residual labels Oct 9, 2026
@yusiwen
yusiwen merged commit 7e6b017 into master Oct 9, 2026
9 checks passed
@yusiwen
yusiwen deleted the fix/139-confinement-default branch October 9, 2026 02:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature A new capability rather than an improvement to existing behaviour security Security hardening, sandbox or SSRF residual

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] Command confinement is opt-in, so the default posture is still the string denylist

1 participant