Skip to content

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

Closed

Description

@yusiwen

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:

  1. 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.
  2. 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 breaks go build, go test, npm install and 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_commands to 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

  1. added
    featureA new capability rather than an improvement to existing behaviour
    securitySecurity hardening, sandbox or SSRF residual
    on Oct 8, 2026
  2. yusiwen commented on Oct 9, 2026

    @yusiwen
    OwnerAuthor

    Landed in PR #165 — rebase-merged to master as 7e6b017 on 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 the ubuntu-latest migration notice, 2 on browser and tui-visual for setup-chrome@v1) — zero new annotations. The Sandbox confinement (Landlock + PTY) job ran the acceptance scenario on CI.

    What the default is now

    Host sandbox.confine_commands Behaviour
    Kernel mechanism present (Linux + Landlock) on every shell command runs under the boundary
    No mechanism (macOS, or a failing probe) off string checks only, and /sandbox says which and why

    The user's own ~/.tinycode/config.json decides when it names the key, in both directions: false on a capable host is the escape hatch, true where 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 shipping confine_commands: true cannot 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:

    • 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 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.
    • no temp area. A confined command's TMPDIR is pointed at <cache>/tinycode/tmp (created before the command starts), so the shared temp area stays outside the fence while go build/go test get the scratch the go command needs.

    Measurements

    • darwin: go test ./... -count=1 → 13/13 ok; -count=1 -race → 13/13 ok; gofmt -l clean; GOOS=linux go vet ./... clean (compiles the Linux-only acceptance test).
    • nuc12 (Linux 6.8, Landlock): TestRepoBuildAndTestRunUnderTheDefaultBoundary → PASS with a redirected GOCACHE, running go build ./... plus go 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.
    • Gate can fail (mutation check): dropping CacheRoots from WritableRoots() makes the drift guard fail with the fence refuses the cache root the command boundary grants; 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.

    Honest scope

    • macOS cannot confine a subprocess, so the end-to-end assertion and make test-tui-confine run 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.
    • The acceptance test builds the whole module (go build ./...) but runs two test packages (./config/, ./types/), not go 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.
    • Only os.UserCacheDir() is granted. ~/.npm, ~/.cargo and GOMODCACHE are not, so a confined go mod download on 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 opencode agent 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureA new capability rather than an improvement to existing behavioursecuritySecurity hardening, sandbox or SSRF residual

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions