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
- Install
- Quick start
- How it works
- Commands
- Configuration
- Environment variables (
--env) - GitHub MCP
- Development
- License
Requirements
- nono on
PATH— the sandbox engine. - Go 1.25 or later (to build from source).
claudeonPATH— foragent-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 debugshows 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_policyargv 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_pathsand 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:
nonois onPATHandnono --versionruns.- 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-bytesun_pathlimit. - Both nono profiles — the agent profile (
[agents.<name>].profile) and the command profile — exist andnono profile validateaccepts them. - The agent profile forwards
AGENT_SANDBOX_BROKER_SOCKETinto the sandbox. doctor sets the variable to a sentinel value and starts one throwawaynono wrap --profile <agent profile>session to confirm the sentinel survives. nono cannot be asked directly:nono profile showdoes not reportenvironment.allow_vars, andnono whyhas 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 writeand 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_commandMCP 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:
gitis bound to a wrapper, not to the real binary. The profile pinsgit'sexecutableback to theagent-sandboxbinary itself, withargv_prepend: ["safe", "git"]inserted after the shim's ownargv[0], sogit status --shortreaches the wrapper as["safe", "git", "status", "--short"]— exactly whatagent-sandbox safe gitparses. The real binary gets a second name reachable only from the wrapper (realgit), so there is no path to it that skips the parser.gocarries neither a wrapper nor aninvocation_policy. A compiler is not something argv-level rules can usefully bound, but it still needs its own child sandbox, becausego testcompiles 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-sandbox → git → realgit, 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.sockis reachable by any command that can execute thedockerbinary, regardless of whatfs_read/fs_writegrant it does or does not have. Not declaringdockerin 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 needslinux.af_unix_mediationplus afilesystem.unix_socketallowlist — a separate opt-in nono's profile guide documents under itsno-dockerexample — which this repository's profile does not configure, because this repository's profile does not declaredockerat 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 createfollowed bydocker startreaches the same running state asdocker runwith none of the dangerous flags present on either individual invocation —createis 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=bindis a bind mount in substance (alocal-driver volume witho=bindbehaves as a bind mount ofdevice's path) that the mount check does not catch, since it keys ontype=bindspecifically and this spec'stypeisvolume.
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
goentry has noinvocation_policyat all — a compiler is not something argv-level rules can usefully bound — and its own child sandbox originally granted/nix/storeread access, the same NixOS execute path every other command needs. That combination is a real, measured bypass:go runon a program that copiesgit's (orbash's) real binary into/tmpandexecs it directly reaches the real binary, unmediated by any shim orinvocation_policy, exactly as if the copy-then-exec had been attempted at the floor. Fixed here by removing/nix/storeand/run/current-system/swfromgo's ownfs_read: the toolchain (built withCGO_ENABLED=0, confirmed withlddreporting "not a dynamic executable") and everything it compiles here need neither for their own linking, so the fix costs nothinggo test/go buildneed — but it does not make the underlying risk disappear. A dynamically linked binary staged through$WORKDIRinstead (readable to both the floor and togo) and then copied to/tmpand exec'd now fails at the shared-library-loading step, since/tmpis not/nix/storeand the copy carries no working runtime with it — measured directly, both forgitand forbash. A sufficiently deliberate attack that stages an entire dependency closure (a copy of the dynamic linker plus every.soit needs) into$WORKDIRand 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 invokesagent-sandbox brokerby 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 ownPATH, before any sandbox exists — not throughcommand_policies.executable_dirs, which measurably plays no part in resolving the session entrypoint at all. This also means PATH resolution finds whicheveragent-sandboxcomes 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 pinnedexecutablemust be the real program, never a multi-call host or a version-manager shim — pointing an entry atmiseturns every mise-managed tool into an attempted direct exec ofmiseitself. Nix's owndockerpackage has the identical shape and cost a real debugging session to find while testing the opt-indockerblock above:bin/dockeris a small stub that re-execslibexec/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. Pinningrealdockeratbin/dockercrashed every invocation withexecve(...) = -1 EACCES(reported only as "Command exited with code 255", no other output); the block above already pinslibexec/docker/dockerdirectly, for this reason. nono profile validatechecks JSON syntax and group references only — it does not catch every schema mistake (exec_pathsitself is not in the published JSON Schema, though the runtime honours it). Verify a real workflow against a realnono runsession, not just a passingvalidate.
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. |
|
command-router
module
|