agent-sandbox

command module
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 2 Imported by: 0

README

agent-sandbox

English | 日本語

Run an AI coding agent (Claude Code) inside a nono sandbox, and mediate every shell command it issues through a host-side command broker that runs in its own sibling nono session. Which commands may run, what each may touch, and which invocations are refused is decided once, by a nono command profile the operator writes — not by agent-sandbox.toml.

The point is not to lock the agent out of your machine. It is to make the boundary explicit and inspectable: agent-sandbox ai explain tells the agent which commands are policy-controlled, which run at the floor with no argv rules, and why any refusal fired, so a policy denial reads as a policy denial rather than an unexplained failure worth retrying.

launcher
├── nono wrap  --profile <agent profile>    -- claude …         no command control here
└── nono run   --profile <command profile>  -- agent-sandbox broker
                                               │
                                               ├─ exec git   → shim → wrapper (parses the invocation)
                                               │                    → shim → real git, its own child sandbox
                                               └─ exec rg    → runs directly in the broker's own sandbox

The two sessions are siblings, never nested. There is no shell in the loop: the broker parses the agent's command line itself (pipelines, &&/||/;, redirections, globbing, $(…), for/if, cd and the other shell builtins all work) and execs each simple command directly. Neither bash nor sh is declared as a policy command or a floor command, so the broker never dispatches a shell, in either tier — a claim about dispatch, not about what a dispatched toolchain can go on to run once it starts; see the two profiles for a measured case where a compiler reaches a real bash binary anyway.

Contents

Requirements

  • nono on PATH — the sandbox engine.
  • Go 1.25 or later (to build from source).
  • claude on PATH — for agent-sandbox claude.

Run agent-sandbox doctor to verify.

Install

go install github.com/ynny-github/agent-sandbox@latest

Or with mise:

# .mise.toml
[tools]
"go:github.com/ynny-github/agent-sandbox" = "latest"

The installed binary must live outside every path the command profile grants write access to. nono refuses to start a session otherwise (tool-sandbox policy command binary is replaceable through writable parent directory) — a build placed inside the project's own working directory does not qualify once that directory is the agent's workspace. Install to a stable, non-project path (what go install already does by default) before writing a command profile that declares agent-sandbox as a policy command.

Quick start

Write agent-sandbox.toml in your project root — it only names the two profile files, it does not build them:

In hook mode the hook is the only thing standing between a Bash call and the agent's own sandbox, so both of its failure modes are closed:

  • The hook runs but cannot rewrite the call — unreadable payload, unparseable JSON, no command in it — and exits 2, the one code Claude Code treats as blocking, with the reason on stderr. Any other non-zero exit would be a non-blocking error, after which Claude Code runs the original command unwrapped, in the agent's own sandbox, under none of the command profile's limits.
  • The hook cannot start at all — a binary the agent profile does not reach, say. Nothing inside the hook can catch that, so the launcher proves it first: before handing control to the agent it runs the hook once inside the agent's own sandbox with a probe payload and refuses to launch unless the command comes back rewritten. agent-sandbox debug shows the same invocation.

mcp mode has neither failure: Bash and Monitor are disabled outright, so there is nothing to bypass.

tool_mode = "hook"

[agents.claude]
profile = "claude-profile.json"   # the agent process's own nono profile, written by you

