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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <agent> --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. |
Expand Down
51 changes: 38 additions & 13 deletions design/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
127 changes: 117 additions & 10 deletions internal/cli/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ")))
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading