diff --git a/CLAUDE.md b/CLAUDE.md index 8f575db..dec3c36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,14 @@ These landed after phase 10, and all are documented in `design/orchestration.md` **`@path` is the stale check** — the notes half of the codewiki citation rule, since a note about code that no longer exists is read as current, which is worse than the comment it replaced: that one at least died with the file. At *write* time it is a warning and never a block, because a note about a file this branch has not created yet is the note most worth having; `notes add` otherwise writes under the same verify-and-roll-back contract as `scc patch`. The ninth validator also reports the failure this file cannot tolerate quietly: a hand-written line that missed the grammar, which no query will ever return. +- **`scc launch --jail`, the sandbox.** An agent needs filesystem access to do its job, and the same access lets it run `rm -rf`, read `~/.aws`, or ship a key somewhere — by accident, on a poisoned instruction in a file it read, or through a dependency it installed. `autonomy: auto` sharpens that rather than softening it: nobody is watching the step where noticing was still possible. `--jail` starts the agent inside [ai-jail](https://github.com/akitaonrails/ai-jail) — bubblewrap on Linux, `sandbox-exec` on macOS — wrapping *outermost*, Headroom included. + + **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. + + **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. + - **The spec records where it is being built: `scc spec track` and `scc spec sync`.** A branch was the one part of this methodology that left no trace in the artifacts — the spec said which boxes were ticked, git said a branch had been unmerged for three weeks, and nothing joined the two, so *which of these actually shipped* was answerable only by somebody holding both halves. Under `autonomy: auto` that is nobody. Three keys on `requirements.md` beside the kickoff answers — `branch:`, `pr:`, `delivery: in-progress|in-review|merged|abandoned` — with the vocabulary closed for the reason a task's flags are, and graded by the validator only when present, so every spec written before this keeps passing. **`track` records what the caller knows; `sync` derives what git knows.** `--here` takes the branch from the checkout, `--pr ` the pull request, and `scc spec sync` walks every spec, asks git and (when installed) `gh`, and writes the answer back under the same verify-and-roll-back contract as `scc patch`. `scc spec list` shows the record beside the phases. **Neither guesses**: a deleted branch with no PR to ask about is reported undetermined and left alone, because merged and abandoned are indistinguishable once the ref is gone. @@ -164,7 +172,7 @@ cmd/scc/main.go os.Exit(cli.Run(os.Args[1:])) plain files on disk: / · specs/ · plans/ · docs/ · CLAUDE.md|AGENTS.md ``` -Four packages sit off to the side of that tree — `rtk`, `headroom`, `codegraph`, `git` — reached only from `internal/cli`. They are the third-party integrations, and they are the only code that starts another process. `git` is the one scc never installs and never writes with: every call in it is a query. +Five packages sit off to the side of that tree — `rtk`, `headroom`, `codegraph`, `git`, `jail` — reached only from `internal/cli`. They are the third-party integrations, and they are the only code that starts another process. `git` is the one scc never installs and never writes with: every call in it is a query. `internal/cli/cli.go` is the whole dispatcher: `Run(args)` switches on `args[0]` and hands off to `run` in a file named for that resource. Each handler owns its own `flag.FlagSet`. Adding a subcommand means adding a case there plus one file — nothing is registered dynamically, so the command set is readable in one place. @@ -186,11 +194,12 @@ Four 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/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. | -`internal/rtk`, `internal/headroom`, `internal/codegraph`, and `internal/git` are the only packages that shell out to another program. Keep that boundary there rather than in a command handler: a third party's binary name, install command, and argument vocabulary all age on that third party's schedule, and one package per integration is what keeps a version bump from touching the dispatcher. Headroom's renamed MCP flag is the worked example — the fix stayed inside `internal/headroom`, and nothing else in the tree knows the flag exists. +`internal/rtk`, `internal/headroom`, `internal/codegraph`, `internal/git`, and `internal/jail` are the only packages that shell out to another program. Keep that boundary there rather than in a command handler: a third party's binary name, install command, and argument vocabulary all age on that third party's schedule, and one package per integration is what keeps a version bump from touching the dispatcher. Headroom's renamed MCP flag is the worked example — the fix stayed inside `internal/headroom`, and nothing else in the tree knows the flag exists. `go.mod` is stdlib-only. Keep it that way unless a dependency earns its place — the binary is distributed to six platforms and every dep is a supply-chain surface. diff --git a/README.md b/README.md index 8e3cb91..e11fb93 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Installed globally (`npm i -g @protonspy/scc`) the same commands are just `scc i | `skill validate` | Conformance to the published [Agent Skills](https://agentskills.io/specification) spec. | | `validate` | Every applicable validator, one exit code, one JSON document. | | `rtk` | Wires in [RTK](https://github.com/rtk-ai/rtk): installs it if missing, then splices its usage block into the entry file. | +| `launch` | Starts the harness with the workspace's symbol graph and RTK block current — and, with `--jail`, inside a sandbox. | ### RTK, optionally @@ -57,6 +58,33 @@ the markers is untouched either way. Opt-in on purpose: it tells the agent to prefix every command with a binary the machine may not have. `--no-install` writes the block and never touches cargo. +### A sandbox, optionally + +An agent needs filesystem access to do its job, and the same access lets it run +`rm -rf`, read `~/.aws`, or ship a key somewhere — by accident, on a poisoned +instruction in a file it read, or through a dependency it installed. `scc launch +--jail` starts it inside [ai-jail](https://github.com/akitaonrails/ai-jail), which +sandboxes with bubblewrap on Linux and `sandbox-exec` on macOS: + +```bash +npx @protonspy/scc launch claude --jail # the agent, contained +npx @protonspy/scc launch claude --jail --jail-arg --lockdown +``` + +**It refuses rather than degrading.** Every other integration here starts the agent +anyway when its binary is missing, because every other one is an enhancement. A +sandbox is the property you asked for by name: if ai-jail is not installed, or the +platform has no backend (Windows — use WSL2), nothing starts and it says why. An +agent that started unjailed would hand you the confidence of containment without the +containment. + +scc passes exactly the two flags that let an agent run at all — a network to reach +its model and the credential state to authenticate — and reads even those off +`ai-jail --help` rather than hardcoding them. Everything else is policy and belongs +in ai-jail's own `~/.ai-jail` / `./.ai-jail`, which scc never writes. + +The idea, and the tool, are [Fábio Akita's](https://akitaonrails.com/2026/01/10/ai-agents-garantindo-a-protecao-do-seu-sistema/). + ### Three harnesses, one methodology The same rules, review agents, and skills — the knowledge base's authors, plus diff --git a/design/orchestration.md b/design/orchestration.md index 6be916e..3f4d4b4 100644 --- a/design/orchestration.md +++ b/design/orchestration.md @@ -986,6 +986,59 @@ stays reproducible from the file, and nobody gets asked twice. - **A checkout left dirty or off `main`** is what this shape can leave behind. Say what is still uncommitted rather than starting the next unit of work on top of it. +### The sandbox — the one integration that refuses + +An agent needs filesystem access to do its job. The same access lets it run `rm -rf`, +read `~/.aws`, or ship a private key somewhere — by accident, on an instruction +planted in a file it was asked to read, or through a dependency it installed. §2's +`auto` sharpens that rather than softening it: autonomy means nobody is watching the +step where noticing was still possible, which is exactly the argument for putting a +boundary around the whole session instead of around each decision inside it. + +`scc launch --jail` starts the agent inside [ai-jail], which sandboxes with +bubblewrap on Linux and `sandbox-exec` on macOS. It wraps *outermost* — Headroom +included — so the jail contains the session and everything the session starts. + +**It refuses rather than degrading, and that is the whole design.** Headroom, +CodeGraph and RTK all end in the agent starting anyway when their binary is missing, +because each of the three is an enhancement and a launcher that failed over a +compression proxy would be putting its own preference above the thing the user asked +for. A sandbox inverts that. It *is* the thing the user asked for, by name, and the +failure mode is not a worse session — it is somebody who typed `--jail`, watched an +agent start, and believes they are contained. A false belief about containment is +worse than a known absence of it, because with the known absence they would not have +run the thing at all. So a missing binary, a declined install, an unattended run, or +a platform with no backend all end in nothing starting, and the refusal is decided +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 +**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. + +**scc integrates the binary and reimplements nothing.** ai-jail began as the shell +script in [Akita's article][akita], and copying that script into Go was the obvious +move and the wrong one: the thing does namespaces, Landlock and seccomp, it has since +grown a second platform backend, and a half-copy of a sandbox is worse than none for +the same reason the refusal above exists. Windows has no backend and is unlikely to +get one, since there is nothing there for bubblewrap or `sandbox-exec` to stand on; +WSL2 is the answer, and it is a real one rather than a workaround. + +[ai-jail]: https://github.com/akitaonrails/ai-jail +[akita]: https://akitaonrails.com/2026/01/10/ai-agents-garantindo-a-protecao-do-seu-sistema/ + ## 10 · The three spec artifacts §1 routes work into a spec at `specs//`; this is what those three files diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b832d8d..d3491e7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -117,7 +117,7 @@ Commands: init Scaffold a workspace: rules, agents, skills, commands, layout, manifest update Bring the managed files onto this build's templates, after showing the plan rtk Install RTK if missing and put its usage block in the entry file - launch Start a harness in this workspace, with its symbol graph and RTK block current + launch Start a harness here, with its symbol graph and RTK block current; --jail sandboxes it graph The workspace's symbol graph — build | sync | status | query | explore spec Create and inspect specs — new | list | show | delete | validate plan Create and inspect plans — new | list | delete | validate diff --git a/internal/cli/launch.go b/internal/cli/launch.go index b60406b..00f9244 100644 --- a/internal/cli/launch.go +++ b/internal/cli/launch.go @@ -12,6 +12,7 @@ import ( "github.com/protonspy/spec-claude-code/internal/assets" "github.com/protonspy/spec-claude-code/internal/codegraph" "github.com/protonspy/spec-claude-code/internal/headroom" + "github.com/protonspy/spec-claude-code/internal/jail" "github.com/protonspy/spec-claude-code/internal/mdblock" "github.com/protonspy/spec-claude-code/internal/paths" "github.com/protonspy/spec-claude-code/internal/render" @@ -70,9 +71,12 @@ func runLaunch(args []string) int { "which MCP servers Headroom may register: all | retrieve (its own only) | none") contextTool := fs.Bool("headroom-context-tool", false, "let Headroom set up its own CLI context tool (RTK or lean-ctx) and append its guidance to the entry file") + jailFlag := fs.Bool("jail", false, "start the agent inside ai-jail's sandbox; refuses to start it outside one") + var jailArgs repeatable + fs.Var(&jailArgs, "jail-arg", "an extra `flag` for ai-jail itself, repeatable (policy otherwise lives in .ai-jail)") noGraph := fs.Bool("no-graph", false, "start the agent without building or refreshing the symbol graph") noRTK := fs.Bool("no-rtk", false, "start the agent without setting up RTK's binary or its usage block in the entry file") - noInstall := fs.Bool("no-install", false, "never install anything; use Headroom, CodeGraph and RTK only if they are already on PATH") + noInstall := fs.Bool("no-install", false, "never install anything; use ai-jail, Headroom, CodeGraph and RTK only if they are already on PATH") yes := fs.Bool("yes", false, "answer the install prompts with yes, for an unattended run") dryRun := fs.Bool("dry-run", false, "print the command this would run, and run nothing") jsonOut := addJSON(fs) @@ -109,6 +113,26 @@ func runLaunch(args []string) int { return ExitError } + // The jail is settled first, and it is the one thing here that refuses. Every + // other integration degrades because it is an enhancement; a sandbox is a + // containment boundary, and a launcher that quietly started the agent outside the + // boundary somebody asked for would hand them the confidence of containment + // without the containment. So a run that cannot jail starts nothing — and it + // decides that before the graph is built or the entry file is touched, so a + // refusal leaves the workspace exactly as it found it. + var jailed *jailReport + if *jailFlag { + jailed = resolveJail(jailOptions{ + noInstall: *noInstall, + yes: *yes, + quiet: *jsonOut, + extra: jailArgs, + }) + if jailed == nil { + return ExitError + } + } + // --json and --dry-run both report the plan and start nothing. For --json that // is not a shortcut but the only coherent answer: the agent inherits this // terminal and writes to the same stdout, so a launched session and a clean @@ -131,6 +155,15 @@ 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, @@ -186,6 +219,7 @@ type launchCommand struct { Dir string `json:"dir"` Bin string `json:"bin"` Args []string `json:"args"` + Jail *jailReport `json:"jail,omitempty"` Headroom *headroomReport `json:"headroom,omitempty"` Graph *graphReport `json:"graph,omitempty"` RTK *rtkLaunchReport `json:"rtk,omitempty"` @@ -789,3 +823,138 @@ var launchExec = func(cmd launchCommand) (int, error) { } return ExitOK, nil } + +// repeatable is a flag that may be given more than once, collecting its values. +// The flag package has no such type, and the alternative — a comma-separated +// string — cannot carry a value containing a comma, which a path or a glob can. +type repeatable []string + +func (r *repeatable) String() string { return strings.Join(*r, " ") } + +func (r *repeatable) Set(v string) error { + *r = append(*r, v) + return nil +} + +// jailReport says what happened on the sandbox side of a launch. +// +// Unlike the other three it never describes a launch that went ahead without what +// was asked for: when this is present at all, the agent is inside the sandbox. +// There is no "started unjailed" outcome to report, because that outcome does not +// exist — see resolveJail. +type jailReport struct { + // Wrapping is always true when the report exists, and it is written down + // anyway: a JSON consumer checking `jail.wrapping` should get the same answer + // as one checking whether the key is there. + Wrapping bool `json:"wrapping"` + Path string `json:"path,omitempty"` + Version string `json:"version,omitempty"` + Backend string `json:"backend,omitempty"` + // Install is what happened to the binary: present | installed. The vocabulary + // the other integrations use, minus the outcomes that end in starting anyway. + Install string `json:"install"` + // 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"` + // 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"` +} + +type jailOptions struct { + noInstall bool + yes bool + quiet bool + extra []string +} + +// resolveJail puts this launch inside ai-jail, or returns nil having said why it +// could not. +// +// nil means the caller stops. That is the whole difference between this and +// resolveHeadroom, and it is deliberate: Headroom missing costs a session of +// compression, while a sandbox missing costs the property the user asked for by +// name. 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. +func resolveJail(opts jailOptions) *jailReport { + if !jail.Supported() { + render.Err(jail.Unsupported()) + render.Detail(" " + jail.Repo) + return nil + } + report := &jailReport{Wrapping: true, Backend: jail.Backend(), Extra: opts.extra} + + p, ok := jail.Path() + if !ok { + if !installJail(opts) { + return nil + } + if p, ok = jail.Path(); !ok { + render.Err(fmt.Sprintf("cargo reported success but %s is still not on PATH", jail.Bin)) + return nil + } + report.Install = installInstalled + render.OK(strings.TrimSpace(jail.Bin + " installed: " + p)) + } else { + report.Install = installPresent + } + report.Path, report.Version = p, jail.Version(p) + + // 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))) + 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 "))) + render.Detail(" scc passes no substitute rather than guessing; set it in ~/.ai-jail, or pass --jail-arg") + } + return report +} + +// installJail offers the install, and reports rather than proceeds when it cannot. +// Every path out of here that is not an installed binary ends the launch. +func installJail(opts jailOptions) bool { + switch { + case opts.noInstall: + render.Err(fmt.Sprintf("%s is not on PATH, and --no-install was passed", jail.Bin)) + case !jail.Available(): + render.Err(fmt.Sprintf("%s is not on PATH and cargo is not either, so it cannot be installed here", jail.Bin)) + render.Detail(" " + jail.InstallHint()) + return false + case opts.yes: + return runJailInstall(opts) + case opts.quiet || !interactive(): + // Unattended. Building a Rust binary without being asked is not a decision + // scc makes silently, and a sandbox is not a thing to install by surprise. + render.Err(fmt.Sprintf("%s is not on PATH, and nobody is here to answer the install prompt", jail.Bin)) + default: + render.Warn(fmt.Sprintf("%s is not on PATH — it is the sandbox --jail asks for (%s)", jail.Bin, jail.Backend())) + render.Detail(" " + jail.Repo) + if confirmInstall(promptIn, fmt.Sprintf("Install it now with `%s`?", jail.InstallCmd())) { + return runJailInstall(opts) + } + render.Err("install declined, so there is no sandbox to start the agent in") + return false + } + render.Detail(" " + jail.InstallHint()) + render.Detail(" or drop --jail to start the agent without a sandbox, deliberately") + return false +} + +func runJailInstall(opts jailOptions) bool { + render.Info(fmt.Sprintf("installing %s: %s — this takes a few minutes", jail.Bin, jail.InstallCmd())) + out := os.Stdout + if opts.quiet { + out = os.Stderr + } + if err := jail.Install(out, os.Stderr); err != nil { + render.Err(err.Error()) + return false + } + return true +} diff --git a/internal/cli/launch_test.go b/internal/cli/launch_test.go index 4de7dc8..17b5c3c 100644 --- a/internal/cli/launch_test.go +++ b/internal/cli/launch_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/protonspy/spec-claude-code/internal/codegraph" + "github.com/protonspy/spec-claude-code/internal/jail" "github.com/protonspy/spec-claude-code/internal/paths" "github.com/protonspy/spec-claude-code/internal/rtk" ) @@ -887,3 +888,73 @@ func TestConfirmInstallDefaultsToYes(t *testing.T) { } } } + +// The one integration here that refuses. Every other one degrades to starting the +// agent bare, because every other one is an enhancement; a sandbox is the property +// the user asked for by name, and somebody who typed --jail and watched an agent +// start believes they are contained. A false belief about containment is worse than +// a known absence of it — they would not have run the thing at all. +func TestJailRefusesRatherThanStartingUnsandboxed(t *testing.T) { + if _, present := jail.Path(); present { + t.Skip("ai-jail is installed here, so there is nothing to refuse") + } + root := initWorkspace(t) + started := false + orig := launchExec + launchExec = func(launchCommand) (int, error) { started = true; return 0, nil } + t.Cleanup(func() { launchExec = orig }) + + _, stderr, code := run(t, "launch", "claude", "--jail", "--no-install", + "--no-graph", "--no-rtk", "--root", root) + if code != ExitError { + t.Errorf("exit = %d, want %d", code, ExitError) + } + if started { + t.Fatal("the agent started without the sandbox it was asked for") + } + if stderr == "" { + t.Error("nothing was said about why there is no sandbox") + } +} + +// It refuses before anything else runs, so a launch that cannot be jailed leaves the +// workspace exactly as it found it — no graph built, no entry file edited. +func TestJailRefusesBeforeTouchingTheWorkspace(t *testing.T) { + if _, present := jail.Path(); present { + t.Skip("ai-jail is installed here") + } + root := initWorkspace(t) + entry := filepath.Join(root, paths.Claude.EntryFile) + before, err := os.ReadFile(entry) + if err != nil { + t.Fatalf("read entry: %v", err) + } + orig := launchExec + launchExec = func(launchCommand) (int, error) { return 0, nil } + t.Cleanup(func() { launchExec = orig }) + + // No --no-rtk: without the refusal, RTK's preflight would splice a block in. + if _, _, code := run(t, "launch", "claude", "--jail", "--no-install", "--root", root); code != ExitError { + t.Fatalf("exit = %d, want %d", code, ExitError) + } + after, err := os.ReadFile(entry) + if err != nil { + t.Fatalf("read entry: %v", err) + } + if string(after) != string(before) { + t.Error("a refused launch edited the entry file anyway") + } +} + +// Without --jail nothing changes: the sandbox is opt-in, and its report is absent +// rather than false, so a consumer cannot mistake an ordinary launch for a jailed one. +func TestLaunchIsUnjailedByDefault(t *testing.T) { + root := initWorkspace(t) + cmd := launchJSON(t, "launch", "claude", "--json", "--no-graph", "--no-rtk", "--root", root) + if cmd.Jail != nil { + t.Errorf("jail = %+v, want none without --jail", cmd.Jail) + } + if cmd.Bin != paths.Claude.Bin { + t.Errorf("bin = %q, want %q", cmd.Bin, paths.Claude.Bin) + } +} diff --git a/internal/jail/jail.go b/internal/jail/jail.go new file mode 100644 index 0000000..62e409a --- /dev/null +++ b/internal/jail/jail.go @@ -0,0 +1,216 @@ +// Package jail is the sandbox an agent runs inside: [ai-jail], Fábio Akita's +// wrapper over bubblewrap on Linux and sandbox-exec on macOS. +// +// It is the fifth integration package, on the same terms as rtk, headroom, +// codegraph and git — scc composes a command line for a binary somebody else +// ships and never reimplements what it does. That restraint matters more here than +// anywhere else in the tree: a sandbox is security-critical C and kernel interfaces +// (namespaces, Landlock, seccomp), and a half-copy of one is worse than none, +// because it produces the confidence of containment without the containment. +// +// # Why an agent wants one +// +// An agent needs filesystem access to do its job, and the same access lets it run +// `rm -rf`, read `~/.aws`, or ship a private key to a paste site — by accident, on a +// poisoned instruction in a file it read, or through a dependency it installed. This +// workspace's own methodology makes that sharper rather than softer: `autonomy: auto` +// means nobody is watching the step where noticing was still possible. +// +// # 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 +// 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 +// 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". +// +// [ai-jail]: https://github.com/akitaonrails/ai-jail +package jail + +import ( + "fmt" + "io" + "os/exec" + "regexp" + "runtime" + "strings" +) + +// Repo is where ai-jail lives, printed whenever scc suggests installing it. +const Repo = "https://github.com/akitaonrails/ai-jail" + +// Bin is the executable, as it appears on PATH. +const Bin = "ai-jail" + +// Crate is the name ai-jail publishes under, which is what cargo installs. +const Crate = "ai-jail" + +// Supported reports whether ai-jail runs on this platform at all. +// +// Linux (bubblewrap) and macOS (sandbox-exec) are the two backends it ships. There +// is no Windows backend and there is unlikely to be one: the sandbox is built out of +// Linux namespaces and Apple's sandbox interface, neither of which has a Windows +// equivalent ai-jail could stand on. WSL2 is the answer there, and it is a real one — +// scc inside WSL2 is scc on Linux. +func Supported() bool { + return runtime.GOOS == "linux" || runtime.GOOS == "darwin" +} + +// Backend names what would do the sandboxing here, for a report that says why. +func Backend() string { + switch runtime.GOOS { + case "linux": + return "bubblewrap" + case "darwin": + return "sandbox-exec" + default: + return "" + } +} + +// Unsupported is the sentence a caller prints when Supported is false. It names the +// way out rather than only the wall: WSL2 is a supported Linux, not a workaround. +func Unsupported() string { + return fmt.Sprintf("%s has no %s backend — it sandboxes with bubblewrap on Linux and sandbox-exec on macOS; on Windows, run scc inside WSL2", + Bin, runtime.GOOS) +} + +// Path reports where the ai-jail binary is, and whether it is on PATH at all. +func Path() (string, bool) { + p, err := exec.LookPath(Bin) + if err != nil { + return "", false + } + return p, true +} + +// Version reports what `ai-jail --version` says, or "" when the binary cannot +// answer. Advisory only: printed, never branched on. +func Version(bin string) string { + out, err := exec.Command(bin, "--version").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// InstallCmd is the command Install runs, named before it runs so the user can see +// what they are agreeing to, and printed verbatim when cargo is missing and they +// have to do it themselves. +// +// cargo, of the several ways ai-jail is distributed — Homebrew, an AUR package, Nix, +// signed release archives — because it is the one that works the same on both +// supported platforms and needs no tap, channel, or manual download. The others are +// better if you already use them, which is why InstallHint names them. +func InstallCmd() string { return "cargo install --locked " + Crate } + +// InstallHint is what to tell somebody who has no cargo: the platform-native routes, +// in the order they are worth trying. +func InstallHint() string { + switch runtime.GOOS { + case "darwin": + return "brew tap akitaonrails/tap && brew install " + Bin + ", or " + InstallCmd() + default: + return InstallCmd() + " (or the AUR, Nix, and signed release archives at " + Repo + ")" + } +} + +// Available says whether the toolchain Install needs is on PATH — the same question +// rtk answers about cargo, for the same reason: offering to install without asking +// it first is a prompt whose only possible answer is no. +func Available() bool { + _, err := exec.LookPath("cargo") + return err == nil +} + +// Install builds and installs ai-jail with cargo, streaming the build's output: it +// takes minutes, and a silent command that long reads as a hang. +func Install(stdout, stderr io.Writer) error { + cargo, err := exec.LookPath("cargo") + if err != nil { + return fmt.Errorf("cargo is not on PATH; install a Rust toolchain (https://rustup.rs), then run: %s", InstallCmd()) + } + cmd := exec.Command(cargo, "install", "--locked", Crate) + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", InstallCmd(), err) + } + return nil +} + +var longFlag = regexp.MustCompile(`--[a-z0-9][a-z0-9-]*`) + +// Help is what `ai-jail --help` prints, or "" when the binary cannot answer. +// Combined output, because a build that routes help to stderr still answers the +// question and reading only stdout would silently disable every flag below. +func Help(bin string) string { + out, err := exec.Command(bin, "--help").CombinedOutput() + if err != nil { + return "" + } + return string(out) +} + +// HelpFlags is the set of long options a help text advertises. +func HelpFlags(help string) map[string]bool { + found := map[string]bool{} + for _, f := range longFlag.FindAllString(help, -1) { + found[f] = true + } + return found +} + +// The two flags scc asks for, and the whole of what it asks for. +// +// ai-jail defaults both to off, which is the right default for a sandbox and the +// wrong one for a launcher: an agent with no network cannot reach the model it is, +// and one with no credential state cannot authenticate, so `ai-jail claude` with +// neither is a jail that starts nothing. They are function rather than policy, which +// is exactly why scc is willing to name these two and nothing else. +const ( + FlagNetwork = "--network" + FlagState = "--agent-state" +) + +// Needed returns the flags this build advertises out of the two above, and the ones +// it does not. +// +// A missing flag is reported rather than substituted. If a future ai-jail spells +// network access differently, scc passing nothing means the user's own `.ai-jail` +// still governs and the agent may simply fail to connect — which is a visible, +// diagnosable outcome. Guessing at a replacement spelling is how a sandbox ends up +// opened by a tool that was only trying to help. +func Needed(flags map[string]bool) (have, missing []string) { + for _, f := range []string{FlagNetwork, FlagState} { + if flags[f] { + have = append(have, f) + continue + } + missing = append(missing, f) + } + return have, missing +} + +// Args is the full command line: ai-jail's own options, then `--`, then the agent +// and everything meant for it. +// +// The terminator is not optional. ai-jail parses flags out of its tail the way +// `headroom wrap` does, so an agent argument that collides with one of ai-jail's — +// `--network` is plausible for any tool — would otherwise be eaten by the sandbox +// instead of reaching the agent. `--` ends that ambiguity for good. +// +// extra comes last among the options, so a flag the user passed deliberately wins +// over the two scc supplies. +func Args(opts, extra []string, bin string, rest []string) []string { + args := make([]string, 0, len(opts)+len(extra)+len(rest)+2) + args = append(args, opts...) + args = append(args, extra...) + args = append(args, "--", bin) + return append(args, rest...) +} diff --git a/internal/jail/jail_test.go b/internal/jail/jail_test.go new file mode 100644 index 0000000..0cdfac3 --- /dev/null +++ b/internal/jail/jail_test.go @@ -0,0 +1,115 @@ +package jail + +import ( + "runtime" + "strings" + "testing" +) + +// The terminator is the whole reason Args exists rather than a slice literal at the +// call site: ai-jail parses flags out of its tail, so an agent argument that collides +// with one of its own would be eaten by the sandbox instead of reaching the agent. +func TestArgsTerminatesTheSandboxOptions(t *testing.T) { + got := Args([]string{FlagNetwork, FlagState}, nil, "claude", []string{"--resume", "-p"}) + want := "--network --agent-state -- claude --resume -p" + if strings.Join(got, " ") != want { + t.Errorf("Args = %q, want %q", strings.Join(got, " "), want) + } +} + +// A colliding agent flag reaches the agent, because everything after `--` does. +func TestArgsKeepsACollidingAgentFlagOnTheAgentSide(t *testing.T) { + got := Args([]string{FlagNetwork}, nil, "claude", []string{"--network"}) + term := -1 + for i, a := range got { + if a == "--" { + term = i + break + } + } + if term < 0 { + t.Fatalf("no terminator in %q", got) + } + if strings.Join(got[term+1:], " ") != "claude --network" { + t.Errorf("after the terminator: %q, want %q", got[term+1:], "claude --network") + } +} + +// What the user passed deliberately comes after what scc supplies, so it wins. +func TestArgsPutsTheUsersFlagsLast(t *testing.T) { + got := Args([]string{FlagNetwork}, []string{"--lockdown", "--no-network"}, "claude", nil) + if strings.Join(got, " ") != "--network --lockdown --no-network -- claude" { + t.Errorf("Args = %q", got) + } +} + +func TestNeededReadsTheBuildsOwnHelp(t *testing.T) { + full := HelpFlags(` + --network / --no-network enable network + --agent-state mount credential state + --lockdown strict read-only +`) + have, missing := Needed(full) + if len(have) != 2 || len(missing) != 0 { + t.Errorf("have = %v, missing = %v, want both flags found", have, missing) + } + + // A build that spells one of them differently is reported, never substituted: + // scc guessing at a replacement is how a sandbox gets opened by a helper. + partial := HelpFlags(" --agent-state mount credential state\n") + have, missing = Needed(partial) + if strings.Join(have, " ") != FlagState || strings.Join(missing, " ") != FlagNetwork { + t.Errorf("have = %v, missing = %v", have, missing) + } + + // No help at all is every flag missing, which is the honest reading of an + // answer nobody could get. + if _, missing := Needed(HelpFlags("")); len(missing) != 2 { + t.Errorf("missing = %v, want both", missing) + } +} + +func TestHelpFlagsIgnoresProse(t *testing.T) { + flags := HelpFlags("Usage: ai-jail [OPTIONS] [--] [COMMAND]\n --network run with a network\n") + if !flags["--network"] { + t.Error("--network not found") + } + if flags["--"] || flags["-"] { + t.Errorf("a bare terminator was read as a flag: %v", flags) + } +} + +// Supported and Backend have to agree, or a report says the platform is fine and +// then names no sandbox to do it with. +func TestSupportedAgreesWithBackend(t *testing.T) { + if Supported() != (Backend() != "") { + t.Errorf("Supported() = %v but Backend() = %q", Supported(), Backend()) + } + switch runtime.GOOS { + case "linux": + if Backend() != "bubblewrap" { + t.Errorf("linux backend = %q", Backend()) + } + case "darwin": + if Backend() != "sandbox-exec" { + t.Errorf("darwin backend = %q", Backend()) + } + default: + if Supported() { + t.Errorf("%s reported as supported", runtime.GOOS) + } + // The message names the way out, not just the wall. + if !strings.Contains(Unsupported(), "WSL2") { + t.Errorf("Unsupported() = %q, want it to name WSL2", Unsupported()) + } + } +} + +func TestInstallHintNamesSomethingToRun(t *testing.T) { + if !strings.Contains(InstallCmd(), Crate) { + t.Errorf("InstallCmd() = %q", InstallCmd()) + } + if !strings.Contains(InstallHint(), "install") { + t.Errorf("InstallHint() = %q", InstallHint()) + } +}