Then write both profiles yourself, directly in nono's own schema: claude-profile.json (the agent process's own sandbox — see The agent profile) and command-profile.json (every brokered command's sandbox — see The two profiles). There is no default for either: a missing file is a launch error, and agent-sandbox never generates, reads, or validates either one beyond its path.

Check that both resolve, then launch:

agent-sandbox doctor            # nono, the broker socket, and both profiles all usable?
agent-sandbox ai config-check   # does agent-sandbox.toml resolve, and do both profiles validate?
agent-sandbox claude -- --model opus

There is no sandbox up step. agent-sandbox claude starts the host-side command broker inside its own nono session, launches Claude under a second, sibling session, and tears the broker down when Claude exits.

How it works

The broker is a sandboxed process, not a router

agent-sandbox claude starts two sibling nono sessions: one wraps Claude Code under the operator-written agent profile named by [agents.<name>].profile; the other runs agent-sandbox broker under the operator-written command profile. Neither profile is generated — both are files you write in nono's own schema, and agent-sandbox does no more than resolve their paths and hand them to nono. The two sessions are siblings, not nested — nono refuses to nest a sandbox inside a sandbox, which is exactly why the broker does not run inside the agent's own session.

Every shell command the agent issues reaches the broker over a unix socket. The broker is not a shell: it parses the line itself with an embedded interpreter and calls execve directly for each simple command. Handing the line to bash -c instead — the design this project used before — turns out to be unfixable: a denial written for git reset --hard is trivially defeated by invoking git through its /nix/store path instead of by name, and nono's own documentation says plainly that pointing exec at a general-purpose shell defeats a sandbox. There is no shell in this design to defeat.

Two tiers, and nothing else runs

The command profile sorts every runnable program into one of two tiers. A program named in neither is never dispatched by the broker — that is the allowlist, and there is nothing else to check:

  • Policy commands are declared in the profile with their own child sandbox. Some carry nono's own invocation_policy argv rules directly; others — git, in this repository's own profile — are instead bound to a wrapper binary that parses the tool's actual grammar and decides in Go, with the real binary reachable only from that wrapper (see The two profiles). Either way, the broker dispatches to a policy command only through nono's own generated shim — never by an absolute path, a symlink, or any other indirection that skips it. That guarantee is about how the broker itself dispatches; it is not a guarantee about what a different command's own sandbox can still reach and run — see below.
  • Floor commands are named in the broker's own exec_paths and run directly in the broker's sandbox, with no argv rules of their own — there is no shim to bypass because there is nothing being enforced.

A refusal always explains itself: a policy command's denial carries the reason its profile entry wrote; a command absent from both tiers is refused at execve, the same as an unrecognized command would be, with no further detail to give.

One thing the two tiers do not cover: the broker's own shell builtins (echo, cd, test, read, and the others its embedded interpreter implements) run inside the broker process itself, at the broker's own filesystem grants — never through either tier. A redirect or a glob you write is bounded the same way.

Nor do the two tiers bound what a compiler or interpreter does once it runs. The allowlist above is absolute about what the broker itself will dispatch — not about which programs can execute. A command the profile does not enumerate will never be dispatched by the broker; a toolchain that compiles and executes code is bounded only by what its own sandbox can reach, not by argv rules and not by which other tools are or are not enumerated elsewhere in the profile, and it can run whatever those grants reach — including a copy of a program neither tier names. This repository's own profile enumerates go for exactly this reason — see The two profiles below for what that costs and how far the containment actually reaches once you look closely at what a Go program can do from inside go's own grants.

The filesystem is not virtualized

There is no container and no bind mount. A command runs directly on the host filesystem at the same absolute path, restricted to whatever its own sandbox (policy command) or the broker's sandbox (floor command) grants. HOME keeps its real value. Nothing needs translating between what the agent sees and what a command actually touches.

Paths outside a command's own grants are reachable only where its profile entry says so.

Neither profile is agent-sandbox's

agent-sandbox generates no nono profile at all. Both — the launched agent's own, named by [agents.<name>].profile, and the command profile every brokered command runs under — are files the operator writes directly in nono's schema; agent-sandbox does no more than resolve their paths and hand them to nono. See The agent profile and The two profiles.

Commands

Command What it does
agent-sandbox claude -- [claude args...] Launch Claude under nono, with the command broker running as a sibling session
agent-sandbox exec -- <command> Send one command to the broker and stream its output
agent-sandbox doctor Check that nono works, the broker socket can bind, both profiles exist and validate, the agent profile forwards AGENT_SANDBOX_BROKER_SOCKET, and the command profile does not grant write access to the broker's own binary. Exit 0 / 1
agent-sandbox debug -- [claude args...] Print the nono invocations for both sessions and the GitHub MCP config (token redacted) — without running anything
agent-sandbox ai explain Agent-facing description of the sandbox: how commands run, both tiers, and every denial's reason
agent-sandbox ai config-check Validate agent-sandbox.toml and both nono profiles the way launch reads them
agent-sandbox command-router Start the MCP server (tool_mode = "mcp")
agent-sandbox hook PreToolUse adapter (tool_mode = "hook"; invoked by Claude, not by you)

Global flags: --config <path> (default agent-sandbox.toml) and --env <ref> (repeatable).

For claude and debug, only --config and --env may appear before --; everything after -- goes to claude. agent-sandbox does not forward options to nono — both profiles come from the config file and the command profile it points at.

--settings is reserved by agent-sandbox and rejected as a passthrough option. --mcp-config / --strict-mcp-config are rejected too when the GitHub MCP is enabled.

doctor

doctor checks what a launch depends on:

  • nono is on PATH and nono --version runs.
  • The command broker can bind a unix socket in its socket directory ($XDG_STATE_HOME/agent-sandbox, or ~/.local/state/agent-sandbox). A plain write check is not enough — binding also catches the ~104-byte sun_path limit.
  • Both nono profiles — the agent profile ([agents.<name>].profile) and the command profile — exist and nono profile validate accepts them.
  • The agent profile forwards AGENT_SANDBOX_BROKER_SOCKET into the sandbox. doctor sets the variable to a sentinel value and starts one throwaway nono wrap --profile <agent profile> session to confirm the sentinel survives. nono cannot be asked directly: nono profile show does not report environment.allow_vars, and nono why has no environment-variable query, so this is measured rather than read from the file. Without the variable the agent can never reach the broker, and every command fails for a reason nothing on screen explains. Skipped when doctor is itself running inside a session — nono refuses to nest a sandbox inside a sandbox, so the probe is only meaningful run from the host.
  • The command profile does not grant write access to the broker's own binary. doctor asks nono why --profile <command profile> --path <broker binary> --op write and fails if the answer is allowed.

If any of these fail, agent-sandbox claude will not launch Claude at all.

Configuration

tool_mode

Selects how the agent's commands reach the broker.

Mode Behavior
hook Bash and Monitor stay enabled. A PreToolUse hook is injected at launch via claude --settings, rewriting each command to agent-sandbox exec -- <command>. Nothing is written to .claude/settings.json. agent-sandbox must be on PATH.
mcp (default) Bash and Monitor are disabled. The agent routes commands through the run_command MCP tool, and output is written to files under mcp.command_output_dir — the response carries paths and an exit code only.
tool_mode = "hook"

[mcp]
command_output_dir = "/tmp/mcp-output"  # required in mcp mode; ignored in hook mode

The command profile is read once, when the broker starts — editing it, like editing agent-sandbox.toml, takes effect at the next agent-sandbox claude, never mid-session.

The agent profile: [agents.<name>].profile

The nono profile the launched agent process itself runs under is a file the operator writes in nono's own schema. agent-sandbox does not generate, read, or validate its contents — it only resolves the path named by [agents.<name>].profile and hands it to nono wrap --profile. The table's key is the launch subcommand's name: agent-sandbox claude reads [agents.claude].

written resolves to
omitted <name>-profile.json beside agent-sandbox.toml
relative path joined onto the directory holding agent-sandbox.toml
absolute path itself
[agents.claude]
profile = "claude-profile.json"

A missing profile file is a launch error: there is no generated fallback, matching how the command profile behaves. See The two profiles for what this file must grant, and User-scope config for how the key behaves when set in ~/.config/agent-sandbox/config.toml.

Four grants do not belong in this file, because only the launcher knows their values and it passes each on the nono wrap command line:

grant why it is dynamic
the agent-sandbox binary itself (--read-file) the agent runs agent-sandbox hook and agent-sandbox serve as its own direct children — inside its own sandbox, not through the broker — and on a mise-managed toolchain the binary's path carries the Go version, so an upgrade renumbers it
the main git directory of a worktree (--allow) detected per invocation
the generated GitHub MCP config (--read-file) a temp file, new every launch
the command broker's socket (--allow-unix-socket) its name is derived from the launcher's PID

Without the first one nono refuses the execve and every command fails with nothing on screen to explain it, so the launcher refuses to start at all if it cannot locate its own binary.

Because it is hand-written, this file can name any path nono's schema allows — including protected prefixes such as ~/.aws, ~/.gnupg, ~/.config/gh, and ~/.kube via bypass_protection — where the deleted capability catalog only ever offered a fixed set of bundles. Review both profile files in git diff like any other code.

The two profiles

Two files, both written by the operator in nono's own schema, decide all host access — agent-sandbox generates neither, and hands each only a path:

  • The agent profile ([agents.<name>].profile, see above) is the sandbox the launched agent process itself runs in: its own file tools, and any MCP server it spawns as a direct child — an MCP server the agent spawns is not brokered, so a Python- or Go-based MCP server needs its own runtime granted there. Shell commands never run in it.
  • The command profile, described below, is the sandbox every brokered command runs in — everything reaching the broker via Bash/Monitor (hook mode) or the run_command MCP tool (mcp mode).

Nothing declared in one reaches the other. Because the agent process itself never runs a shell command — hook mode routes Bash/Monitor into the broker through a PreToolUse hook, and mcp mode disables Bash outright — nothing needed only to run a command belongs in the agent profile: a toolchain grant for go, python, or any other command-line tool belongs in the command profile, never here. What is left for the agent profile is genuinely narrow — this repository's own claude-profile.json is a worked example.

AGENT_SANDBOX_BROKER_SOCKET must be in the agent profile's environment.allow_vars, or the agent cannot reach the broker at all: every command then fails with an error that names nothing. agent-sandbox doctor measures this directly (see doctor), because nono cannot be asked — nono profile show does not report environment.allow_vars, and nono why has no environment-variable query.

command-profile.json, written in nono's own schema, resolved next to agent-sandbox.toml — a top-level command_profile = "<path>" in the TOML overrides the name, and a relative path resolves against the directory holding the TOML. A missing file is a launch error: there is no generated default, because a static one cannot absorb host differences (/nix/store versus /usr/bin), and a profile that looks present but refuses every command is the worst failure mode.

agent-sandbox reads only enough of it to help ai explain describe the two tiers (see How it works) and to help doctor check that it validates and does not grant write access to its own binary. Every other decision — which commands exist, what each may touch, which invocations are refused — is the operator's, expressed directly in nono's schema.

$WORKDIR. nono expands it inside filesystem and inside every command_policies child sandbox. Write it wherever the working directory is meant, never a literal path — that is what lets one profile serve multiple git worktrees.

Network. The top-level network section is a ceiling. When it sets network_profile or allow_domain, nono stands up a loopback proxy and injects proxy env vars (http_proxy/HTTP_PROXY/https_proxy/ HTTPS_PROXY/no_proxy/NO_PROXY); block: true or network_profile: null stands up no proxy at all. A command_policies child's own network has exactly two effective states, measured: omitting the key blocks it outright (a bare network: {} behaves the same), and {"allow_all": true} grants Landlock permission to open raw sockets. A per-command allow_domain is not enforced.

Whether an allow_all child's traffic is actually bounded by the ceiling depends on nothing in its own network grant — it depends on whether the proxy env vars reach it. Each hop's own environment.allow_vars filters what it received from its caller, and if a single hop in the chain — including the session's own top-level environment section — omits the proxy vars, they are gone for every hop below it, and the leaf falls back to a direct, unmediated connection: allow_all without the proxy vars means genuinely unbounded, not "up to the ceiling". This is not a theoretical trap: an earlier revision of this profile set the ceiling to network_profile: null (unbounded on purpose) precisely because, at the time, realgit's own chain omitted the proxy vars at every hop and so was already unbounded regardless of the ceiling's value — measured identical under network_profile: "developer" and network_profile: null. That is no longer this profile's shape (see below); it is recorded here as the failure mode the current shape exists to avoid.

This repository now binds git to GitHub, not to an unbounded ceiling. The top-level network is {"allow_domain": ["github.com", "*.githubusercontent.com"]}, which stands up the proxy, and environment.allow_vars at every hop of the chain — the top-level session, agent-sandbox, git, and realgit alike — includes http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY (measured: the six names are sufficient: no NONO_* variable had to be added for GitHub to work). With every hop carrying those vars, realgit's {"allow_all": true} genuinely means "up to the ceiling" rather than "unrestricted": measured through the real broker, git ls-remote https://github.com/git/git.git HEAD returns a real ref, while git ls-remote https://example.com/x, a raw IP, and a nonexistent domain all fail identically with CONNECT tunnel failed, response 403 — a genuine proxy denial, not a DNS or routing failure. Omitting the proxy vars from any one of those four hops' allow_vars silently reopens realgit to the whole internet, exactly as it did before this section's own chain was corrected — check all four when changing this profile, not just the one you touched.

A worked example — this repository's own command-profile.json at the repo root — declares agent-sandbox itself as the session's policy command (can_use: ["git", "go"], exec_paths covering rg, mise, gofmt (as a single file, not its whole directory — see the note on multi-call binaries below), and the coreutils this repo's own workflows use). git and go are its two policy commands, for two different reasons:

  • git is bound to a wrapper, not to the real binary. The profile pins git's executable back to the agent-sandbox binary itself, with argv_prepend: ["safe", "git"] inserted after the shim's own argv[0], so git status --short reaches the wrapper as ["safe", "git", "status", "--short"] — exactly what agent-sandbox safe git parses. The real binary gets a second name reachable only from the wrapper (realgit), so there is no path to it that skips the parser.
  • go carries neither a wrapper nor an invocation_policy. A compiler is not something argv-level rules can usefully bound, but it still needs its own child sandbox, because go test compiles and immediately executes a test binary, and that write-then-execute directory has to stay out of reach of every other command.

More on what a compiler being enumerable at all actually costs below.

"git": {
  "executable": "<agent-sandbox binary>",
  "can_use": ["realgit"],
  "from": { "agent-sandbox": { "sandbox": {
    "argv_prepend": ["safe", "git"],
    "...": "..."
  } } }
},
"realgit": {
  "executable": "/nix/store/…-git-2.54.0/bin/git",
  "from": { "git": { "sandbox": { "...": "..." } } }
}

nono profile validate warns that realgit "allows unrestricted child network" — that warning is generic and, for this profile, misleading if read literally. realgit's own grant is {"allow_all": true}, but the top-level ceiling and every hop's environment.allow_vars bound it to GitHub in practice (see "Network" above); the validator has no way to know that from realgit's entry alone, since the same warning would fire whether or not the surrounding chain actually carries the proxy vars that make the bound real. Do not treat the warning's absence, or its presence, as evidence either way — check the actual chain.

git's wrapper (internal/safe/git, invoked as agent-sandbox safe git) parses the invocation instead of matching argv fragments, which is what lets it refuse two routes an invocation_policy rule cannot reach: a global option placed ahead of the subcommand (git --no-pager config alias.h "reset --hard" walks a naive prefix matcher straight past the rule looking for reset --hard), and an alias written directly into .git/config with no git invocation at all — the wrapper resolves an unrecognized leading token against the repository's own configured aliases and re-checks the expansion, which is the only way to catch that second route. As of this writing the rule set refuses, among others: unconditional --force/-f on push, reset --hard, clean -f, force-deleting a branch, filter-branch/ filter-repo, update-ref -d/--delete, reflog expire, gc --prune=now/--prune=all, bypassing hooks or signatures (--no-verify, --no-gpg-sign, commit -n), injecting an alias or an exec-capable config key via -c/--config-env, stash drop/clear, removing a remote or changing its URL (adding one is allowed), deleting a tag, discarding working-tree changes (checkout -- ./restore --worktree), writing config (git config reads are allowed; anything that is not a read is not), and --exec-path. The list above is a snapshot; internal/safe/git/rules.go is the source, and agent-sandbox ai explain renders it live from that same source (not from this document) for whichever profile is actually running. Do not point an agent at agent-sandbox safe git --help for this: safe git disables its own flag parsing, so --help passes straight through to real git and prints git's own help instead, not a rule set.

The -c/--config-env entries above (execCapableConfigKeys in internal/safe/git/rules.go) are a nine-key denylist, not the boundary, and treating them as the boundary is the mistake to avoid. git has more exec-capable config keys than that nine — diff.external, filter.*.clean/smudge, merge.*.driver, pager.*, protocol.*.command, uploadpack.packObjectsHook, trailer.*.command, core.gitProxy, gpg.<fmt>.program among them — and every one of them is settable by the same route the alias check above exists to catch: echo '[diff] external = …' >> .git/config is a broker builtin, not an execve, so neither this wrapper's parser nor nono's shim is ever in the loop. What actually stops these is a layer underneath the wrapper, not the wrapper itself: realgit's own exec_paths in the command profile names only libexec/git-core, so every shell-out one of those config keys would need — sh -c, a bare program name, rebase -x, bisect run, submodule foreach, difftool --extcmd — fails at execve under nono's Landlock execute restriction, regardless of what the parser did or did not catch. That narrow exec_paths is load-bearing and lives entirely in command-profile.json, not in this repository's Go source. Widening realgit's exec_paths toward /nix/store — the natural fix to reach for when some unrelated git subcommand's shell-out fails — silently reopens every one of those config keys at once, because nothing in the wrapper's own rule set changed. The parser stops what it can see in argv; a narrow exec_paths stops what it cannot see at all; a git config key that runs a program is exactly what that second layer is holding back — for every route except one, measured and recorded as an accepted residual in the spec's "Accepted residual: diff.external reaches ld-linux directly" (a single-token diff.external reaches the pinned git binary's own dynamic linker regardless of exec_paths, and from there reaches arbitrary execution of anything readable under the sandbox).

It is also worth being precise about what kind of rule each entry in the list above actually is. hard-reset, clean-force, discard-changes, stash-destroy and tag-delete are guardrails against an accidental invocation, not defenses against a deliberate one: rm, mv and cp are floor commands with write access to $WORKDIR in this repository's own profile, so rm -rf .git needs no git at all, and none of those five rules sits anywhere near that path. The rest of the list — force-push, branch-force-delete, filter-history, update-ref-delete, gc-prune, bypass-hooks, alias-injection, config-exec-injection, remote-tamper, config-write and exec-path-injection — are the ones doing boundary work against what git itself, or a config value it reads, can be made to do; presenting the whole list as one undifferentiated policy overstates the first five.

remote-tamper in particular is not a network control, and its message says so: it refuses remote remove/rm/set-url through git's own CLI, but remote add is explicitly allowed, remote.origin.url is settable by the same direct .git/config write the alias check exists to catch, and realgit's own child sandbox carries "network": {"allow_all": true} regardless. That grant is bounded, not unrestricted: this repository's session ceiling (the top-level network section) is {"allow_domain": ["github.com", "*.githubusercontent.com"]}, and every hop of this chain (agent-sandboxgitrealgit, including the session's own top-level environment) carries the proxy env vars in its environment.allow_vars, so realgit's traffic is actually routed through nono's proxy and held to that allowlist (see "Network" above for the measurement: GitHub reaches, example.com and a raw IP both fail with a 403 proxy denial). What git can reach over the network is GitHub (and *.githubusercontent.com) only — nothing this wrapper checks narrows that further, but nothing about remote add/remote.origin.url widens it either, since the allowlist lives in the profile's network section, not in anything git's own config can influence.

