From a3a1a7213459f67bd9738444407589c507461b82 Mon Sep 17 00:00:00 2001 From: prode Date: Fri, 28 Aug 2026 12:11:29 -0300 Subject: [PATCH] fix(jail): mount directories before the files that land inside them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binds apply in the order they are given, so a directory arriving after a file 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 the shim's neighbours needed was mounted on top of it a moment later. The specific mount was the whole reason the mapping existed, and it was the one being discarded. Compose (was Dedupe, which no longer described it) orders directories first and keeps the file mounts last. Verified against ai-jail 1.20.1 under bubblewrap with Landlock enforced: rtk, an npm-installed codegraph and the running scc all resolve by name inside the jail, and scc now reports the build that started it. --- CLAUDE.md | 2 ++ design/orchestration.md | 7 +++++++ internal/cli/launch.go | 2 +- internal/jail/toolchain.go | 36 ++++++++++++++++++++++++++------- internal/jail/toolchain_test.go | 31 +++++++++++++++++++++++++--- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a45fae4..c022b44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/design/orchestration.md b/design/orchestration.md index ca41d62..2edca07 100644 --- a/design/orchestration.md +++ b/design/orchestration.md @@ -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 diff --git a/internal/cli/launch.go b/internal/cli/launch.go index ae0f3c1..dbe31c7 100644 --- a/internal/cli/launch.go +++ b/internal/cli/launch.go @@ -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 diff --git a/internal/jail/toolchain.go b/internal/jail/toolchain.go index dbbe0c2..307f899 100644 --- a/internal/jail/toolchain.go +++ b/internal/jail/toolchain.go @@ -181,11 +181,21 @@ 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) { @@ -193,7 +203,7 @@ func Dedupe(maps []Mapping) []Mapping { } } 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 @@ -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 } diff --git a/internal/jail/toolchain_test.go b/internal/jail/toolchain_test.go index 00ee2e0..e29fad7 100644 --- a/internal/jail/toolchain_test.go +++ b/internal/jail/toolchain_test.go @@ -133,7 +133,7 @@ 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 { @@ -141,7 +141,7 @@ func TestDedupeDropsRepeatsAndWhatADirectoryCovers(t *testing.T) { } inside := tr.write(t, "bin/rtk", "\x7fELF fake") - got := specs(Dedupe([]Mapping{ + got := specs(Compose([]Mapping{ {Src: dir}, {Src: inside}, {Src: dir}, @@ -149,7 +149,32 @@ func TestDedupeDropsRepeatsAndWhatADirectoryCovers(t *testing.T) { })) 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) } }