Skip to content

Latest commit

 

History

History
333 lines (260 loc) · 55 KB

CLAUDE.md

File metadata and controls

333 lines (260 loc) · 55 KB

devsandbox

Coding practices

After completing the task always run:

  • task test - to run tests
  • task lint - to run lint

Always prefer reasonable defaults if that it possible. Reduce amount of work for the user to do when this is possible. All defaults must be secure by default, and not cause any security issues if used without modification. Never bind to all interfaces by default, and do not expose any ports by default.

Errors must always be handled and reported when present. Silent failures are not acceptable.

notice.Warn is for something the user has to act on: a setting that was ignored, a control that reaches less than it says, a cleanup that did not run. A tool reporting that it started, or that an optional integration is not attached, is notice.Info. The distinction is load-bearing, not cosmetic - every warning raised before the workload starts is collected and blocks the launch on a confirmation prompt, so an announcement emitted at warn level turns every launch into a question and trains the user to answer it without reading. notice.Alert is a warning that must reach the terminal even during the running phase.

A ToolWithSetup runs in PhaseRunning, so its warnings must be notice.Alert. main.go's SetRunning() precedes iso.Run → bwrap.go's AddTools(), and writeMessagePhase only reaches stderr when always || verbose || PhaseStartup || PhaseTeardown - so a notice.Warn raised from tool setup is diverted to the log file and misses the confirmWarningsStdio gate entirely. Setup also must not return an error for a condition the user should merely be told about: builder.go aborts the launch on a setup error while docker.go only warns, so the same condition would be fatal on one backend and cosmetic on the other.

Setup runs more than once per launch, so a Setup that records state must assign it, never append. tools.Register hands out singletons and internal/isolator/docker.go calls getToolBindings twice per launch, each of which calls Setup on that same struct - so appending emits every binding twice, which is a trackMount panic on bwrap and a "Duplicate mount point" error on Docker, i.e. every launch of that tool failing. Git.refBindings is the worked example: setupReadWriteRefs assigns it, and dedups against the static set rather than trusting the count. The mirror rule is that Bindings must stay pure - tools info and tools check call it on a registry singleton that Setup never touched, so anything subprocess-shaped belongs in Setup, and Bindings only appends what it left behind, degrading to exactly the static set when that is nil.

A limit the user configured must never silently fail to apply. This is stricter than error handling, because the dangerous case is not an error at all - it is a success path that quietly enforces less than promised. systemd, for example, accepts CPUQuota= in a user scope and ignores it when the cpu controller is not delegated, warning only to the journal. Verify enforceability before launch and abort naming what is missing, rather than degrading to an unlimited run the user believes is capped. Where a value can be accepted but round to nothing (cpus = "0.004" becoming CPUQuota=0%), reject it at translation time.

The same rule applies to config devsandbox generates for a tool: never emit a key whose value will not resolve inside the sandbox. internal/sandbox/tools/git.go writes core.excludesFile into the safe gitconfig only once the file it names has been copied in, because git ignores a missing excludesFile with exit 0 and no warning - so a dangling value is not an error the user ever sees, it is their global ignore rules quietly not applying. copyAuxFiles deletes an uncopied key from the value map rather than leaving the caller to remember. A generated config is also read fresh every launch: caching it against one source file's mtime goes stale the moment the real source is an [include] target, which is a different file.

"Inside the sandbox" is not one absolute path. bwrap binds the sandbox home at the host home path, Docker and krun mount it at /home/sandboxuser - so a Binding whose Dest names a location inside the sandbox home has to say so with tools.Binding.HomeRelativeDest, or the Docker/krun conversion (internal/isolator/docker.go, which only remaps a Dest it is told to) mounts it where the guest never looks. Only a destination that must be used verbatim leaves the flag unset: an identical-path pin such as the shared temp directory or a host binary, or a repository path like the worktree .git, whose absolute gitdir: pointer has to resolve unchanged. A path value written into a generated config has the same problem and cannot use the flag, so spell it ~/-relative and let the tool expand it against $HOME - which is what core.excludesFile does. A tool Environment value gets a third treatment: remapEnvValue in internal/isolator/docker.go replaces every occurrence of the host home in the value with /home/sandboxuser on Docker and krun, which covers a bare path and the unix:, unix:path= and unix:// socket addresses alike - with one exception, a value that (behind the same scheme prefixes) equals or lies under the project directory is left verbatim, because the project is mounted at its identical host path. MISE_TRUSTED_CONFIG_PATHS is the worked example: remapped, it would name a directory nothing mounts, and the project config would silently stay untrusted.

A host config file the host itself reads back is never bind-mounted writable - seed a copy into the sandbox home instead. ~/.claude.json was a read-write bind, and the host's own Claude Code reads it back (mcpServers, project trust flags, oauth state), so a sandboxed agent could plant an MCP server the host then launched. Claude.Setup's seedClaudeJSON is the worked example: copy once with WriteFileAtomic at 0600; a non-empty copy is sandbox state and wins; a 0-byte one counts as absent, because bwrap created that placeholder at every existing sandbox home while the bind existed; and a symlink at the destination is replaced with a notice.Alert rather than refused, because a Setup error aborts a bwrap launch and would let the sandbox block its own next launch at will. TestRegistry_NoWritableBindUnderHostHome scans every tool for a writable MountBind under the host home so the class cannot return. The only entries it excuses are identical-path binds the host does not read as configuration - the shared temp directory, the opt-in zellij socket directory, the herdr proxy socket - and each is exercised, not merely declared, so extend that list only with something of the same kind.

Terminal socket proxies

Proxies that let sandboxed code drive a host terminal (kitty, herdr) share the same building blocks. Reuse them rather than reimplementing:

  • internal/socketproxy - UDS listener lifecycle. Handlers for streaming protocols must not assume one request per connection.
  • internal/cmdpattern - validation of commands the host will execute: CommandPattern for a single argv, ScriptPattern for a generated script body.