A refusal from the wrapper prints blocked: <reason> to stderr and exits 1 — it is caught in Go before nono is ever involved, so it is not the invocation_policy exit code 126 an argv-rule denial produces (still true for a command that carries invocation_policy directly, and for nono's own tool-sandbox refusals, e.g. a command absent from can_use).

docker is not declared in this repository's profile at all — not as a policy command, not as a floor command. A docker wrapper exists (internal/safe/dockercompose and cmd/safe_docker.go, identical shape to git's: docker → wrapper → realdocker → the real binary) and is fully built and tested, but wiring it into a command profile is a capability decision an operator makes deliberately, not something to enable by copying this repository's profile. The reason is the Docker socket, and it is not what it looks like:

The Docker socket is not filesystem-gated. nono does not mediate pathname AF_UNIX sockets — only the Linux abstract socket namespace — so /var/run/docker.sock is reachable by any command that can execute the docker binary, regardless of what fs_read/fs_write grant it does or does not have. Not declaring docker in a profile is a real allowlist boundary (the broker will not dispatch a name absent from both tiers, full stop); declaring it, with no filesystem grant anywhere near the socket, is not — the daemon is reachable the moment the binary is. Once it is, the wrapper's checks (below) are the entire defense, not a second layer behind a filesystem bound, and reaching the daemon at all is root-equivalent: the socket permits mounting / into a container. Gating the socket itself needs linux.af_unix_mediation plus a filesystem.unix_socket allowlist — a separate opt-in nono's profile guide documents under its no-docker example — which this repository's profile does not configure, because this repository's profile does not declare docker at all.

