Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ These landed after phase 10, and all are documented in `design/orchestration.md`

**`--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`.

**`Compose` puts every directory before the files inside it, and that is not cosmetic.** Mounts apply in the order they are given, so a directory bind arriving after a file bind underneath it hides the file. Measured inside a real sandbox: `scc` mapped as "the real binary, at the shim's name" answered `v0.19.0` — the npm shim — because the bin directory its neighbours needed was mounted on top a moment later. Directories first, and the specific mount wins, which is the whole reason it was asked for.

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.
Expand Down
7 changes: 7 additions & 0 deletions design/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,13 @@ and mounting it at the shim's path takes node out of the picture entirely. A
resolution walks, so its bin directory and package root come too; that is `codegraph`,
and it is why the walk reads shebangs at all.

And **the order the mounts are asked for in is load-bearing**, which only shows from
inside the sandbox. Binds apply in sequence, so a directory arriving after a file
underneath it hides that file: the first cut mapped scc as *the real binary, at the
shim's name* and then mapped the bin directory the shim's neighbours needed, and what
answered inside the jail was the shim. Directories go first, so the specific mount —
the one that was the whole point — lands on top.

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
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@ func jailToolchain(report *jailReport, root string, tools []jailTool, quiet bool
for _, t := range tools {
maps = append(maps, jail.Needs(root, t.entry, t.real)...)
}
report.Maps = jail.Dedupe(maps)
report.Maps = jail.Compose(maps)
args, ok := jail.MapArgs(report.flags, report.Maps)
if !ok {
// Reported, never substituted. The launch goes ahead — the sandbox is what
Expand Down
36 changes: 29 additions & 7 deletions internal/jail/toolchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,29 @@ func needs(root, entry, real string, depth int) []Mapping {
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 {
// Compose is the set of mounts as a sandbox has to apply them: repeats dropped,
// and every directory before the files that land inside it.
//
// The repeats are the easy half — 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.
//
// The ordering is the half that was wrong first, and it is invisible until you
// look inside the sandbox. Mounts are applied in the order they are given, so a
// directory bind arriving after a file bind underneath it hides the file: an
// npm-installed `scc` mapped as "the real binary, at the shim's name" came back
// as the shim, because the bin directory the shim's neighbours needed was mounted
// on top of it a moment later. Directories first, and the specific mount wins —
// which is the whole reason it was asked for.
func Compose(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))
kept := make([]Mapping, 0, len(maps))
for _, m := range maps {
if seen[m.Spec()] {
continue
Expand All @@ -202,7 +212,19 @@ func Dedupe(maps []Mapping) []Mapping {
continue
}
seen[m.Spec()] = true
out = append(out, m)
kept = append(kept, m)
}

out := make([]Mapping, 0, len(kept))
for _, m := range kept {
if isDir(m.Src) {
out = append(out, m)
}
}
for _, m := range kept {
if !isDir(m.Src) {
out = append(out, m)
}
}
return out
}
Expand Down
31 changes: 28 additions & 3 deletions internal/jail/toolchain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,23 +133,48 @@ func contains(all []string, want string) bool {

// 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) {
func TestComposeDropsRepeatsAndWhatADirectoryCovers(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{
got := specs(Compose([]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)
t.Errorf("Compose = %v, want %v", got, want)
}
}

// Mounts apply in order, so a directory arriving after a file underneath it hides
// the file. Measured inside a real sandbox: an npm-installed scc mapped as "the
// real binary, at the shim's name" came back as the shim, because the bin
// directory was mounted on top of it a moment later. Directories first.
func TestComposePutsDirectoriesBeforeTheFilesInsideThem(t *testing.T) {
tr := newTree(t)
real := tr.write(t, "tools/scc", "\x7fELF fake")
binDir := filepath.Join(tr.home, "n", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
shim := filepath.Join(binDir, "scc")

// The order Needs produces: the specific mount first, the directory it lands
// inside second.
got := specs(Compose([]Mapping{
{Src: real, Dest: shim},
{Src: binDir},
}))
want := []string{binDir, real + ":" + shim}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("Compose = %v, want %v — the file mount has to land on top", got, want)
}
}

Expand Down
Loading