Two invariants matter, both because they have already been violated:

  • Pin the program to its resolved absolute path (cmdpattern.ResolveProgram), never match on basename. A few directories are write-through binds shared with the host at an identical path - internal/sandbox/tools/sharedtmp.go's directory is one - so basename matching lets the sandbox supply the binary. Most of the sandbox home is an overlay whose writes never reach the host, which is exactly why the resolved path is safe and the basename is not. If the binary cannot be resolved, deny everything rather than falling back.
  • Never validate a file the sandbox can still write. Read it once, validate those bytes, and copy them somewhere the sandbox cannot reach before handing the path to the host. Validating in place leaves a swap window.
  • A path the host will write needs a root, not just a shape - and a root is not the whole answer. sentinelPathRe accepted any absolute path of safe characters, and the clause it guards is printf "%s" "$rc" > '<sentinel>'.tmp && mv -f '<sentinel>'.tmp '<sentinel>' - so /home/u/.bashrc had the host destroy that file on the sandbox's word. sentinelAllowed confines it to a root the caller derives from SharedTmpPath, the same function that emits the bind mount, and an empty root denies rather than falling back to the shape test. The confinement is lexical for the reason above, so it bounds the path the script names, not the inode the redirect resolves to - and that root is sandbox-writable at an identical path, so a symlink planted at <sentinel>.tmp was followed and the victim truncated. Where devsandbox owns the bytes it can fix that: ScriptPattern.HardenBody prepends set -C, which makes every shell that can run the body open a > target O_CREAT|O_EXCL, and Relocator writes that rather than the validated bytes. Where the payload is forwarded verbatim (the kitty ; touch '<sentinel>' form) nothing can be inserted, so it stays a narrowing and says so.
  • A name the host's PATH resolves is host-derived only while that PATH stays out of the sandbox's reach. EDITOR=nvim was accepted on the grounds that the sandbox supplies the name and the host the location - but builder.go binds the project tree read-write at its own path, and .venv/bin, node_modules/.bin and a bin direnv or mise adds are all inside it and routinely on PATH. cmdpattern.LaunchBounds carries both writable roots (shared temp and project dir), built once in tools.launchBoundsFor so a validator's bound cannot drift from the mount; a lookup landing under either is refused, in the bare and the absolute form alike, and the same rule covers env. The wrapping shell splits by who owns the bytes: herdr's relocated command is devsandbox's own text and names /bin/sh absolutely, while the kitty argv is forwarded byte for byte and the upstream launcher emits bare sh, so the only move left is hostShellTrusted dropping the entire pattern set when the host resolves sh inside a bound.
  • A writable root has two spellings and a deny list needs both. projectDir is os.Getwd(), which returns $PWD verbatim whenever it names the same directory - so a shell that cd'd through a symlink hands devsandbox the link's name, while --bind <dir> <dir> has bwrap bind the target's inode. Both names then reach the same read-write tree, and a lexical test over one of them bounds nothing written as the other: HostProgramPaths reports what the host resolved, which is the real path, and the herdr relocation root is built from $HOME while the project bound came from $PWD. cmdpattern.ResolvedSpellings / ResolveRoots carry both forms, and LaunchBounds.UntrustedRoots and herdrproxy.NewRelocator go through them. Only a deny list may be expanded this way - widening an allow bound (SentinelRoot, the filter's cwd confinement) with a second spelling admits a path the caller never derived. Resolution stops at the deepest existing ancestor, because the shared temp directory does not exist yet when the bounds are built and the relocator must decide before it creates anything.
  • An allowlist of program names does not bound the program's flags. EDITOR accepting --?[A-Za-z0-9-]+ reinstated the whole hole knownEditors closes: every editor in that table has a flag that makes its trailing operand code - nvim -u, vim -S, emacs -l, hx -c, kak -e - and the operand is a file in the read-write project tree. editorOptionWords keys the accepted words on the editor, because -c is --create-frame to emacsclient and an ex command to vim; an editor with no entry takes no flags. The one escape hatch is a value byte-identical to the host's own EDITOR/VISUAL, which is host-derived by construction.

Scope mutations to resources the sandbox created, taking their ids from the server's response and never from the client. Deny by default: a method with no explicit validator is refused, not passed through.

Deny by default at the field level, not only at the method. Vetting the fields you understand and forwarding the original bytes leaves every other field unchecked - kitty @ launch carries around forty options, and reading type and the argv while passing env, cwd, copy_cmdline and stdin_source through meant the argv allowlist decided nothing. Decode every request with socketproxy.StrictUnmarshal into a struct that enumerates every field the upstream CLI emits, and pin or vet each security-relevant one. Three things make this specific: the CLI serializes its full option set, not just the non-defaults, so DisallowUnknownFields over a partial struct rejects legitimate requests - which is why the field list is pinned to a version recorded beside it (kittyproxy.KittyPayloadVersion) and a newer option denies the request naming itself. The envelope needs the same treatment as the command it carries: kitty executes an encrypted field and ignores the outer cmd, so a filter checking only the name it can see approves a command it cannot. And "declared" has to mean byte-exact, because both proxies forward the received bytes - encoding/json matches field names case-insensitively and lets the last match win, while Python's json.loads and serde keep the spellings distinct and read the one they name, so {"args":[…],"ARGS":[…]} validated the second list and shipped the first, with DisallowUnknownFields raising no objection because it uses the same relaxed matching. StrictUnmarshal scans the raw keys itself and denies an unknown, case-variant, or repeated key, plus trailing data. Anything that decodes a sandbox-supplied payload goes through it; a plain json.Unmarshal on the request side is the bug.