An operator who decides the wrapper's checks are sufficient can wire it in with the identical shape as git:

"docker": {
  "executable": "<agent-sandbox binary>",
  "can_use": ["realdocker"],
  "from": { "agent-sandbox": { "sandbox": {
    "argv_prepend": ["safe", "docker"],
    "...": "..."
  } } }
},
"realdocker": {
  "executable": "/nix/store/…-docker-…/libexec/docker/docker",
  "from": { "docker": { "sandbox": { "...": "..." } } }
}

Three things worth knowing before doing that. First, the executable above is deliberately libexec/docker/docker, not the more obvious bin/docker: on NixOS, bin/docker is a small stub that re-execs libexec/docker/docker by absolute path, and nono's per-command Landlock rule set (built from the pinned executable's own direct library dependencies) does not cover that second, indirectly invoked path — pinning the stub crashes every invocation, silently (execve(...) = -1 EACCES, reported only as "Command exited with code 255"). Second, the wrapper's checks (internal/safe/dockercompose and cmd/safe_docker.go; read the source for the exact, current rule set — --help passes straight through to real docker and prints docker's own help, never the wrapper's rule set: the plain docker path never intercepts it because the wrapper disables its own flag parsing, and the compose path skips model resolution outright once it sees --help, since help executes nothing and needs no model) are argv/model-level, not filesystem-level: a compose invocation is checked against its resolved model (docker compose config) — host-path mounts, the Docker socket, privileged, host network/pid/ipc, dangerous capabilities, disabled seccomp/apparmor — and every other invocation is checked at the argv level for run/exec, --privileged, and a host-path or Docker-socket bind mount.

