Repository navigation
[security] Command confinement is opt-in, so the default posture is still the string denylist #139
Description
Context
The kernel boundary for shell commands landed (#121) and is proven end to end: CI's Sandbox confinement job runs the real binary on a PTY and asserts CONFINE inside=yes outside=no — the kernel allows the write inside the granted root and refuses the one outside it.
But it only runs when sandbox.confine_commands is on, and that defaults to false. So the default posture for shell commands is still CheckCommand's substring denylist, which sh -c 'rm -fr …', variable-assembled paths, base64 -d | sh, or an absolute binary path all defeat. The boundary exists; it is not the default.
Why it was not flipped in #121
Two decisions have to be made first, and neither is a one-line change:
- Every command is refused on a host without a mechanism. The launcher fails closed, so a macOS user who turns the option on today gets a refusal for every command. Default-on needs either a per-platform story (boundary where the kernel has one, string checks elsewhere) or an explicit, documented decision that the feature is Linux-only and the default reflects that.
- A confined command may write only under the session's writable roots — the project root and its auto-allowed paths. Ordinary toolchains write their own caches (
GOCACHE,~/.npm,~/.cargo) and temp areas, so default-on breaksgo build,go test,npm installand friends unless those roots are granted.
Point 2 is also a policy question for the file fence: #120 pinned that the fence and the command boundary share one writable-root list, so adding cache roots widens what the agent's own write_file may touch as well.
What to build
- Decide the cache-root policy (most likely: the platform user cache directory, granted to both consumers) and land it as one change with tests — never as a side effect of flipping a default.
- Decide and document the per-platform default: what a host without a confinement mechanism does when the default is on.
- Flip
sandbox.confine_commandsto on, keeping the option to turn it off for a workflow that needs the wider boundary.
Acceptance
- The repository's own build and test commands (
make build,go test ./...) run under the boundary with the new default, asserted on a host with a mechanism. - A write outside the granted roots is still refused, and the path fence and the command boundary grant the same set — a test fails if they drift.
- On a host without a mechanism, the default is stated in the documentation and in
/sandbox, and commands behave as that statement says rather than being silently unconfined. - The change is one commit that names the granted cache roots and why each is acceptable.
Capture recipe
make test-tui-confine # the end-to-end assertion, on a host with Landlock
make build && go test ./tool/ -run Landlock -v # which mechanism this host offers
Activity
yusiwen commentedon Oct 9, 2026
Landed in PR #165 — rebase-merged to
masteras7e6b017on 2026-10-09. The post-merge run for that commit (37874719453) is 9/9 jobs green, with annotations at the documented baseline (1 per job for theubuntu-latestmigration notice, 2 onbrowserandtui-visualforsetup-chrome@v1) — zero new annotations. TheSandbox confinement (Landlock + PTY)job ran the acceptance scenario on CI.What the default is now
sandbox.confine_commands/sandboxsays which and whyThe user's own
~/.tinycode/config.jsondecides when it names the key, in both directions:falseon a capable host is the escape hatch,truewhere the default is off is an explicit request the launcher answers by refusing each command. A project-local file may turn confinement on (narrow) but never off (widen) — and where the host cannot apply the boundary, that on-request is vetoed, so a repository shippingconfine_commands: truecannot refuse every command in a session that merely cloned it.Granted cache roots (the one commit's rationale)
One root beyond the project, in the single
tool.WritableRoots()list both consumers read:os.UserCacheDir()). Acceptable because it holds toolchain caches (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. Granted only when confinement is on, so a host with no mechanism does not widen the fence for a boundary it cannot apply.TMPDIRis pointed at<cache>/tinycode/tmp(created before the command starts), so the shared temp area stays outside the fence whilego build/go testget the scratch thegocommand needs.Measurements
go test ./... -count=1→ 13/13 ok;-count=1 -race→ 13/13 ok;gofmt -lclean;GOOS=linux go vet ./...clean (compiles the Linux-only acceptance test).TestRepoBuildAndTestRunUnderTheDefaultBoundary→ PASS with a redirectedGOCACHE, runninggo build ./...plusgo test ./config/ ./types/under the boundary the product derives;TestLandlockBoundaryIsEnforced→ PASS;make test-tui-confine→CONFINE inside=yes outside=no,ok: 15 steps, with the scenario on the default rather than an opt-in.CacheRootsfromWritableRoots()makes the drift guard fail withthe fence refuses the cache root the command boundary grants; 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.Honest scope
make test-tui-confinerun on Linux only; on macOS that target skips with a printed reason. Locally the macOS contribution is the unit suite and the compile of the Linux test file.go build ./...) but runs two test packages (./config/,./types/), notgo test ./...— a test running the suite it belongs to would recurse. The 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, so a confinedgo mod downloadon a cold checkout still fails; that gap is now issue [sandbox] Granting only the platform cache root leaves a cold toolchain unable to fetch #166 rather than an undocumented surprise.Handed off
The implementation was produced by a delegated
opencodeagent on another host (nuc12) from this issue's text; every assertion above was then re-run independently here, on that host, and on CI, and three defects found in review were fixed before the merge (two macOS-failing tests; the project-local veto).