From 3563d76cae4dbda78e4d8dbd6301c4e0aa6431f3 Mon Sep 17 00:00:00 2001 From: prode Date: Fri, 28 Aug 2026 11:51:34 -0300 Subject: [PATCH 1/2] fix(jail): map scc, rtk and codegraph past the sandbox's private home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-jail replaces $HOME with a fresh tmpfs and binds only the command it was handed — "tools with needs beyond their install directory stay on the --map escape hatch" — then prunes PATH to what survived. So `scc launch --jail` ran RTK's preflight, wrote the block telling the agent to prefix every command with rtk, and started it where ~/.cargo/bin no longer exists; an npm-installed scc went the same way, while every rule scaffolded here answers questions with it. The agent discovers that one failed command at a time and falls back to reading whole files, which is the cost this methodology exists to remove. --map becomes the third flag scc asks for, read off `ai-jail --help` like the other two and never guessed at. A compiled binary is mounted read-only at the name PATH knows and brings nothing with it — which is also what makes the npm build work, since os.Executable is the real Go binary behind the node shim. A script brings its bin directory, its outermost node_modules and its shebang interpreter, because node resolves a script's real path before looking for anything beside it. The list is closed at scc, rtk and codegraph: what scc's own guidance names, nothing else, every mount reported in jail.maps and printed. The jail argv is now composed after the graph and RTK steps rather than beside the refusal, because a run that has just installed rtk has to map the binary it installed. The refusal stays where it was, before anything touches the workspace. --- CLAUDE.md | 8 +- design/orchestration.md | 51 ++++-- internal/cli/launch.go | 127 ++++++++++++-- internal/cli/launch_test.go | 67 ++++++++ internal/jail/jail.go | 11 +- internal/jail/toolchain.go | 292 ++++++++++++++++++++++++++++++++ internal/jail/toolchain_test.go | 244 ++++++++++++++++++++++++++ 7 files changed, 770 insertions(+), 30 deletions(-) create mode 100644 internal/jail/toolchain.go create mode 100644 internal/jail/toolchain_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dec3c36..a45fae4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,11 @@ These landed after phase 10, and all are documented in `design/orchestration.md` **It is the one integration that refuses.** Headroom, CodeGraph and RTK all degrade to starting the agent bare, because each is an enhancement. A sandbox is the property the user asked for by name, so a missing binary, a declined install, an unattended run, or an unsupported platform all end in **nothing starting** — and the refusal happens before the graph is built or the entry file is touched, so it leaves the workspace as it found it. Somebody who typed `--jail` and watched an agent start believes they are contained, and a false belief about containment is worse than a known absence of it: they would not have run the thing at all. - **scc decides two flags and no more.** `--network` and `--agent-state` are function rather than policy — an agent with neither cannot reach its model or authenticate, so `ai-jail claude` bare is a jail that starts nothing. Everything else (lockdown, denied paths, extra mounts, Docker, the browser) is policy, and policy lives in ai-jail's own `~/.ai-jail` / `./.ai-jail`, which it reads by itself and scc never writes; `--jail-arg` is the per-run escape hatch and comes last so it wins. Even those two are read off `ai-jail --help` rather than compiled in — the lesson `internal/headroom` already paid for — and a build that advertises neither gets **no substitute**, only a warning, because a sandbox opened by a guess is the failure this whole feature exists to prevent. + **scc asks for two capabilities and its own toolchain.** `--network` and `--agent-state` are function rather than policy — an agent with neither cannot reach its model or authenticate, so `ai-jail claude` bare is a jail that starts nothing. Everything else (lockdown, denied paths, Docker, the browser) is policy, and policy lives in ai-jail's own `~/.ai-jail` / `./.ai-jail`, which it reads by itself and scc never writes; `--jail-arg` is the per-run escape hatch and comes last so it wins. + + **`--map` is the third flag, and it is the same argument one step later.** ai-jail's private home replaces `$HOME` with a fresh tmpfs, binds only the command it was handed ("tools with needs beyond their install directory stay on the `--map` escape hatch"), and then prunes `PATH` to what survived — so `rtk` in `~/.cargo/bin` and an npm-installed `scc` are simply gone, while the entry file still tells the agent to prefix every command with one and the rules still tell it to answer questions with the other. It discovers that one failed command at a time and falls back to reading whole files, which is the cost this methodology exists to remove. So `jailToolchain` maps three binaries back in read-only — `scc`, `rtk`, `codegraph`, the list closed because scc can name exactly what its own guidance names — reported in `jail.maps` and printed in the command line. A compiled binary is mounted at the name `PATH` knows and brings nothing with it; that is also what makes the npm build work, since `os.Executable()` is the real Go binary behind the node shim. A **script** brings its bin directory, its package root (the outermost `node_modules`) and its shebang interpreter, because node resolves a script's real path before looking for anything beside it — that is `codegraph`. + + All three flags are read off `ai-jail --help` rather than compiled in — the lesson `internal/headroom` already paid for — and a build that advertises one of them gets **no substitute**, only a warning, because a sandbox opened by a guess is the failure this whole feature exists to prevent. **Windows has no backend and is unlikely to get one**: the sandbox stands on Linux namespaces and Apple's sandbox interface. WSL2 is the answer there, and it is a real one rather than a workaround — scc inside WSL2 is scc on Linux. The idea and the tool are [Fábio Akita's](https://akitaonrails.com/2026/01/10/ai-agents-garantindo-a-protecao-do-seu-sistema/); scc integrates the binary rather than the article's original shell script, because that script has since become a maintained Rust tool with a second platform backend. @@ -194,7 +198,7 @@ Five packages sit off to the side of that tree — `rtk`, `headroom`, `codegraph | `internal/validate` | The nine validators, one file each, sharing `mdscan` and `finding`. The exception is `stack_manifests.go`: the seven dependency-file readers age on their own schedule, so they sit beside the rule rather than inside it. | | `internal/rtk` | RTK's marker pair (`rtk.Markers`, spliced by `internal/mdblock`), the foreign-block detection that names Headroom's copy, and finding or `cargo install`ing the binary. | | `internal/headroom` | Headroom's agent-slug table, the `wrap` argument vector, the MCP opt-out discovered from `wrap --help`, and finding or installing the binary (uv, then pip — never npm, which ships the SDK and no CLI). The slugs live here rather than on `paths.Harness` because they are Headroom's vocabulary, not scc's layout. | -| `internal/jail` | [ai-jail](https://github.com/akitaonrails/ai-jail)'s argument vector, its platform backends, the two flags scc asks for (discovered from `--help`), and finding or `cargo install`ing the binary. Composes a command line and reimplements nothing: a sandbox is security-critical kernel interface work, and a half-copy of one has the confidence of containment without the containment. | +| `internal/jail` | [ai-jail](https://github.com/akitaonrails/ai-jail)'s argument vector, its platform backends, the three flags scc asks for (discovered from `--help`), the walk that works out which toolchain paths the sandbox's private home would take away, and finding or `cargo install`ing the binary. Composes a command line and reimplements nothing: a sandbox is security-critical kernel interface work, and a half-copy of one has the confidence of containment without the containment. | | `internal/git` | `git` and `gh`, read-only: does this branch still exist, has it landed, and what did the forge do with the pull request. Two binaries in one package because the question has two halves and no caller should have to route between them — and nothing here installs or writes, so running it over every spec in a workspace is safe by construction. | | `internal/codegraph` | CodeGraph's argument vectors (`init`/`sync`/`index`/`status`/`query`/`explore`), the `.codegraph/` presence test, `codegraph.Markers` for the usage block `scc launch` splices, and finding or `npm install -g`ing the binary. Composes command lines and reads nothing inside the graph — the database is CodeGraph's schema on CodeGraph's schedule. | | `internal/cli` | The dispatcher and every command handler. | diff --git a/design/orchestration.md b/design/orchestration.md index 3f4d4b4..ca41d62 100644 --- a/design/orchestration.md +++ b/design/orchestration.md @@ -1012,19 +1012,44 @@ a platform with no backend all end in nothing starting, and the refusal is decid before the graph is built or the entry file is touched: a launch that cannot be jailed leaves the workspace exactly as it found it. -**scc decides two flags and no more.** ai-jail defaults network and credential state -to off, which is the right default for a sandbox and the wrong one for a launcher: an -agent with neither cannot reach the model it is or authenticate as anyone, so -`ai-jail claude` bare is a jail that starts nothing. Those two are *function*. -Everything else — lockdown, denied paths, extra mounts, Docker, the browser — is -*policy*, and policy belongs in ai-jail's own `~/.ai-jail` and `./.ai-jail`, which it -reads by itself and scc never writes. A launcher that quietly loosened somebody's -sandbox policy would be the worst kind of helpful. `--jail-arg` is the per-run escape -hatch, and it comes last on the command line so a flag the user typed wins over the -two scc supplies. - -Even those two are read off `ai-jail --help` rather than compiled in — the lesson -§6's Headroom integration already paid for — and a build advertising neither gets +**scc asks for two capabilities and its own toolchain, and nothing else.** ai-jail +defaults network and credential state to off, which is the right default for a +sandbox and the wrong one for a launcher: an agent with neither cannot reach the +model it is or authenticate as anyone, so `ai-jail claude` bare is a jail that starts +nothing. Those two are *function*. Everything else — lockdown, denied paths, Docker, +the browser — is *policy*, and policy belongs in ai-jail's own `~/.ai-jail` and +`./.ai-jail`, which it reads by itself and scc never writes. A launcher that quietly +loosened somebody's sandbox policy would be the worst kind of helpful. `--jail-arg` +is the per-run escape hatch, and it comes last on the command line so a flag the user +typed wins over what scc supplies. + +**The toolchain is the same argument one step later, and it was originally missed.** +ai-jail's private home replaces `$HOME` with a fresh tmpfs and binds only the command +it was handed — it says so plainly: *tools with needs beyond their install directory +stay on the `--map` escape hatch* — and then prunes `PATH` to the directories that +survived. So `rtk` in `~/.cargo/bin` and an npm-installed `scc` disappear, and the +agent is left in front of an entry file telling it to prefix every command with a +binary that is not there and rules telling it to answer questions with a command that +is not there either. It finds that out one failed command at a time and works around +it by reading whole files, which is the cost this methodology exists to remove. A +jail that starts an agent unable to run the tools the file in front of it names is a +jail that starts a useless agent — the same failure as one that cannot reach its +model, arriving a step later. So scc maps three binaries back in read-only: itself, +because every rule it scaffolds answers questions with it, and `rtk` and `codegraph`, +because scc wrote the blocks that tell the agent to use them. The list is closed, +every mount is reported in `jail.maps` and printed in the command line, and a tool +scc never mentioned is one the user maps with `--jail-arg`. + +Two details are load-bearing. A **compiled binary is mounted at the name `PATH` +knows** and nothing else comes with it — which is also what makes the npm +distribution work, since `os.Executable` is the real Go binary behind the node shim, +and mounting it at the shim's path takes node out of the picture entirely. A +**script** needs its interpreter, its siblings and the symlink structure its module +resolution walks, so its bin directory and package root come too; that is `codegraph`, +and it is why the walk reads shebangs at all. + +All three flags are read off `ai-jail --help` rather than compiled in — the lesson +§6's Headroom integration already paid for — and a build advertising one of them gets **no substitute**, only a warning. Guessing at a replacement spelling is precisely how a sandbox ends up opened by the tool that was trying to help. diff --git a/internal/cli/launch.go b/internal/cli/launch.go index 00f9244..ae0f3c1 100644 --- a/internal/cli/launch.go +++ b/internal/cli/launch.go @@ -155,15 +155,6 @@ func runLaunch(args []string) int { cmd.Args = headroom.WrapArgs(hr.Agent, hr.Options, passthrough) } } - // Outermost, after Headroom: the jail contains the whole session, wrapper - // included. Anything Headroom registers from inside it lands in the sandbox's - // own transient home rather than the real one, which is a change in what `wrap` - // leaves behind and worth knowing before combining the two. - if jailed != nil { - cmd.Args = jail.Args(jailed.Options, jailed.Extra, cmd.Bin, cmd.Args) - cmd.Bin = jail.Bin - cmd.Jail = jailed - } cmd.Graph = resolveGraph(target, graphOptions{ disabled: *noGraph, noInstall: *noInstall, @@ -184,6 +175,23 @@ func runLaunch(args []string) int { plan: plan, quiet: *jsonOut, }) + + // Outermost, after Headroom: the jail contains the whole session, wrapper + // included. Anything Headroom registers from inside it lands in the sandbox's + // own transient home rather than the real one, which is a change in what `wrap` + // leaves behind and worth knowing before combining the two. + // + // Last of everything, rather than beside resolveJail where the refusal happens, + // because the toolchain it maps in is not settled until the two steps above have + // run: a launch that just installed rtk has to map the binary it installed, and + // one composed before that would sandbox the agent away from it. + if jailed != nil { + jailToolchain(jailed, jail.HiddenRoot(), jailTools(), *jsonOut) + opts := append(append([]string{}, jailed.Options...), jailed.mapArgs...) + cmd.Args = jail.Args(opts, jailed.Extra, cmd.Bin, cmd.Args) + cmd.Bin = jail.Bin + cmd.Jail = jailed + } if cmd.Args == nil { // A JSON consumer gets [] rather than null: the field is a command line, // and an empty one is still a list. @@ -856,12 +864,23 @@ type jailReport struct { // Options is what scc asked for, kept apart from Extra because scc put these // there and the user did not. Options []string `json:"options,omitempty"` + // Maps is the toolchain scc mounted back in read-only: the binaries its own + // guidance names, which the sandbox's private home would otherwise take away + // along with the rest of $HOME. + Maps []jail.Mapping `json:"maps,omitempty"` // Missing names anything scc asked for that this build does not advertise. // Reported rather than substituted: a sandbox opened by a guess is worse than // an agent that fails to connect. Missing []string `json:"missing,omitempty"` // Extra is what the user passed with --jail-arg, verbatim and last. Extra []string `json:"extra,omitempty"` + + // flags is what this build's own help advertises, read once in resolveJail and + // consulted again when the toolchain is mapped. mapArgs is Maps as this build + // spells them. Neither is reported: flags is an implementation detail of asking, + // and mapArgs would say what Maps already says, in a second vocabulary. + flags map[string]bool + mapArgs []string } type jailOptions struct { @@ -907,7 +926,8 @@ func resolveJail(opts jailOptions) *jailReport { // The two flags an agent needs to function, read off this build's own help // rather than compiled in. Everything else is policy and stays in .ai-jail, // which ai-jail reads by itself. - report.Options, report.Missing = jail.Needed(jail.HelpFlags(jail.Help(p))) + report.flags = jail.HelpFlags(jail.Help(p)) + report.Options, report.Missing = jail.Needed(report.flags) if len(report.Missing) > 0 && !opts.quiet { render.Warn(fmt.Sprintf("this %s build advertises no %s — the agent may not reach its API or its credentials", jail.Bin, strings.Join(report.Missing, " or "))) @@ -946,6 +966,93 @@ func installJail(opts jailOptions) bool { return false } +// jailToolchain mounts the binaries scc's own guidance names back into the +// sandbox, read-only. +// +// This is the third thing scc asks the jail for, and it is asked for the same +// reason as the first two. ai-jail's private home replaces $HOME with a fresh +// tmpfs and binds only the command it was handed, so `rtk` in ~/.cargo/bin and an +// npm-installed `scc` vanish with the rest of the home, and PATH is then pruned +// to what survived. The agent is left in front of an entry file telling it to +// prefix every command with a binary that is not there, and rules telling it to +// answer questions with a command that is not there either — which it discovers +// one failed command at a time, and works around by reading whole files instead. +// +// Mapping is a real loosening and it is kept as narrow as the tools allow: three +// binaries scc can name, read-only, every one of them reported in Maps and +// printed in the command line. Everything else stays policy, and policy stays in +// .ai-jail. +// root and tools are parameters rather than calls, so a test can state a +// toolchain and a hidden region instead of depending on how the machine running +// the suite installed its own. +func jailToolchain(report *jailReport, root string, tools []jailTool, quiet bool) { + if root == "" { + return + } + var maps []jail.Mapping + for _, t := range tools { + maps = append(maps, jail.Needs(root, t.entry, t.real)...) + } + report.Maps = jail.Dedupe(maps) + args, ok := jail.MapArgs(report.flags, report.Maps) + if !ok { + // Reported, never substituted. The launch goes ahead — the sandbox is what + // was asked for and it is intact — but the agent will find its own toolchain + // missing, and that is worth hearing before it does. + report.Missing = append(report.Missing, jail.FlagMap) + report.Maps = nil + if !quiet { + render.Warn(fmt.Sprintf("this %s build advertises no %s — %s, %s and %s will not exist inside the sandbox", + jail.Bin, jail.FlagMap, prog(), rtk.Bin, codegraph.Bin)) + render.Detail(" map them from ~/.ai-jail instead, under ro_maps") + } + return + } + report.mapArgs = args +} + +// jailTool is one binary to keep reachable: where PATH finds it, and the file scc +// already knows sits behind that name. +type jailTool struct { + entry string + real string +} + +// jailTools is the toolchain, and the list is closed on purpose: scc, because +// every rule it scaffolds answers questions with it; rtk and codegraph, because +// scc wrote the blocks in the entry file that tell the agent to use them. A tool +// scc never mentioned is a tool the user can map themselves with --jail-arg. +func jailTools() []jailTool { + tools := []jailTool{sccTool()} + if p, ok := rtk.Path(); ok { + tools = append(tools, jailTool{entry: p}) + } + if p, ok := codegraph.Path(); ok { + tools = append(tools, jailTool{entry: p}) + } + return tools +} + +// sccTool names the running binary rather than whatever PATH resolves to, which +// is what makes the npm distribution work inside a sandbox at all: `scc` there is +// a node shim that spawns the Go binary out of a platform package, and +// os.Executable is that binary. Mapping it at the name PATH knows replaces a +// launcher with the thing it launches, and takes node out of the picture. +func sccTool() jailTool { + real, err := os.Executable() + if err != nil { + real = "" + } + entry, err := exec.LookPath("scc") + if err != nil { + // Not on PATH — run through npx, or from a build directory. Mount it where + // it stands: an agent that cannot find it is a smaller problem than one + // whose `scc` is a path that does not exist. + entry = real + } + return jailTool{entry: entry, real: real} +} + func runJailInstall(opts jailOptions) bool { render.Info(fmt.Sprintf("installing %s: %s — this takes a few minutes", jail.Bin, jail.InstallCmd())) out := os.Stdout diff --git a/internal/cli/launch_test.go b/internal/cli/launch_test.go index 17b5c3c..2bb0025 100644 --- a/internal/cli/launch_test.go +++ b/internal/cli/launch_test.go @@ -958,3 +958,70 @@ func TestLaunchIsUnjailedByDefault(t *testing.T) { t.Errorf("bin = %q, want %q", cmd.Bin, paths.Claude.Bin) } } + +// The bug this fixes, stated as a test: ai-jail's private home replaces $HOME with +// a fresh tmpfs and binds only the command it was handed, so an agent told by +// scc's own entry file to prefix every command with `rtk` finds no rtk. Mapping is +// how it gets one back, and the mount is read-only and named. +func TestJailMapsTheToolchainItsOwnGuidanceNames(t *testing.T) { + home := t.TempDir() + rtkBin := filepath.Join(home, "rtk") + if err := os.WriteFile(rtkBin, []byte("\x7fELF fake"), 0o755); err != nil { + t.Fatal(err) + } + report := &jailReport{flags: map[string]bool{jail.FlagMap: true}} + + jailToolchain(report, home, []jailTool{{entry: rtkBin}}, true) + + if len(report.Maps) != 1 || report.Maps[0].Src != rtkBin { + t.Fatalf("maps = %+v, want a read-only mount of %s", report.Maps, rtkBin) + } + if strings.Join(report.mapArgs, " ") != jail.FlagMap+" "+rtkBin { + t.Errorf("options = %q", report.mapArgs) + } +} + +// A build that does not advertise --map gets no substitute — the same answer the +// other two flags get. The launch still goes ahead: the sandbox is intact and is +// what was asked for, and it is the toolchain that is missing. +func TestJailReportsABuildThatCannotMapRatherThanGuessing(t *testing.T) { + home := t.TempDir() + rtkBin := filepath.Join(home, "rtk") + if err := os.WriteFile(rtkBin, []byte("\x7fELF fake"), 0o755); err != nil { + t.Fatal(err) + } + report := &jailReport{flags: jail.HelpFlags(" --network enable network\n")} + + jailToolchain(report, home, []jailTool{{entry: rtkBin}}, true) + + if report.mapArgs != nil || report.Maps != nil { + t.Errorf("maps = %+v, args = %q, want neither", report.Maps, report.mapArgs) + } + found := false + for _, m := range report.Missing { + if m == jail.FlagMap { + found = true + } + } + if !found { + t.Errorf("missing = %v, want %s named", report.Missing, jail.FlagMap) + } +} + +// A tool already outside the hidden region is left alone. scc asks the sandbox +// for what an agent cannot work without and stops there; everything else is +// policy, and policy lives in .ai-jail. +func TestJailMapsNothingItDoesNotHaveTo(t *testing.T) { + home := t.TempDir() + elsewhere := filepath.Join(t.TempDir(), "rtk") + if err := os.WriteFile(elsewhere, []byte("\x7fELF fake"), 0o755); err != nil { + t.Fatal(err) + } + report := &jailReport{flags: map[string]bool{jail.FlagMap: true}} + + jailToolchain(report, home, []jailTool{{entry: elsewhere}}, true) + + if len(report.Maps) != 0 || report.mapArgs != nil { + t.Errorf("maps = %+v, want none for a tool the sandbox keeps", report.Maps) + } +} diff --git a/internal/jail/jail.go b/internal/jail/jail.go index 62e409a..541abcb 100644 --- a/internal/jail/jail.go +++ b/internal/jail/jail.go @@ -19,13 +19,14 @@ // # What scc decides, and what it does not // // scc passes exactly the flags that make an agent able to run at all — a network it -// can reach its model through, and the credential state it authenticates with — and -// nothing else. Every other question (lockdown, denied paths, extra mounts, the -// browser, Docker) is *policy*, and policy belongs in ai-jail's own `~/.ai-jail` and -// `./.ai-jail`, which it reads by itself and scc never writes. A launcher that +// can reach its model through, the credential state it authenticates with, and +// read-only mounts for the handful of binaries scc's own guidance tells it to use +// (see [FlagMap]) — and nothing else. Every other question (lockdown, denied paths, +// the browser, Docker) is *policy*, and policy belongs in ai-jail's own `~/.ai-jail` +// and `./.ai-jail`, which it reads by itself and scc never writes. A launcher that // quietly loosened somebody's sandbox policy would be the worst kind of helpful. // -// Even those two are read off `ai-jail --help` rather than compiled in, which is the +// All three are read off `ai-jail --help` rather than compiled in, which is the // lesson internal/headroom already paid for: a flag name hardcoded here turns a // rename in somebody else's release into a launch that dies on "no such option". // diff --git a/internal/jail/toolchain.go b/internal/jail/toolchain.go new file mode 100644 index 0000000..dbbe0c2 --- /dev/null +++ b/internal/jail/toolchain.go @@ -0,0 +1,292 @@ +package jail + +import ( + "bufio" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// FlagMap is ai-jail's read-only extra mount: `--map PATH` shows a host path +// inside the sandbox at its own location, `--map SOURCE:DEST` at another one. +// +// It is the third flag scc asks for, and the last. The first two are what lets an +// agent run at all; this one is what lets it run *this workspace's* toolchain, +// which is the same argument one step further in. ai-jail binds the binary it was +// handed and says so plainly — "tools with needs beyond their install directory +// stay on the --map escape hatch" — so under the default private home an agent +// told by scc's own guidance to prefix every command with `rtk`, or to answer a +// question with `scc map tasks`, finds neither: they live in ~/.cargo/bin and the +// npm prefix, and $HOME inside the sandbox is a fresh tmpfs. PATH is then pruned +// to the directories that survived, so the entries naming them go too. +// +// A jail that starts an agent which cannot run the tools the file in front of it +// names is a jail that starts a useless agent — the same failure as one that +// cannot reach its model, arriving one step later. +const FlagMap = "--map" + +// Mapping is one read-only mount scc asks the sandbox for. Dest empty means "at +// the path it already has", which is the form to prefer: a mount that moves a +// file is a mount somebody has to reason about later. +type Mapping struct { + Src string `json:"src"` + Dest string `json:"dest,omitempty"` +} + +// Spec is the mapping as ai-jail spells it: PATH, or SOURCE:DEST. +func (m Mapping) Spec() string { + if m.Dest == "" || m.Dest == m.Src { + return m.Src + } + return m.Src + ":" + m.Dest +} + +// MapArgs turns mappings into ai-jail options, or reports that this build cannot +// take them. +// +// Read off the build's own help like the other two, and for the harder-learned +// half of the same reason: a flag guessed at here is a mount request the sandbox +// reads as something else. Nothing is substituted — a build with no --map starts +// the agent with a hidden toolchain, which is visible and diagnosable the moment +// the agent runs one command. +func MapArgs(flags map[string]bool, maps []Mapping) ([]string, bool) { + if len(maps) == 0 { + return nil, true + } + if !flags[FlagMap] { + return nil, false + } + args := make([]string, 0, len(maps)*2) + for _, m := range maps { + // Two arguments rather than --map=SPEC: it is the spelling ai-jail's own + // tests use, and a value carrying `=` cannot be misread in it. + args = append(args, FlagMap, m.Spec()) + } + return args, true +} + +// HiddenRoot is the region this platform's backend takes away from the sandbox, +// and so the region a tool has to be mapped back out of. +// +// It is ai-jail's own choice of root, mirrored: on Linux the private home is a +// fresh tmpfs, so $HOME is what disappears; on macOS there is no mount namespace +// and the seatbelt profile denies by default everywhere, so everything outside +// the project does. Empty means nothing is hidden — the answer on a platform with +// no backend, where Supported is false and no launch gets this far. +func HiddenRoot() string { + switch runtime.GOOS { + case "linux": + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home + case "darwin": + return string(filepath.Separator) + default: + return "" + } +} + +// Hidden reports whether path falls inside the region root names. +func Hidden(root, path string) bool { + if root == "" || path == "" { + return false + } + root, path = filepath.Clean(root), filepath.Clean(path) + if root == string(filepath.Separator) { + return filepath.IsAbs(path) + } + if path == root { + return true + } + return strings.HasPrefix(path, root+string(filepath.Separator)) +} + +// maxInterpreterDepth bounds the shebang walk. A script run by a script run by a +// script is somebody's packaging accident, not a chain to follow forever. +const maxInterpreterDepth = 2 + +// Needs is the read-only mounts one tool needs to stay runnable when its install +// directory is hidden. +// +// entry is where PATH finds it — the name the agent will type. real is the file +// scc already knows sits behind that name, or empty to resolve the symlinks +// itself. Nothing is returned when neither is hidden: a tool in /usr/bin is +// already there. +// +// A compiled binary needs one mount, itself, shown at the name PATH knows. A +// script needs three things that are all the same fact — an interpreter, its own +// siblings, and the symlink structure its module resolution walks — so its bin +// directory and package root come with it. That is the npm-installed case, which +// is how both scc and codegraph are distributed. +func Needs(root, entry, real string) []Mapping { + return needs(root, entry, real, 0) +} + +func needs(root, entry, real string, depth int) []Mapping { + if entry == "" || depth > maxInterpreterDepth { + return nil + } + entry = filepath.Clean(entry) + resolved := resolve(real) + if resolved == "" { + resolved = resolve(entry) + } + if resolved == "" { + return nil + } + // ai-jail splits a map on its first colon, so a path containing one cannot be + // expressed at all — and the request it would parse as is a mount of something + // else. Nothing legitimate lands here; a mount scc cannot spell exactly is one + // it does not ask for. + if !spellable(entry) || !spellable(resolved) { + return nil + } + if !Hidden(root, entry) && !Hidden(root, resolved) { + return nil + } + + var out []Mapping + if resolved == entry { + out = append(out, Mapping{Src: entry}) + } else { + out = append(out, Mapping{Src: resolved, Dest: entry}) + } + + interp, script := shebang(resolved) + if !script { + // A compiled binary carries its dependencies or finds them under /usr, + // which the sandbox mounts read-only anyway. + return out + } + // The bin directory rather than the file: a global npm shim is a symlink into + // the package tree, its interpreter is usually its neighbour, and node resolves + // the real path of a script before looking for anything beside it. Mounting the + // file alone at the name PATH knows would start it and then fail to find its + // own package. + if dir := filepath.Dir(entry); Hidden(root, dir) { + out = append(out, Mapping{Src: dir}) + } + if dir := payload(resolved); dir != "" && dir != filepath.Dir(entry) && Hidden(root, dir) { + out = append(out, Mapping{Src: dir}) + } + if interp != "" { + if p, err := exec.LookPath(interp); err == nil { + out = append(out, needs(root, p, "", depth+1)...) + } + } + return out +} + +// Dedupe drops repeats and anything a directory in the same set already covers, +// preserving order. Two tools installed the same way name the same interpreter +// and the same package root, and a command line that says so three times is one +// nobody reads. +func Dedupe(maps []Mapping) []Mapping { + var dirs []string + for _, m := range maps { + if m.Dest == "" && isDir(m.Src) { + dirs = append(dirs, m.Src) + } + } + seen := map[string]bool{} + out := make([]Mapping, 0, len(maps)) + for _, m := range maps { + if seen[m.Spec()] { + continue + } + if m.Dest == "" && under(m.Src, dirs) { + continue + } + seen[m.Spec()] = true + out = append(out, m) + } + return out +} + +func under(path string, dirs []string) bool { + for _, d := range dirs { + if path != d && Hidden(d, path) { + return true + } + } + return false +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// spellable reports whether a path can be said in ai-jail's map syntax, which +// splits on its first colon. A drive letter is discounted: it is a Windows path, +// and no sandbox runs there — the suite does. +func spellable(path string) bool { + return !strings.ContainsRune(path[len(filepath.VolumeName(path)):], ':') +} + +// resolve is EvalSymlinks with the original kept when it fails, since a path that +// cannot be resolved is still a path worth mounting. +func resolve(path string) string { + if path == "" { + return "" + } + if p, err := filepath.EvalSymlinks(path); err == nil { + return filepath.Clean(p) + } + return filepath.Clean(path) +} + +// payload is the directory a script's own resolution walks: the outermost +// node_modules above it when there is one, so a global npm package finds the +// sibling packages it depends on, and otherwise the directory it sits in. +func payload(path string) string { + dir := filepath.Dir(path) + parts := strings.Split(dir, string(filepath.Separator)) + for i, p := range parts { + if p == "node_modules" { + return strings.Join(parts[:i+1], string(filepath.Separator)) + } + } + return dir +} + +// shebang reads the interpreter a script names, and reports whether the file is a +// script at all. `#!/usr/bin/env node` answers "node", because the interpreter +// that matters there is the one PATH has to find. +func shebang(path string) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer func() { _ = f.Close() }() + + r := bufio.NewReader(f) + head, err := r.Peek(2) + if err != nil || string(head) != "#!" { + return "", false + } + line, err := r.ReadString('\n') + if err != nil && line == "" { + return "", true + } + fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "#!")) + for i, field := range fields { + if i == 0 { + if filepath.Base(field) != "env" { + return field, true + } + continue + } + // `env -S node --flag` and `env NAME=value node` both put the command + // after the options and the assignments. + if strings.HasPrefix(field, "-") || strings.Contains(field, "=") { + continue + } + return field, true + } + return "", true +} diff --git a/internal/jail/toolchain_test.go b/internal/jail/toolchain_test.go new file mode 100644 index 0000000..00ee2e0 --- /dev/null +++ b/internal/jail/toolchain_test.go @@ -0,0 +1,244 @@ +package jail + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// A fake install tree: a home the sandbox would hide, and the two shapes a tool +// arrives in — a compiled binary, and a script symlinked onto PATH out of a +// package directory. +type tree struct { + home string +} + +func newTree(t *testing.T) tree { + t.Helper() + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return tree{home: home} +} + +func (tr tree) write(t *testing.T, rel, content string) string { + t.Helper() + path := filepath.Join(tr.home, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func specs(maps []Mapping) []string { + out := make([]string, 0, len(maps)) + for _, m := range maps { + out = append(out, m.Spec()) + } + return out +} + +// The cargo-installed case, and the one that started this: rtk is a compiled +// binary sitting in a directory the private home takes away. One mount, itself, +// at the path PATH already knows — nothing about ~/.cargo/bin comes with it. +func TestNeedsMountsACompiledBinaryAndNothingElse(t *testing.T) { + tr := newTree(t) + bin := tr.write(t, ".cargo/bin/rtk", "\x7fELF fake") + + got := Needs(tr.home, bin, "") + if len(got) != 1 || got[0].Spec() != bin { + t.Errorf("Needs = %v, want just %s", specs(got), bin) + } +} + +// A tool outside the hidden region is already there. Mapping it would be scc +// asking the sandbox for something it does not need, which is how a launcher ends +// up owning somebody's policy. +func TestNeedsSkipsWhatTheSandboxKeeps(t *testing.T) { + tr := newTree(t) + other, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + bin := filepath.Join(other, "rtk") + if err := os.WriteFile(bin, []byte("\x7fELF fake"), 0o755); err != nil { + t.Fatal(err) + } + if got := Needs(tr.home, bin, ""); got != nil { + t.Errorf("Needs = %v, want none for a path outside the hidden root", specs(got)) + } +} + +// The npm distribution: `scc` on PATH is a node shim, and the binary that answers +// for it is the running process. Mapping the real one at the shim's name replaces +// a launcher with the thing it launches, so node never enters the sandbox at all. +func TestNeedsShowsTheRealBinaryAtTheNamePathKnows(t *testing.T) { + tr := newTree(t) + real := tr.write(t, ".npm/lib/node_modules/@protonspy/scc-linux-x64/bin/scc", "\x7fELF fake") + entry := filepath.Join(tr.home, ".npm", "bin", "scc") + + got := Needs(tr.home, entry, real) + if len(got) != 1 || got[0].Spec() != real+":"+entry { + t.Errorf("Needs = %v, want %s", specs(got), real+":"+entry) + } +} + +// A script is the case where mounting the file alone starts it and then watches +// it fail: node resolves the real path of what it runs before looking for +// anything beside it, so the package tree has to be there too, and the +// interpreter has to be findable. +func TestNeedsCarriesAScriptsPackageAndInterpreter(t *testing.T) { + tr := newTree(t) + script := tr.write(t, "n/lib/node_modules/codegraph/bin/cli.js", "#!/usr/bin/env fakenode\nconsole.log(1)\n") + node := tr.write(t, "n/bin/fakenode", "\x7fELF fake") + // The real npm shape is a symlink from the bin directory into the package + // tree. It is spelled as a resolved pair here rather than an os.Symlink, + // because the two reach needs identically and creating one needs a privilege + // the Windows CI job does not have. + entry := filepath.Join(tr.home, "n", "bin", "codegraph") + t.Setenv("PATH", filepath.Dir(node)) + + got := specs(Needs(tr.home, entry, script)) + want := []string{ + script + ":" + entry, // runnable under the name PATH knows + filepath.Dir(entry), // the bin directory, symlinks intact + filepath.Join(tr.home, "n", "lib", "node_modules"), // sibling packages resolve + } + for _, w := range want { + if !contains(got, w) { + t.Errorf("Needs = %v, missing %s", got, w) + } + } + // The interpreter is a neighbour here, so the bin directory already covers it; + // what matters is that the walk found it rather than stopping at the script. + if runtime.GOOS != "windows" && !contains(got, filepath.Dir(node)) { + t.Errorf("Needs = %v, does not reach the interpreter", got) + } +} + +func contains(all []string, want string) bool { + for _, a := range all { + if a == want { + return true + } + } + return false +} + +// Two tools installed the same way name the same package root and the same +// interpreter. The command line says it once. +func TestDedupeDropsRepeatsAndWhatADirectoryCovers(t *testing.T) { + tr := newTree(t) + dir := filepath.Join(tr.home, "bin") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + inside := tr.write(t, "bin/rtk", "\x7fELF fake") + + got := specs(Dedupe([]Mapping{ + {Src: dir}, + {Src: inside}, + {Src: dir}, + {Src: inside, Dest: filepath.Join(tr.home, "other", "rtk")}, + })) + want := []string{dir, inside + ":" + filepath.Join(tr.home, "other", "rtk")} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("Dedupe = %v, want %v", got, want) + } +} + +// A build that does not advertise --map gets no substitute, the same answer +// Needed gives for the other two: a mount spelled by guesswork is a sandbox +// opened by a tool that was only trying to help. +func TestMapArgsRefusesToGuessTheSpelling(t *testing.T) { + maps := []Mapping{{Src: "/home/u/.cargo/bin/rtk"}} + + args, ok := MapArgs(HelpFlags(" --map read-only mount\n"), maps) + if !ok || strings.Join(args, " ") != "--map /home/u/.cargo/bin/rtk" { + t.Errorf("MapArgs = %v (ok=%v)", args, ok) + } + + if args, ok := MapArgs(HelpFlags(" --network enable network\n"), maps); ok || args != nil { + t.Errorf("MapArgs = %v (ok=%v), want nothing from a build with no %s", args, ok, FlagMap) + } + + // Nothing to ask for is not a failure to ask. + if _, ok := MapArgs(HelpFlags(""), nil); !ok { + t.Error("MapArgs reported a failure for an empty toolchain") + } +} + +func TestHiddenIsTheRegionTheBackendTakesAway(t *testing.T) { + home := filepath.Join(string(filepath.Separator), "home", "u") + cases := []struct { + root, path string + want bool + }{ + {home, filepath.Join(home, ".cargo", "bin", "rtk"), true}, + {home, home, true}, + {home, filepath.Join(string(filepath.Separator), "usr", "bin", "git"), false}, + {home, filepath.Join(string(filepath.Separator), "home", "user2", "rtk"), false}, + // macOS hides everything outside the project, so its root is "/" and any + // absolute path is inside it. Built from a real absolute path, since what + // counts as one is a platform question. + {string(filepath.Separator), filepath.Join(os.TempDir(), "git"), true}, + {"", filepath.Join(home, "rtk"), false}, + } + for _, c := range cases { + if got := Hidden(c.root, c.path); got != c.want { + t.Errorf("Hidden(%q, %q) = %v, want %v", c.root, c.path, got, c.want) + } + } +} + +func TestSpecPrefersThePathAToolAlreadyHas(t *testing.T) { + if got := (Mapping{Src: "/a/rtk"}).Spec(); got != "/a/rtk" { + t.Errorf("Spec = %q", got) + } + if got := (Mapping{Src: "/a/rtk", Dest: "/a/rtk"}).Spec(); got != "/a/rtk" { + t.Errorf("Spec = %q, want no self-referential destination", got) + } + if got := (Mapping{Src: "/a/rtk", Dest: "/b/rtk"}).Spec(); got != "/a/rtk:/b/rtk" { + t.Errorf("Spec = %q", got) + } +} + +func TestShebangNamesWhatPathHasToFind(t *testing.T) { + tr := newTree(t) + cases := []struct { + name, content, want string + script bool + }{ + {"env.js", "#!/usr/bin/env node\n", "node", true}, + {"envopts.js", "#!/usr/bin/env -S node --enable-source-maps\n", "node", true}, + {"envassign.js", "#!/usr/bin/env NODE_ENV=production node\n", "node", true}, + {"direct.sh", "#!/bin/sh\necho hi\n", "/bin/sh", true}, + {"binary", "\x7fELF fake", "", false}, + } + for _, c := range cases { + path := tr.write(t, c.name, c.content) + got, script := shebang(path) + if got != c.want || script != c.script { + t.Errorf("shebang(%s) = %q, %v; want %q, %v", c.name, got, script, c.want, c.script) + } + } +} + +// A colon is the map syntax's own separator, so a path carrying one cannot be +// asked for — and what ai-jail would parse instead is a mount of something else. +// The drive letter is discounted, since the suite runs on Windows and no sandbox +// does. +func TestSpellableRejectsWhatTheMapSyntaxWouldSplit(t *testing.T) { + if spellable("/home/u/od:d/rtk") { + t.Error("a path with a colon was called spellable") + } + if !spellable(filepath.Join(os.TempDir(), "rtk")) { + t.Error("an ordinary absolute path was refused") + } +} From 045fb9010451cfb590b9aa04a26b482843dbb038 Mon Sep 17 00:00:00 2001 From: prode Date: Fri, 28 Aug 2026 11:54:22 -0300 Subject: [PATCH 2/2] test(cli): name the temp directory the way the mapping walk names it --- internal/cli/launch_test.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/cli/launch_test.go b/internal/cli/launch_test.go index 2bb0025..88bbdca 100644 --- a/internal/cli/launch_test.go +++ b/internal/cli/launch_test.go @@ -959,12 +959,25 @@ func TestLaunchIsUnjailedByDefault(t *testing.T) { } } +// resolvedDir is a temp directory named the way the mapping walk will name it. +// The walk resolves symlinks, because a mount has to point at the real file, and a +// bare t.TempDir does not: it is under /var on macOS, which is a symlink to +// /private/var, and it comes back as an 8.3 short name on Windows. +func resolvedDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return dir +} + // The bug this fixes, stated as a test: ai-jail's private home replaces $HOME with // a fresh tmpfs and binds only the command it was handed, so an agent told by // scc's own entry file to prefix every command with `rtk` finds no rtk. Mapping is // how it gets one back, and the mount is read-only and named. func TestJailMapsTheToolchainItsOwnGuidanceNames(t *testing.T) { - home := t.TempDir() + home := resolvedDir(t) rtkBin := filepath.Join(home, "rtk") if err := os.WriteFile(rtkBin, []byte("\x7fELF fake"), 0o755); err != nil { t.Fatal(err) @@ -985,7 +998,7 @@ func TestJailMapsTheToolchainItsOwnGuidanceNames(t *testing.T) { // other two flags get. The launch still goes ahead: the sandbox is intact and is // what was asked for, and it is the toolchain that is missing. func TestJailReportsABuildThatCannotMapRatherThanGuessing(t *testing.T) { - home := t.TempDir() + home := resolvedDir(t) rtkBin := filepath.Join(home, "rtk") if err := os.WriteFile(rtkBin, []byte("\x7fELF fake"), 0o755); err != nil { t.Fatal(err) @@ -1012,8 +1025,8 @@ func TestJailReportsABuildThatCannotMapRatherThanGuessing(t *testing.T) { // for what an agent cannot work without and stops there; everything else is // policy, and policy lives in .ai-jail. func TestJailMapsNothingItDoesNotHaveTo(t *testing.T) { - home := t.TempDir() - elsewhere := filepath.Join(t.TempDir(), "rtk") + home := resolvedDir(t) + elsewhere := filepath.Join(resolvedDir(t), "rtk") if err := os.WriteFile(elsewhere, []byte("\x7fELF fake"), 0o755); err != nil { t.Fatal(err) }