Third, and this is the one that matters most given the socket fact above: the wrapper's checks have two known gaps, both accepted only because docker was otherwise unreachable — a premise this opt-in block removes the moment it is pasted in.

  • docker create followed by docker start reaches the same running state as docker run with none of the dangerous flags present on either individual invocation — create is not itself refused (nothing about creating a container without starting it is dangerous on its own), so this is a structural gap across two calls, not a parsing defect the argv check could close in one.
  • --mount type=volume,volume-opt=device=...,volume-opt=o=bind is a bind mount in substance (a local-driver volume with o=bind behaves as a bind mount of device's path) that the mount check does not catch, since it keys on type=bind specifically and this spec's type is volume.

Neither is closed by anything in this repository. An operator enabling docker is accepting both until someone closes them.

Four properties worth knowing before writing your own:

  • A toolchain that compiles and runs code is bounded only by its own sandbox, not by which other commands are enumerated. This repository's own go entry has no invocation_policy at all — a compiler is not something argv-level rules can usefully bound — and its own child sandbox originally granted /nix/store read access, the same NixOS execute path every other command needs. That combination is a real, measured bypass: go run on a program that copies git's (or bash's) real binary into /tmp and execs it directly reaches the real binary, unmediated by any shim or invocation_policy, exactly as if the copy-then-exec had been attempted at the floor. Fixed here by removing /nix/store and /run/current-system/sw from go's own fs_read: the toolchain (built with CGO_ENABLED=0, confirmed with ldd reporting "not a dynamic executable") and everything it compiles here need neither for their own linking, so the fix costs nothing go test/go build need — but it does not make the underlying risk disappear. A dynamically linked binary staged through $WORKDIR instead (readable to both the floor and to go) and then copied to /tmp and exec'd now fails at the shared-library-loading step, since /tmp is not /nix/store and the copy carries no working runtime with it — measured directly, both for git and for bash. A sufficiently deliberate attack that stages an entire dependency closure (a copy of the dynamic linker plus every .so it needs) into $WORKDIR and invokes the copied linker directly was not attempted and is not claimed to be closed. If you enumerate a compiler or interpreter in your own profile, treat this as the honest boundary: its own sandbox's reach, not the two-tier model's absoluteness, is what actually bounds it.
  • The installed binary's directory must be on the launcher's own PATH. The launcher invokes agent-sandbox broker by base name, never by an absolute path: nono treats an absolute-path invocation of a declared policy command as a direct exec bypass and refuses it. That name then resolves the same way an ordinary shell would — through the launcher process's own PATH, before any sandbox exists — not through command_policies.executable_dirs, which measurably plays no part in resolving the session entrypoint at all. This also means PATH resolution finds whichever agent-sandbox comes first on it, not necessarily the one you meant: a stale copy or an unrelated program sharing the name, earlier on that PATH, would silently become the broker instead.
  • Enumerating every runnable command is the real cost of this design. The broker will not dispatch a program absent from both tiers, which is the allowlist working as intended — and also the profile's recurring maintenance burden. That is a claim about dispatch, not about reachability in general: a command with a compiler or interpreter in its own sandbox can still execute code the broker never dispatched, exactly the chain the compiler caveat above measures. On a NixOS host, coreutils applets (cat, ls, rm, cp, …) are symlinks into one combined multi-call binary: pinning each as its own policy command silently disables enforcement, so they belong at the floor, granted as one directory. A pinned executable must be the real program, never a multi-call host or a version-manager shim — pointing an entry at mise turns every mise-managed tool into an attempted direct exec of mise itself. Nix's own docker package has the identical shape and cost a real debugging session to find while testing the opt-in docker block above: bin/docker is a small stub that re-execs libexec/docker/docker, the actual CLI binary, and nono's per-command Landlock rule set — built from the pinned executable's own direct library dependencies — does not cover that second, indirectly invoked path. Pinning realdocker at bin/docker crashed every invocation with execve(...) = -1 EACCES (reported only as "Command exited with code 255", no other output); the block above already pins libexec/docker/docker directly, for this reason.
  • nono profile validate checks JSON syntax and group references only — it does not catch every schema mistake (exec_paths itself is not in the published JSON Schema, though the runtime honours it). Verify a real workflow against a real nono run session, not just a passing validate.
User-scope config

An optional ~/.config/agent-sandbox/config.toml is composed with the project config: every field is a scalar or a map of scalars, and the project file wins for anything it sets — an omitted key falls back to the user-scope value.

[agents.<name>] tables merge by key, not by union: an agent declared only in the user-scope file (say [agents.codex]) still applies even when the project file declares only [agents.claude]. A table present in both files is replaced wholesale by the project one rather than merged field by field — today that means a project [agents.claude] table always fully determines that agent's profile path, since AgentConfig has a single field.

This is the same scalar-override behavior command_profile already has: both it and every [agents.<name>].profile are project-overrides-user values, so "every project has a claude-profile.json beside its config" is declarable once, in the user-scope file, and a project that needs a different path just sets it.

Environment variables (--env)

--env loads variables from a file into the launcher's own process before running Claude or a command. It is repeatable and uses a scheme-based reference; only file: exists today.

agent-sandbox claude --env file:.env -- --model opus
agent-sandbox exec --env file:.env -- go test ./...

The format is a minimal dotenv subset: KEY=VALUE per line, # comments and blank lines ignored, an optional export prefix stripped, surrounding quotes removed. There is no variable interpolation. Values override any same-named host variable; with multiple files, later files win.

--env no longer grants anything. Loading a variable into the launcher's own process is not the same as the sandboxed agent seeing it: nono forwards only what a profile's environment.allow_vars lists, hand-written by the operator. A variable loaded by --env reaches the launched agent only if the agent profile's environment.allow_vars names it — a glob such as MISE* covers a family in one line. Exposing the same variable to a brokered command is a separate, explicit edit to the command profile's environment.allow_vars — with one exception: AGENT_SANDBOX_BROKER_SOCKET must never appear in the command profile's environment.allow_vars, under any name or wildcard that would match it. A command that can reach the broker socket can recurse into the broker, which spawns handlers with no concurrency cap — a host-side fork bomb. A value silently not reaching the agent is exactly the failure this paragraph exists to pre-empt.

GitHub MCP

The built-in GitHub MCP server is enabled when GITHUB_MCP_TOKEN is non-empty; otherwise it is not configured at all. Its value is passed to the MCP server as GITHUB_PERSONAL_ACCESS_TOKEN.

agent-sandbox claude --env file:.secrets.env -- --model opus
# .secrets.env: GITHUB_MCP_TOKEN=ghp_...

agent-sandbox debug prints the resulting MCP config with the token redacted.

Development

mise install          # Go + lefthook
go test ./...         # unit + integration tests
go build ./...
mise run build         # install a working-tree build via `go install`

Building this project's own binary no longer puts it on PATH. agent-sandbox claude resolves its own broker entrypoint by base name through the launcher's PATH (see the two profiles), and a build that landed in this working tree would sit inside the same directory the command profile grants fs_write — the writable-and-executable combination nono refuses an entrypoint binary for. mise run build runs go install instead, which installs to $(go env GOBIN) when it is set and to $(go env GOPATH)/bin otherwise — outside $WORKDIR either way, and already on the developer's PATH (this repository's own mise-managed Go sets GOBIN to its own version-scoped bin/). Run mise run build after every change you want to exercise, then launch as usual (agent-sandbox claude). go run . cannot stand in for this: its output binary is staged under $TMPDIR at run time, on no reliable footing with the profile at all.

End-to-end suites live in e2e (Python/pytest, MCP stdio).

Commits follow Conventional Commits; lefthook validates the title on commit-msg.

License

MIT © Yuya Nagai

Documentation

Overview

main.go

The entrypoint lives at the module root so the install path stays short: `go install github.com/ynny-github/agent-sandbox@latest`. Everything else stays under agent-sandbox/, where the internal/ tree remains importable only from within that subtree — this file reaches the CLI through the non-internal cmd package and touches nothing else.

Directories

Path Synopsis
agent-sandbox
cmd
agent-sandbox/cmd/claude.go
agent-sandbox/cmd/claude.go
internal/broker
Package broker carries command execution across the sandbox boundary.
Package broker carries command execution across the sandbox boundary.
internal/claude
Package claude builds and runs the sandboxed `claude` command: it parses the launcher's arguments, constructs the `nono wrap … claude …` invocation (including the hook settings injected in hook mode), and executes it.
Package claude builds and runs the sandboxed `claude` command: it parses the launcher's arguments, constructs the `nono wrap … claude …` invocation (including the hook settings injected in hook mode), and executes it.
internal/envflag
Package envflag loads environment variables from --env references and applies them to the current process.
Package envflag loads environment variables from --env references and applies them to the current process.
internal/policysnapshot
Package policysnapshot used to persist the sandbox policy to a per-session JSON file so hook-mode `agent-sandbox exec` could route from a frozen copy the agent could not edit.
Package policysnapshot used to persist the sandbox policy to a per-session JSON file so hook-mode `agent-sandbox exec` could route from a frozen copy the agent could not edit.
internal/safe
Package safe holds shared helpers for the "safe" command wrappers.
Package safe holds shared helpers for the "safe" command wrappers.
internal/safe/dockercompose
Package dockercompose validates and runs docker compose invocations safely.
Package dockercompose validates and runs docker compose invocations safely.
internal/safe/git
Package git implements the "safe git" wrapper: it parses a git argv and reports known-dangerous invocations so the command layer can refuse them.
Package git implements the "safe git" wrapper: it parses a git argv and reports known-dangerous invocations so the command layer can refuse them.
internal/shellquote
Package shellquote quotes strings as single shell tokens.
Package shellquote quotes strings as single shell tokens.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL