Repository navigation
feat(sandbox): make command confinement the per-platform default - #165
Merged
Merged
Conversation
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.
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.
Closes #139.
What changed
config.ResolveConfineCommandsdecidessandbox.confine_commandsper platform: on where the host can confine a subprocess (Landlock ABI present), off where it cannot — the launcher fails closed, so a default oftruethere would refuse every command.ConfineCommandsbecomes*boolso "unset" is distinguishable from an explicitfalse, andUserConfineCommands()reads the off direction from the user layer alone.tool.WritableRoots()gainsSandboxConfig.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'sTMPDIRpoints at<cache>/tinycode/tmp(created before the command starts), so the shared temp area stays outside the fence whilego build/go teststill get scratch./sandboxnow prints the effective answer and why (tool.CommandConfinementStatus), so a host with no mechanism says so instead of leaving commands silently unconfined.confine_commands: trueand 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 bysh -c 'rm -fr …', variable-assembled paths, orbase64 -d | sh. The two blockers named in the issue are handled rather than flipped blind:GOCACHEand 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.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/sandboxand 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 shippingconfine_commands: truewould refuse every command in a session that merely cloned it. The user's own file is not vetoed:falseon a capable host is the documented escape hatch, andtruewhere the default is off is an explicit request the launcher then answers by refusing, with the reason on/sandbox.Verification
go test ./... -count=1→ 13/13 ok;go test ./... -count=1 -race→ 13/13 ok;gofmt -lclean;GOOS=linux go vet ./...clean (this compiles the Linux-only acceptance test).lockdown,capability,landlock,yama,apparmor):TestRepoBuildAndTestRunUnderTheDefaultBoundary→ PASS, run with a redirectedGOCACHE(/tmp/dsh-go-redirect/build), i.e. the env that used to make it skip. It derives its roots throughPolicyFromConfig(the one derivation both consumers use), pointsGOCACHEinside the granted cache root, and runsgo build ./...plusgo 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.TestResolveConfineCommandsDefaultIsPerPlatform,TestResolveConfineCommandsUserMayTurnItOff,TestResolveConfineCommandsProjectCannotForceItOnWithoutAMechanism,TestMergeConfineCommandsOnlyNarrows→ PASS.CacheRootsfromWritableRoots()makesTestCacheRootIsSharedByTheFenceAndTheCommandBoundaryfail 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--allowargv againstPolicyFromConfig(...).Rootsin both directions, so a second derivation on either side shows up as a count or membership mismatch;CheckPathderives from the same policy, which is what makes the two structurally unable to disagree.Honest scope
make test-tui-confinerun on Linux only;make test-tui-confinethere 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.go build ./...) but two test packages (./config/,./types/), notgo 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.os.UserCacheDir()is granted.~/.npm,~/.cargoandGOMODCACHEare not; ago mod downloadon a clean checkout would need a granted root. The issue namesgo build/go test, which pass.Follow-ups