A response the filter cannot parse is a denial, not a passthrough. The one fail-open path in internal/kittyproxy was postProcessResponse logging a FilterLsResponse error and returning the upstream body - which is every host window, tab, title, cwd and per-window env, the exact set list_owned exists to narrow. A filter that inverts to "send everything" on a parse failure is worse than no filter, because the capability's name still promises the narrowing.

Two more invariants come from the herdr agent-reporting work:

  • Anchor every validator to something derived on the host. A request is checked against what devsandbox already knows - the pane id herdr gave this process, the agent devsandbox was asked to launch, the session directory that tool's own bindings produce - never against a value the request supplies. Where a bound is a filesystem path, take it from the same function that produces the bind mount so the two cannot drift apart.
  • Charset-restrict any sandbox-supplied string the host will hand to a shell. herdr shell-quotes a reported session id and types it into a host pane shell, so the filter caps it at 128 bytes of [A-Za-z0-9._-] rather than trusting an unversioned third-party quoter. Length checks are not enough. Confinement of a path that names a location inside the sandbox overlay must be lexical - pathWithin resolves symlinks against the host filesystem, which proves nothing about a path the host cannot see.
  • Scan runes, not bytes, when rejecting what a terminal will act on. A byte-wise < 0x20 test sees only C0. The C1 controls (U+0080-U+009F, U+009B being CSI) arrive UTF-8-encoded as bytes ≥ 0xC2 and sail straight through it, which defeats the check in the case it exists for. termsafe.HasControlRune (which internal/herdrproxy/filter.go calls) refuses Cc and Cf; ordinary non-ASCII text stays allowed, because the check bounds behavior, not charset.

A wrapper-only agent needs just the agentid entry; herdr session capture is what adds the other three. The agents table drives two things on its own: the shell wrappers (agentid.KnownAgents) and the run-agent worktree guard (IsResumeInvocation). An agent herdr cannot restore - herdr v0.7.4 compiles resume plans for claude/pi/codex only - belongs in the table with an empty resumeFlag and its own resume verbs in resumeAliases (opencode, copilot are the worked examples), and it deliberately gets no ToolWithAgentSessionDir: wiring capture for a launch herdr will never replay is dead code that also flips such a launch onto the filtered proxy for no benefit. A new row also reserves its name, case-insensitively, in shell_wrappers.commands (shellwrap.ValidateCommandName), so a config that already lists it fails validation after the upgrade - record the addition under Breaking Changes.

Adding an agent herdr can restore touches four places, and missing one fails silently. internal/agentid's agents table (the name, plus the resume shape herdr compiles in - claude/pi use a flag, codex a resume subcommand); the tool registered under that same name; ToolWithAgentSessionDir on that tool, derived from the same helper its bindings use; and a CategoryData binding for that directory so sessions survive the sandbox. The last two fail in opposite directions - a missing binding discards the session, a bound that drifts from the binding denies every real report. A binding alone is not enough either: Optional bindings whose host source is missing are skipped, so a directory the agent only ever creates in-sandbox needs a ToolWithSetup that creates it on the host (Codex.Setup is the worked example).

State the host trusts must live where the sandbox cannot write it. $XDG_STATE_HOME is repointed at the synthetic home inside the sandbox, which is what makes ~/.local/state/devsandbox/ safe for host-owned records such as internal/herdrstate. Anything under the project dir or a bound tool directory is sandbox-writable. Hash any opaque third-party id into the filename rather than letting it choose a path.

The shared temp directory

