After completing the task always run:
task test- to run teststask 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.
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:CommandPatternfor a single argv,ScriptPatternfor 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.
sentinelPathReaccepted any absolute path of safe characters, and the clause it guards isprintf "%s" "$rc" > '<sentinel>'.tmp && mv -f '<sentinel>'.tmp '<sentinel>'- so/home/u/.bashrchad the host destroy that file on the sandbox's word.sentinelAllowedconfines it to a root the caller derives fromSharedTmpPath, 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>.tmpwas followed and the victim truncated. Where devsandbox owns the bytes it can fix that:ScriptPattern.HardenBodyprependsset -C, which makes every shell that can run the body open a>targetO_CREAT|O_EXCL, andRelocatorwrites 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
PATHresolves is host-derived only while thatPATHstays out of the sandbox's reach.EDITOR=nvimwas accepted on the grounds that the sandbox supplies the name and the host the location - butbuilder.gobinds the project tree read-write at its own path, and.venv/bin,node_modules/.binand abindirenv or mise adds are all inside it and routinely onPATH.cmdpattern.LaunchBoundscarries both writable roots (shared temp and project dir), built once intools.launchBoundsForso 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 coversenv. The wrapping shell splits by who owns the bytes: herdr's relocated command is devsandbox's own text and names/bin/shabsolutely, while the kitty argv is forwarded byte for byte and the upstream launcher emits baresh, so the only move left ishostShellTrusteddropping the entire pattern set when the host resolvesshinside a bound. - A writable root has two spellings and a deny list needs both.
projectDirisos.Getwd(), which returns$PWDverbatim 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:HostProgramPathsreports what the host resolved, which is the real path, and the herdr relocation root is built from$HOMEwhile the project bound came from$PWD.cmdpattern.ResolvedSpellings/ResolveRootscarry both forms, andLaunchBounds.UntrustedRootsandherdrproxy.NewRelocatorgo 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.
EDITORaccepting--?[A-Za-z0-9-]+reinstated the whole holeknownEditorscloses: 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.editorOptionWordskeys the accepted words on the editor, because-cis--create-frameto 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 ownEDITOR/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 -pathWithinresolves 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
< 0x20test 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(whichinternal/herdrproxy/filter.gocalls) 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.
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.
$TMPDIRis 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/tmpthe 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-recursivemkdirthen failed with ENOENT. - Register before observing.
prepareSharedTmpcallsensureRunDirfirst, 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.modifiedSincewalks, 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.liveSandboxHomesbuilds 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:
acquireSharedLockreads a missing root as "the sandbox is gone" and recreates it at the same path, which hashes to the same directory.stageForRemovaltherefore 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$TMPDIRof the launch that just took its place. Only the rename: the delete is a chmod walk over everything the sandbox put in$TMPDIR, andTeardownGracePeriodis already spent in full bybeforeRemove, so unbounded work added under the lock aborts the launchsharedLockRetriesis 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.gorecords 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.
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_CatalogueIsCompletepins 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.Aliveanswers 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 readprocstate.Proberather thanAlive: 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 fromdevsandbox sessionsand 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 throughdevsandbox 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 pruneestablishes neither, so a nilSweepis 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. EverySweepis a plain function over plain arguments, so no owner needs theLocationtype, and that is what keeps the catalogue free of cycles. The check is that nothing outsidecmd/imports it. If an owner ever does needLocation, split the catalogue intointernal/reclaim/catalograther 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'sO_APPENDopen. Host-written logs belong under<sandboxRoot>/logs/, which is not bound -sandbox.log,tools-errors.log,<engine>.logandlogging-errors.logall live there, and the wrapper log under$XDG_STATE_HOME/devsandbox/.doctorstill 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.Setupdoes it before the append handle opens - and is still swept, becausenotice.Setupruns 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.
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.gobinds fish's config directory and bash/zsh rc files into the sandbox, so anything evaluated there is also evaluated in there - an unguardedclaudefunction would re-invokedevsandbox clauderecursively. Use non-empty semantics (test -z "$DEVSANDBOX"in fish,-n "${DEVSANDBOX:-}"in bash/zsh) and match it in Go, soDEVSANDBOX=""behaves identically everywhere. - Guard the activation line itself on
DEVSANDBOXtoo. 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 itssourceline. activatemust 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
activatenever 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--agentsvalue 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 (resolveAgentSelectionahead ofresolveWrapperEnvincmd/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 isagentid.KnownAgents(); a narrowed selection worded that way asserts devsandbox supports nothing else.agentid.KnownAgentsorder is a documented contract now -docs/tools.mdspells the emission order out - so reordering that table makes the docs wrong. - A project
.devsandbox.tomlnever decides whether a shell gets wrappers.activateloads 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. SoconfiguredCommandsretries 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:LoadConfigWithOptionscallsos.Getwd()even withSkipLocalConfig, and the sandbox can remove the project subdirectory a host shell sits in, so an unresolvable directory falls back toconfig.LoadFromon the global config. Returning the project error meant sandboxed code could plant an unparsable file and leave the next host shell with noclaudewrapper, 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 typedclaude --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.
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_ADMINover the netns before it execs bwrap; on krun the in-guest shim waits on a sentinel. Applying rules after the sandbox starts - the obviousnsenter-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
[][]stringlists viansenter, 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 inLockdown, 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-4is emitted on every pasta invocation that renders a lockdown. Removing-4leaves 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/-Udefault toauto, which binds every host-listening port inside the namespace on loopback - andoif lo acceptis a rule the firewall cannot drop, so that traffic is a direct path to the host's own127.0.0.1services that no gateway rule can see.egress.NoAutoForwardArgsemits-T none/-U nonefor 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/-Ualready overrides the default, so it is left alone; inbound-t/-uis 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'sPATHis how the surgery silently never applied on/usr/sbindistros. 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 -ealone exits with the failing command's status, and the device lookup runsawkandsleepthroughPATH- a shell that cannot find them exits 127, whichasLockdownOrCommandExitpasses through as a workload status. AnEXITtrap converts any such abort; it is cleared immediately beforeexec "$@"so a workload's own status is never rewritten, andfailclears 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,StartWithPastasees 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.ReadyFileis created after the last rule and immediately before the exec, andegress.LockdownAppliedreads it: 78 with the marker is the workload's own status, 78 without it is the lockdown.Scriptrefuses 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 atos.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 asinternal/herdrstate; see State the host trusts above.
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>/cmdlineis world-readable on a default procfs, and the launcher lives for the whole session - pasta carries the entire bwrap invocation in its argv, the attacheddocker execclient carries its-elist.proxyenv.Var.Secretmarks every credential-bearing variable, and each backend keys on it: the bwrap builder routes them throughSetSecretEnv/SecretArgsandbwrap.StartWithPastawrites 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 NAMEand values it throughcommandEnv, which everyexec.Cmdthe engine runs must carry. That environment also setsNO_PROXY=*: the engine CLI honoursHTTP(S)_PROXYfor its own daemon transport on atcp://DOCKER_HOST, so without it every control-plane call would go through the sandbox proxy and be filtered.NO_PROXYis never bare in argv, so the wildcard reaches the CLI only and the container keeps its explicit value.TestBuilder_AddProxyEnvironment_KeepsCredentialOutOfArgvandTestProxyEnvArgs_KeepsCredentialOutOfArgvgrep the rendered argv for the token; a new place that spells aSecretvalue into argv fails there. - One argv builder per exec path.
docker.go'sexecCommandbuilds the workload exec frombuildExecArgsandcommandEnv, andinstallMiseToolsgoes throughmiseExecArgsfor the same reason: a second hand-assembleddocker execargv silently dropped the re-injected credential on every reusedkeep_containercontainer, so the second session of a project got 407 on every request while the tests overbuildExecArgsstayed green.TestExecCommand_CarriesCurrentProxyEnvpins the path that runs, not the function that renders.
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:portand nothing more, so host scope is enforceable there andpathandurlare not.NewServeraborts 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
CONNECTpast the request hooks.ServeHTTPhands it tohandleHttps, which runs only the httpsHandlers and then hijacks the connection and copies bytes, so theOnRequest().DoFuncthe 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 theHandleConnectFuncinsetupMITM'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.COMandblocked.example.com.reach the same server asblocked.example.com, soNormalizeHostlowercases and strips one trailing dot, and rule compilation does the same to host-scopedexactandglobpatterns. 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:canonicalizeURLandcanonicalizeURLPatternsharecanonicalizeAuthorityso a url rule and a url target agree, including on dropping the port the scheme implies - MITM rebuilds everyreq.URLfrom the CONNECT target, so every intercepted HTTPS request arrives spelledhttps://host:443/.... Anything else that compares or map-keys a host -log_skiprules, the credential injector'shostmatcher, 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, neverreq.Host. goproxy rebuildsreq.URLfrom the CONNECT target and leavesreq.Hostas the tunneled request spelled it, while the transport dialsreq.URL.Host. Matching the header meantCONNECT evil:443carryingHost: allowedwas 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.RequestHostis the single reader; filtering, the cache, the ask prompt and the audit event all go through it.
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
AskRequestforwarded to a proxy decoded as anAskResponsewith an empty action, which read as allow. Two sessions then approved each other's requests with nobody asked.AskHandshakesends{"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 -HasMonitorfalse, everyAskreturnsErrNoMonitorand blocks, no reconnect - and reports it throughHandshakeErrorinstead of failing the launch, because the socket's owner could trigger that failure at will.main.goraises it asnotice.WarnbeforeSetRunningso it lands on the confirmation gate; a refusal during a reconnect isnotice.AlertinrefuseSocketOwner, because by then the workload owns the terminal. Independently of the handshake,RequestApprovalmaps anyActionoutside{allow, block}to block and logs the raw value with%q, and request ids come fromcrypto/rand, so a peer cannot resolve a request it was never sent. - A monitor unlinks the socket only on a dial nobody answers.
connectOrServeMonitortreatserrAskSocketStale(connection refused) as the one stale-socket signal; a peer that answers but fails the handshake iserrAskPeerRejectedand 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) andtermsafe.Truncate(by rune, never mid-character) wrap every field, the decision echo and both error paths incmd/devsandbox/proxy.go. The rule is general: a byte that originated inside the sandbox reaches a host terminal only throughtermsafe, and that includes an error string that wraps sandbox input.
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 byIsSessionActiveandRemoveSandboxIfIdle. 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.
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.
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.
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.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, orBreaking 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
pastaorbwrap). - When cutting a release, rename
[Unreleased]to## [vX.Y.Z](https://github.com/zekker6/devsandbox/releases/tag/vX.Y.Z) - YYYY-MM-DDand 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].