internal/sandbox/tools/sharedtmp.go owns the one directory bind-mounted read-write at an identical path on host and sandbox, plus the $TMPDIR export that points at it. A tool declares ToolWithSharedTmp and gets the mount, the export and the cleanup; it must not emit any of the three itself. Two tools emitting the same mount is a trackMount panic, which is why internal/sandbox/builder.go and internal/isolator/docker.go each emit it once for the first enabled tool that declares it rather than per tool.

  • $TMPDIR is global, and that is a cost, not a feature. It points here only because the launchers that need the directory are third-party shell scripts resolving ${TMPDIR:-/tmp} with no other hook. The consequence is that every temporary file in the sandbox lands on host-visible disk instead of the tmpfs /tmp the builder mounts - Go build temporaries, test scratch trees, agent scratchpads. That reached 500 MB in one project before anything reclaimed it. Do not widen the blast radius further by moving more state here.
  • Cleanup is gated on liveness, not on age alone. A cold start (no other devsandbox process registered under this sandbox home's .run/) empties the directory; a live sibling downgrades that to removing entries with nothing modified for 7 days. Wiping unconditionally is the bug this replaced: it yanked state from under a running tenant, and a non-recursive mkdir then failed with ENOENT.
  • Register before observing. prepareSharedTmp calls ensureRunDir first, then looks for siblings. Two sessions starting together then each see the other and both take the conservative path; the reverse order lets both conclude they are alone and delete each other's files.
  • Age must be judged over the whole subtree. A directory's mtime moves only when a direct child changes, so a tenant writing deep inside a tree it created days ago looks stale to a shallow Stat. modifiedSince walks, and treats any unreadable entry as recent - an unknown age must never authorize deletion.
  • The directory is keyed on a hash of the sandbox home, so there is exactly one spelling of that path. sandbox.SandboxHomePath(root) is it; a caller that spells it any other way - through a symlink, with a trailing slash, from a staged copy under .removing/ - hashes to a name nothing ever created and removes nothing, with no error. reclaim.liveSandboxHomes builds its live set through that same function rather than joining "home" itself, or the orphan sweep would name every live sandbox an orphan.
  • Keying on the path and not the inode is also why the rename runs under the lock. Staging the sandbox tree aside does not take the shared temp's name away from anyone: acquireSharedLock reads a missing root as "the sandbox is gone" and recreates it at the same path, which hashes to the same directory. stageForRemoval therefore renames it aside (tools.StageSharedTmpForRemoval) while still holding the exclusive lock, where the sandbox is provably idle - a removal running after the lock was released deletes the $TMPDIR of the launch that just took its place. Only the rename: the delete is a chmod walk over everything the sandbox put in $TMPDIR, and TeardownGracePeriod is already spent in full by beforeRemove, so unbounded work added under the lock aborts the launch sharedLockRetries is sized to hold on. The staged name is not a session hash, so a kill between the two leaves an orphan the 7-day sweep reclaims.
  • A configured base is not the full ownership list. sharedtmp_owner.go records each sandbox home under host-owned state before creating its temporary directory. Prune consults those records across base changes and preserves unknown hashes; absence from the current base plus age never proves an orphan. An inaccessible recorded base also blocks deletion, since it may be an unmounted volume.
  • Nothing the host trusts may live here. The sandbox can write every byte of it. Host-owned records go under $XDG_STATE_HOME/devsandbox/; see State the host trusts above.

Host-owned state and the reclaim catalogue

internal/reclaim catalogues every place devsandbox writes state on the host for its own bookkeeping - twelve Locations, each naming itself, its path and the function that sweeps it. It is a list of places, not of moments: every component keeps its sweep where it is and the launch keeps calling it in the order the launch needs, and the catalogue only points at that logic. That is what gives sandboxes prune one command that sweeps every location, one report shape for what a sweep did, and a reviewer one list to check a new state directory against. A BeforeRun/AfterRun hook bus was considered and rejected: AfterRun is defer with a registry in front, and every leak in these locations exists precisely because the process was killed before its own cleanup could run.

  • State devsandbox keeps for itself must record an owner per entry and appear in the catalogue. The owner is a pid in the entry name, a held lock, or a reference to state elsewhere whose absence proves the entry abandoned - herdr pane records name the sandbox root their launch used, and go when it does. TestLocations_CatalogueIsComplete pins the twelve names in order, so a location added without registering it, or one silently retired, fails there. Reclaiming by age alone is the weaker rule, because it cannot tell an abandoned entry from an idle one; where it is the only rule available it has to be justified at the registration, as the orphaned shared temp's 7 days is.
  • The pid probe answers uncertainty as alive, so a pid-keyed location needs an age backstop or an accepted leak. procstate.Alive answers dead only when the kernel says the process is gone; EPERM - a pid recycled by another user's process - reads as alive, so an uncertain probe never authorizes a deletion. Every one of these directories is per-user, so that answer is conservative rather than correct and the entry would otherwise survive forever. Session records and interrupted removals carry a 30-day backstop, because the first holds its session name against every later launch and the second is an entire sandbox tree. A backstop belongs on the uncertain answer and not on "alive", which is why those two read procstate.Probe rather than Alive: age says nothing about whether a session is still running - a record is rewritten only when its forwarded ports change - so aging out a pid the kernel confirms is live drops a running session from devsandbox sessions and frees its name for the next launch, and does the same to a staged tree the teardown holding that pid is still deleting. Egress markers and run directories deliberately carry none: the leak is an empty directory, and a wrong deletion costs a live session its exit-78 marker or its open sockets. Say which of the two it is where the location is registered.
  • ~/.local/share/devsandbox-scratchpads/ is the deliberate exception. It holds the user's work rather than devsandbox's bookkeeping, its lifecycle is user-directed through devsandbox scratchpad rm, and it must never be swept. It is named here as an exception so it does not read as a location someone forgot to register.
  • A second entry point needs its own safety, not the launch's. A launch establishes primary designation and run-dir registration before it sweeps; sandboxes prune establishes neither, so a nil Sweep is a real answer rather than an omission. Run directories and session overlay dirs are reported and never swept from prune - the launch already sweeps both, and taking the exclusive lock across a multi-gigabyte overlay delete can exhaust a concurrent launch's retry budget for zero bytes gained - and the live shared temp takes only its age branch, since a prune process is not registered in .run/ and so cannot conclude it is alone. All three stay in the list, because a list that omits what it does not sweep is no longer the list to check against.
  • The owning packages must never import internal/reclaim. Every Sweep is a plain function over plain arguments, so no owner needs the Location type, and that is what keeps the catalogue free of cycles. The check is that nothing outside cmd/ imports it. If an owner ever does need Location, split the catalogue into internal/reclaim/catalog rather than reaching back.
  • A log the host writes must not live on a path the sandbox can write. The sandbox home is bound read-write at the host home path, so <sandboxHome>/logs/internal/ is $HOME/logs/internal/ inside the sandbox, and a symlink planted there is followed by the host's O_APPEND open. Host-written logs belong under <sandboxRoot>/logs/, which is not bound - sandbox.log, tools-errors.log, <engine>.log and logging-errors.log all live there, and the wrapper log under $XDG_STATE_HOME/devsandbox/. doctor still reads the old home-side directory as well, deliberately, so sandboxes created before the move keep their logs findable.
  • An append-only host log is bounded by internal/logrotate, at the moment it is opened. Rotating on open is what makes the catalogue entry for the internal error logs reported-only: they are per-sandbox, so that sandbox's next launch bounds them, and a sweep would rotate a file no process holds open and reclaim nothing. The wrapper log rotates on open too - notice.Setup does it before the append handle opens - and is still swept, because notice.Setup runs only from a launch: a log that grew past the limit during the last launch would otherwise stay over it until the next one, on a host that may not have another.

Shell snippets evaluated on the host

internal/shellwrap generates the snippet devsandbox shell-wrappers activate <shell> (alias agent-wrappers) prints. devsandbox writes nothing to the user's shell config: the user pastes an activation line that evaluates the command's stdout at every shell start, like mise activate. Five things are load-bearing there:

  • Guard the whole snippet on DEVSANDBOX. internal/sandbox/tools/shell.go binds fish's config directory and bash/zsh rc files into the sandbox, so anything evaluated there is also evaluated in there - an unguarded claude function would re-invoke devsandbox claude recursively. Use non-empty semantics (test -z "$DEVSANDBOX" in fish, -n "${DEVSANDBOX:-}" in bash/zsh) and match it in Go, so DEVSANDBOX="" behaves identically everywhere.
  • Guard the activation line itself on DEVSANDBOX too. The snippet's own guard is too late: the rc files are bound in while devsandbox itself need not exist in the sandbox, so an unguarded line fails with command-not-found on every in-sandbox shell start. Same reason the old installed-file model needed an existence guard on its source line.
  • activate must not fail on a host with no agent installed. It runs on every shell start, so it emits a comment - still valid shell - rather than an error that would break the startup file. The comment is followed by the snippet even when nothing is wrapped: its cleanup is what removes an earlier activation's wrappers, so a comment alone would leave them defined.
  • A failure has to distinguish host state from a bad activation line. The bullet above is not a rule that activate never fails. An agent that is merely not installed - including one named in --agents - degrades to a comment, because it is a fact about this host that installing it later fixes on its own. A --agents value naming an unsupported agent, or an explicitly empty one, is wrong at every shell start until the line is edited, so it exits non-zero naming the supported agents. Both are settled before any discovery runs and before a byte reaches stdout (resolveAgentSelection ahead of resolveWrapperEnv in cmd/devsandbox/agentwrappers.go), or a rejected selection would leave a startup file evaluating half a snippet. The comment itself may call the list "supported" only when it is agentid.KnownAgents(); a narrowed selection worded that way asserts devsandbox supports nothing else. agentid.KnownAgents order is a documented contract now - docs/tools.md spells the emission order out - so reordering that table makes the docs wrong.
  • A project .devsandbox.toml never decides whether a shell gets wrappers. activate loads config the way a launch does, and the loader reads and parses that file before asking for trust - while the project directory is writable from inside the sandbox. So configuredCommands retries a failed load without the project file and warns on stderr; only a failing global config or include is returned as an error, because the sandbox cannot write those. The retry is not enough on its own: LoadConfigWithOptions calls os.Getwd() even with SkipLocalConfig, and the sandbox can remove the project subdirectory a host shell sits in, so an unresolvable directory falls back to config.LoadFrom on the global config. Returning the project error meant sandboxed code could plant an unparsable file and leave the next host shell with no claude wrapper, so herdr's typed resume ran on the host. For the same reason activation never prompts for trust (declineProjectTrust): the prompt reads stdin at shell start, where herdr has typed claude --resume <id> into the pane, so a sandbox that changed the file's hash had the prompt swallow the resume as its answer.

Emit devsandbox's resolved absolute path into the generated definitions, not command devsandbox: a login or terminal-multiplexer pane shell may have a different PATH (mise shims are the common case), and a project-local bin directory on PATH is sandbox-writable. The activation line is the one place PATH is used, because it runs before devsandbox can resolve anything and an absolute path there would break on every upgrade that moves the binary.

Proxy egress lockdown

internal/egress holds the deny-by-default rule set that makes proxy mode a boundary on both bwrap and krun. Four invariants are load-bearing, each of them the reason a gap that used to exist is closed:

  • The lockdown lands before the workload exists. On bwrap it is rendered into pasta's wrapper prologue, which runs as root in pasta's user namespace holding CAP_NET_ADMIN over the netns before it execs bwrap; on krun the in-guest shim waits on a sentinel. Applying rules after the sandbox starts - the obvious nsenter-after-launch shape - reopens a window in which untrusted code runs with egress open. Do not move it later.
  • One rule set, two application mechanisms. krun applies the [][]string lists via nsenter, bwrap renders the same lists into a shell script. Both backends' posture then diverges only by someone deliberately editing shared code, never by drift. A backend-specific rule belongs in Lockdown, not in a second builder.
  • The pasta family restriction and the ruleset family must stay in sync. The rules are IPv4 (the gateway is 10.0.2.2), so -4 is emitted on every pasta invocation that renders a lockdown. Removing -4 leaves a second address family nothing filters - which is exactly what made IPv6 egress survive the old route surgery.
  • pasta's automatic port forwarding must be off wherever the lockdown is rendered. -T/-U default to auto, which binds every host-listening port inside the namespace on loopback - and oif lo accept is a rule the firewall cannot drop, so that traffic is a direct path to the host's own 127.0.0.1 services that no gateway rule can see. egress.NoAutoForwardArgs emits -T none/-U none for each protocol with no configured outbound forward, and is shared by both backends for the same reason the rule lists are. A configured forward's explicit -T/-U already overrides the default, so it is left alone; inbound -t/-u is a different direction and out of this scope.
  • Fail closed, with no opt-out. A missing binary, an unloadable nf_tables/nf_conntrack, an undiscoverable route device, or any failing rule aborts the launch (exit code 78, mapped back to a named error so it is not read as a workload status). Binaries are resolved to absolute paths on the host - a bare name resolved against the invoking user's PATH is how the surgery silently never applied on /usr/sbin distros. There is deliberately no config key that degrades to unenforced egress.

The preflight and the doctor row share one probe that applies the real rule set in a throwaway user+net namespace. A cheaper check (binary presence, /sys/module/nf_tables) passes on a host with nf_tables but no nf_conntrack and lets the launch die mid-script instead. The preflight returns the resolved egress.Tools and the launch renders those - internal/bwrap never resolves its own. A second resolution would mean the probe verified one set of binaries and the sandbox ran another, which is a security control verified in name only.

The lockdown scopes the path, not the destination. It leaves the sandbox one way out - the proxy - and says nothing about where that way leads: internal/proxy's filter allows every destination until default_action is set, so cloud metadata, LAN hosts and host loopback ports are refused as direct sockets and still reachable through the proxy. That is a real narrowing (those requests become visible, loggable and refusable), and it is not unreachability. Docs and comments here say "no direct path", never "closed" unqualified - the overclaim was reported in review once already.

Three things about exit code 78 in the rendered prologue:

  • Every pre-exec failure must leave with exactly 78, not only the ones guarded by || fail. set -e alone exits with the failing command's status, and the device lookup runs awk and sleep through PATH - a shell that cannot find them exits 127, which asLockdownOrCommandExit passes through as a workload status. An EXIT trap converts any such abort; it is cleared immediately before exec "$@" so a workload's own status is never rewritten, and fail clears it before exiting so the handler cannot re-enter itself.
  • An abort can also arrive as a start failure, not just from Wait. If the wrapper dies before its child is visible in procfs, StartWithPasta sees a PID-discovery error and the 78 is the only evidence of what happened - so it is surfaced rather than discarded, and the launch path maps it the same way.
  • 78 alone does not identify an abort, so the prologue leaves a marker. The trap is cleared before exec "$@", so a workload that exits 78 itself is status-identical to an abort; classifying on the status alone turns that workload's exit into a security error and replaces its code with 1. Lockdown.ReadyFile is created after the last rule and immediately before the exec, and egress.LockdownApplied reads it: 78 with the marker is the workload's own status, 78 without it is the lockdown. Script refuses to render without a marker path rather than reintroduce the guess, and a marker that cannot be written aborts like any other step. The path is rooted at $XDG_STATE_HOME/devsandbox/egress, not at os.MkdirTemp("", …): that resolves against $TMPDIR, which the invoking user may have pointed at a directory bound read-write into the sandbox - and a workload that can delete the marker makes its own exit 78 read as an abort, destroying the signal. Same reasoning as internal/herdrstate; see State the host trusts above.

Proxy credential transport

The per-session proxy credential is the one secret devsandbox itself hands the sandbox, and the boundary it draws is "only this session", which includes other users on the host. Two things keep that true:

  • Never put it on a command line. /proc/<pid>/cmdline is world-readable on a default procfs, and the launcher lives for the whole session - pasta carries the entire bwrap invocation in its argv, the attached docker exec client carries its -e list. proxyenv.Var.Secret marks every credential-bearing variable, and each backend keys on it: the bwrap builder routes them through SetSecretEnv/SecretArgs and bwrap.StartWithPasta writes those to a 0600 file under $XDG_STATE_HOME/devsandbox/bwrap-args/ that the wrapper prologue opens on fd 3 for --args 3 (pasta closes every other inherited descriptor at startup, so the file cannot simply be passed down); the docker/krun backend emits a bare -e NAME and values it through commandEnv, which every exec.Cmd the engine runs must carry. That environment also sets NO_PROXY=*: the engine CLI honours HTTP(S)_PROXY for its own daemon transport on a tcp:// DOCKER_HOST, so without it every control-plane call would go through the sandbox proxy and be filtered. NO_PROXY is never bare in argv, so the wildcard reaches the CLI only and the container keeps its explicit value. TestBuilder_AddProxyEnvironment_KeepsCredentialOutOfArgv and TestProxyEnvArgs_KeepsCredentialOutOfArgv grep the rendered argv for the token; a new place that spells a Secret value into argv fails there.
  • One argv builder per exec path. docker.go's execCommand builds the workload exec from buildExecArgs and commandEnv, and installMiseTools goes through miseExecArgs for the same reason: a second hand-assembled docker exec argv silently dropped the re-injected credential on every reused keep_container container, so the second session of a project got 407 on every request while the tests over buildExecArgs stayed green. TestExecCommand_CarriesCurrentProxyEnv pins the path that runs, not the function that renders.

Proxy filter scopes

internal/proxy's filter has exactly three scopes - host, path, url (filter_types.go) - and an unset scope falls through to host. Three things are load-bearing:

  • Refuse a scope the request shape cannot carry, rather than enforcing less than it says. With MITM off, an HTTPS connection reaches the proxy as CONNECT host:port and nothing more, so host scope is enforceable there and path and url are not. NewServer aborts naming the offending rule instead of starting a server on which those rules read as covering everything and cover plain HTTP only. Same rule as the resource limits in Coding practices - a success path that quietly enforces less than promised is worse than an error.
  • goproxy routes CONNECT past the request hooks. ServeHTTP hands it to handleHttps, which runs only the httpsHandlers and then hijacks the connection and copies bytes, so the OnRequest().DoFunc the logging path installs never sees a tunnel. Filtering, ask mode, the decision cache and the per-CONNECT log entry all have to be driven from the HandleConnectFunc in setupMITM's transparent branch; adding them to the request hook silently does nothing.
  • Canonicalize the host on both sides, and key the cache on the canonical form. BLOCKED.EXAMPLE.COM and blocked.example.com. reach the same server as blocked.example.com, so NormalizeHost lowercases and strips one trailing dot, and rule compilation does the same to host-scoped exact and glob patterns. A host-scoped regex cannot be rewritten without corrupting its metacharacters, so it is compiled with (?i) instead - leaving it verbatim would silently retire every existing host regex containing uppercase, because the target is now always lowercase. A url-scoped regex is the one left as written, because a URL's path half is case-sensitive; that one is documented rather than fixed. Canonicalizing one side only is the same bug in the other direction: canonicalizeURL and canonicalizeURLPattern share canonicalizeAuthority so a url rule and a url target agree, including on dropping the port the scheme implies - MITM rebuilds every req.URL from the CONNECT target, so every intercepted HTTPS request arrives spelled https://host:443/.... Anything else that compares or map-keys a host - log_skip rules, the credential injector's host matcher, the ask-mode decision cache, Server.bypassedHosts - goes through the same helper or drifts from it.
  • The host a request goes to is req.URL.Host, never req.Host. goproxy rebuilds req.URL from the CONNECT target and leaves req.Host as the tunneled request spelled it, while the transport dials req.URL.Host. Matching the header meant CONNECT evil:443 carrying Host: allowed was checked against a name it was not contacting, and the same value keyed the decision cache, so one ask-mode approval was reusable against any destination. RequestHost is the single reader; filtering, the cache, the ask prompt and the audit event all go through it.

Ask mode socket and monitor

internal/proxy/askmode.go is the Unix socket between a proxy running --filter-default=ask and devsandbox proxy monitor; cmd/devsandbox/proxy.go is the monitor. Three invariants, each because it was violated:

  • Every connection opens with a role hello, and each end requires the opposite role. The socket path is per project, so a second session of the same project finds the first's socket live, dials it, and used to be accepted as that proxy's monitor - and an AskRequest forwarded to a proxy decoded as an AskResponse with an empty action, which read as allow. Two sessions then approved each other's requests with nobody asked. AskHandshake sends {"proto":"devsandbox-ask/1","role":"proxy"|"monitor"} as the first JSON object in both directions - on the initial connection and on every re-dial of the reconnect loop, because the path is the one a monitor used to own and a concurrent session's proxy may have taken it since - and a missing, wrong-protocol or same-role hello closes the connection before any request is sent. A proxy must never accept a proxy-role peer, in either direction. A proxy that dials and is refused enters a dead state - HasMonitor false, every Ask returns ErrNoMonitor and blocks, no reconnect - and reports it through HandshakeError instead of failing the launch, because the socket's owner could trigger that failure at will. main.go raises it as notice.Warn before SetRunning so it lands on the confirmation gate; a refusal during a reconnect is notice.Alert in refuseSocketOwner, because by then the workload owns the terminal. Independently of the handshake, RequestApproval maps any Action outside {allow, block} to block and logs the raw value with %q, and request ids come from crypto/rand, so a peer cannot resolve a request it was never sent.
  • A monitor unlinks the socket only on a dial nobody answers. connectOrServeMonitor treats errAskSocketStale (connection refused) as the one stale-socket signal; a peer that answers but fails the handshake is errAskPeerRejected and is reported as is. Removing a socket a running session owns would cut that session off from its monitor.
  • Any sandbox-supplied string printed to a host terminal goes through internal/termsafe. The monitor runs the terminal in raw mode and printed the request's method, host, path and headers verbatim, so a request could redraw the approval prompt or move the cursor - and its two error paths embedded the decode error, which quotes the offending bytes, into the same terminal. termsafe.Escape (Cc and Cf become visible escapes, printable non-ASCII passes) and termsafe.Truncate (by rune, never mid-character) wrap every field, the decision echo and both error paths in cmd/devsandbox/proxy.go. The rule is general: a byte that originated inside the sandbox reaches a host terminal only through termsafe, and that includes an error string that wraps sandbox input.

Session designation

internal/sandbox/lock.go decides which bwrap launch owns a project's persistent overlay. Three lock files, and the reason there are three is that each answers a different question:

  • .lock - held shared by every session for its lifetime, taken exclusively by IsSessionActive and RemoveSandboxIfIdle. This is the sandbox's public "is anyone here" signal.
  • .primary.lock - the designation gate, held exclusively across the designation only and released before the workload starts. It does not record who won; it only makes the decision one launch at a time.
  • .sessions.lock - held shared by every session for its lifetime, probed exclusively only by the designation itself while it holds the gate. Being alone here is being primary.

Probe and registration must be one indivisible step, and the gate is what makes them one. Every launch waits for the gate, probes .sessions.lock, registers in it, then releases the gate - so another launch either sees a full registration or has not started looking. Holding the gate for the primary's lifetime instead is the bug this replaced, and it broke in both directions: a launch that lost the gate registered itself outside it, so the winner's probe saw that registration and stood down, electing no primary at all (reproducible about half the time under two concurrent launches, with neither session writing the persistent overlay); and in the mirror case a launch could win the gate while a concurrent session that had not yet registered was already starting up.

Being alone is required, not just a free gate. The gate frees when its holder finishes designating, which says nothing about who is still running - and a launch treating that as "sole occupant" wipes a live session's overlay dirs on startup (CleanupStaleSessionDirs is written for a sandbox with no other occupant) and writes the upper that session has mounted as a read-only lower. That is why the probe exists, and why the probe cannot use .lock: a sandboxes list sampling liveness or a --rm teardown mid-removal holds .lock exclusively too, and reading either as a live session silently demotes the launch to concurrent, whose writes are discarded at exit. Taking the shared lock is retried rather than failed outright for the same teardown.

Gate cleanup that touches shared sandbox state on SessionHandle.IsPrimary(), not on Config.IsConcurrent. They are not complements: DesignateSession only reroutes bwrap launches, so a docker or krun launch is IsConcurrent == false even when it lost the designation. The sandbox home is derived from the project name, so such a launch shares one with a live bwrap session - and gating CleanupStaleSessionDirs on !IsConcurrent sent it straight through that session's upper and work dirs. IsConcurrent remains the right question for the two things that genuinely ask "am I on a session overlay": the builder's overlay routing, and the concurrent-session notice. The --rm teardown may also read it, because RemoveSandboxIfIdle re-probes liveness under the exclusive lock rather than trusting the flag.

A removal renames before it deletes. The exclusive lock does not hold a launch off on its own: RemoveAll unlinks .lock as an ordinary entry, and acquireSharedLock reopens the path with O_CREATE on every retry - so the moment the file is gone a waiting launch creates a fresh inode, flocks it, conflicts with nothing, is designated primary, and runs against a root still being deleted. RemoveSandboxIfIdle renames the tree to .removing/<name>-<pid> under the lock and deletes it afterwards, so the name vanishes atomically and the slow chmod walk runs off the path nobody is racing. ListSandboxes skips .removing by exact name, or a removal in flight reads as a sandbox.

Staging is a directory, not a name prefix, and the difference is a data-loss bug. Staging beside the sandboxes as .removing-<name>-<pid> puts both in one namespace, and no test over a name separates them: a bare prefix check also hides a real sandbox whose project basename starts with .removing-, leaving it unlistable and unprunable, and the obvious repair - also requiring a trailing -<pid> - is worse, because a sandbox directory is <basename>-<8 hex> and roughly one hash in 43 is all decimal digits. Such a sandbox then parses as a staged removal whose pid is above any pid_max, so ListAbandonedStaging reports live state and prune deletes it. Inside .removing/ there is nothing but staged trees, so stagedPID reads the pid suffix without inferring anything about the name in front of it. Anything that reclaims stranded state must keep that property rather than reintroduce a shape test.

The staging root is created once and never removed. It is shared across the whole sandbox base, so an rmdir that fires when it looks empty races a teardown for a different project: that one's MkdirAll is a no-op against the existing directory, and between it returning and its os.Rename issuing there is nothing staged for the first teardown to see. The rmdir then succeeds, the rename fails ENOENT, and that teardown reports a failed removal having already run beforeRemove - which deletes the --worktree checkout - and released its lock. An empty directory ListSandboxes skips by name costs nothing; tidying it up costs a half-applied --rm.

Platform-specific packages

internal/bwrap and internal/isolator/bwrap.go have no build tags - they compile on darwin even though bwrap is Linux-only. Anything they import must build on darwin too. A Linux-only package pulled into that import graph breaks the darwin release build.

Split such a package three ways: an untagged file holding the types and any pure logic, a _linux.go with the real implementation, and a _other.go (//go:build !linux) whose stub returns a clear "only supported on Linux" error rather than silently succeeding. internal/cgroups is the worked example.

task test, task lint and CI all build for the host only, so this class of break is invisible to them. Run task cross-build and task cross-test after touching a platform-split package or its importers.

Pinned dependencies

github.com/elazarl/goproxy was held at v1.8.4 because v1.8.5 wrapped the MITM client connection in a bufio.Writer flushed only after resp.Write returned, so response headers and small SSE events stayed buffered until the whole body was consumed - streaming responses arrived all at once. v1.9.0 writes the response through a head-only buffer (responseHeadWriter) and streams the body; the two regression tests in internal/proxy/server_test.go, TestServerSSE_StreamsHeadersWithoutBuffering and TestServerStreaming_EmptyContentTypeNotBuffered, pass on it (re-run 2026-09-02), and the renovate.json cap is gone. Those two tests stay the tripwire: if a goproxy bump makes them fail, the buffering is back - re-pin with an allowedVersions rule on github.com/elazarl/goproxy in renovate.json, keeping osvVulnerabilityAlerts on so a CVE update can still lift the cap, rather than loosen the tests. TestServerAuth_CONNECT_MITM in internal/proxy/server_auth_test.go is a second tripwire: the tunnel credential rides on goproxy copying ctx.UserData from the CONNECT context into every per-request context (https.go, http2.go), so a version that stops doing that fails it and would otherwise answer 407 to every request inside an authenticated tunnel.

Documentation site

README.md and CHANGELOG.md are the source of the site's home and changelog pages, pulled in with --8<--. A snippet's links are resolved against the including page, not the file they came from. Both files link as docs/proxy.md so they work when browsed on GitHub, which on the site meant /docs/docs/proxy/ and /docs/about/docs/proxy/ - 61 of the 66 links that were once broken there, and the reason task site:snippets rebases them into the git-ignored docs/.include/. Edit the two root files; never docs/.include/, and never point the snippet directives back at the originals.

Two checks guard this, and neither subsumes the other. zensical build --strict reads the Markdown source, so it catches a bad relative link in docs/*.md and is blind to everything inside a snippet. task site:check (site/linkcheck.py) reads the rendered HTML, so it sees the snippet bodies and the resolved anchors; it checks whatever site:build last produced, which is why both workflows spell the pair out as task site:build site:check. Both run in pages.yml and in lint.yml, the latter being what fails a pull request rather than a deploy. Neither fetches external links - that would put third-party uptime in the build's path.

A heading whose two slug flavors disagree cannot be linked from both places. GitHub renders ### Shell wrappers - run agents sandboxed by default as #shell-wrappers---run-agents-sandboxed-by-default and Python-Markdown as #shell-wrappers-run-agents-sandboxed-by-default, so any link to it is broken in one renderer or the other. Fix it at the heading - : in place of - collapses both to the same slug - rather than picking a spelling and breaking the other side.

Changelog

CHANGELOG.md follows the Keep a Changelog format with an [Unreleased] section at the top.

  • Every user-facing change MUST be recorded under [Unreleased] in the same change that introduces it: new features, behavior/config/CLI/API changes, bug fixes, and breaking changes. Do not defer it to a later "docs" commit.
  • Categorize each entry as Added, Changed, Fixed, or Breaking Changes. Describe what changed and why it matters to the user, not the implementation detail.
  • Do not itemize routine dependency bumps or CI/workflow digest updates. Only note a dependency change when it is user-visible (e.g. an embedded binary such as pasta or bwrap).
  • When cutting a release, rename [Unreleased] to ## [vX.Y.Z](https://github.com/zekker6/devsandbox/releases/tag/vX.Y.Z) - YYYY-MM-DD and repoint the [Unreleased] compare link to the new tag, in the same commit that gets tagged. Never tag a release while its content is still under [Unreleased].