agent-sandbox
English | 日本語
Run an AI coding agent (Claude Code) inside a
nono sandbox, and mediate every shell
command it issues through execd, a host-side exec daemon that runs in its own
sibling nono session. What each command may touch is decided 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 run in their own sandbox, which run at execd's own
grants, 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 execd
│
├─ exec git → shim → git, its own child sandbox
│ └─ exec ssh → shim → ssh, its own
└─ exec rg → runs directly in execd's own sandbox
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/cmd/agent-sandbox@latest
Or with mise:
# .mise.toml
[tools]
"go:github.com/ynny-github/agent-sandbox/cmd/agent-sandbox" = "latest"
Install outside every path the command profile grants write access to —
a session will not start otherwise. go install already does this.
Quick start
Run agent-sandbox init in your project root. It downloads a starting point
from this repository's main branch — agent-sandbox.toml, a minimal command
profile and agent profile, and two skills that explain how to grow a profile —
and writes them beside your config. It never overwrites: a file that already
exists is reported and left alone.
agent-sandbox init
What it writes is a starting point, not a finished boundary. The command
profile gives commands $WORKDIR read/write, an allow_domain list covering
GitHub, six environment variables, and policies for git and ssh — with
ssh reachable only through git. Widen it as you need to; the seeded
growing-a-nono-profile skill walks through each change.
On NixOS the command profile needs one edit. Executables live in
/nix/store, which nono's default floor does not grant, so every command
fails with exit 127 until you add:
"groups": { "include": ["nix_runtime"] },
"filesystem": { "read": ["/nix/store", "/run/current-system/sw"] },
and add the output of git --exec-path to git's exec_paths. The agent
profile is unaffected — extends: claude already carries nix_runtime.
You can also write both profiles from scratch, directly in nono's own schema.
There is no default for either — a missing file is a launch error. This
repository's own two files are worked examples of a profile grown over time,
and each entry carries its reasoning in a comment; templates/minimal/ is the
starting point init serves.
agent-sandbox doctor # nono, the execd 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 execd in
its own nono session, launches Claude under a second, sibling session, and
tears execd down when Claude exits.
How it works
Two sibling sessions, never nested. One wraps Claude Code under the agent
profile; the other runs agent-sandbox execd under the command profile.
Sandboxes cannot nest, which is why execd does not run inside the
agent's session.
There is no shell in the loop. Every command the agent issues reaches
execd over a unix socket, and execd parses the line itself with an
embedded interpreter — pipelines, &&/||/;, redirections, globbing,
$(…), for/if, cd and the other builtins all work — then calls
execve directly for each simple command. Handing the line to bash -c
instead cannot be made safe: a denial written for git reset --hard is
defeated by invoking git through its store path instead of by name.
Two tiers. Commands declared in the command profile get their own child
sandbox, reached only through the generated shim; everything else runs
directly at execd's own grants. A refusal from the first tier explains
itself — the denial carries the reason its profile entry wrote. A failure
at the second is plain execve permission, with no reason to give.
execd's own builtins are a third case: they run inside the execd process,
so a redirect or a glob you write is bounded by execd's grants, not by
the command it is attached to.
Neither profile is agent-sandbox's. It generates no profile at all. Both
files are yours; agent-sandbox resolves their paths and hands them to nono,
which is what decides every grant — see
nono's own documentation for the schema and
for nono profile show / nono why.
Commands
| Command |
What it does |
agent-sandbox init |
Download a minimal config, both profiles, and the profile skills into this project. Never overwrites |
agent-sandbox claude -- [claude args...] |
Launch Claude under nono, with execd running as a sibling session |
agent-sandbox exec -- <command> |
Send one command to execd and stream its output |
agent-sandbox doctor |
Check everything a launch depends on: the sandbox engine, the execd socket, both profiles and the paths they pin, and that the command profile does not leave execd's own binary writable. Exit 0 / 1 |
agent-sandbox debug -- [claude args...] |
Print the nono invocations for both sessions — 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 hook |
PreToolUse adapter, injected at launch and invoked by Claude, not by you |
Global flags: --config <path> (default agent-sandbox.toml) and --env <ref>
(repeatable). claude and debug also accept --context-mode. Only those may
appear before --; everything after -- goes to claude. --settings is
reserved — it carries the PreToolUse hook — and is rejected as a passthrough
option.
--context-mode selects the execd backend of
context-mode for the session, by
publishing CONTEXT_MODE_EXEC_BACKEND=execd to the agent. With it, the code
ctx_execute runs goes through execd and the command profile, like every other
command; without it, context-mode runs that code as a child of its MCP server,
inside the agent profile. The launch refuses to start unless Claude Code reports
an enabled context-mode plugin and the selection is measurably present inside
the sandbox — an unforwarded variable is not an error on context-mode's side, it
is a silent fall back to the agent profile. It requires
CONTEXT_MODE_EXEC_BACKEND in the agent profile's environment.allow_vars and
a node the agent profile can execute. agent-sandbox doctor reports the
plugin and the variable; node's reachability is what the launch probe
measures.
Configuration
command_profile = "command-profile.json" # default name; shared by every agent
[agents.claude]
profile = "claude-profile.json" # default name: "<agent>-profile.json"
Bash and Monitor stay enabled, and a PreToolUse hook injected at launch via
claude --settings rewrites each command to agent-sandbox exec -- <command>.
Nothing is written to .claude/settings.json, and agent-sandbox must be on
PATH. Before handing over control the launcher runs the hook once with a
probe payload and refuses to launch unless the command comes back rewritten —
Claude Code treats a hook that cannot start as a non-blocking error and runs
the command anyway, which would be a bypass rather than a degraded mode.
Profile paths resolve beside agent-sandbox.toml unless written absolute. An
optional ~/.config/agent-sandbox/config.toml is composed with the project
config, and the project file wins for anything it sets — so "every project has
a claude-profile.json beside its config" can be declared once, user-wide.
Both profiles are read once, at session start. Editing one takes effect at the
next agent-sandbox claude, never mid-session.
The agent's shell is a wrapper the launcher generates, not the host's bash.
Claude Code spawns tool commands with a socket on stdin, and non-interactive
bash reads a socket on stdin as an rshd/sshd session and sources ~/.bashrc —
a file no profile here grants, so without the wrapper every tool result is
prefixed with a permission error. The launcher writes norc-bash-<pid> beside
the execd socket (bash --norc --noprofile, nothing else), grants it with
--read-file, names it in CLAUDE_CODE_SHELL, and removes it when the session
ends. The agent profile's environment.allow_vars must list that variable or
nono strips it and Claude falls back to the host's bash; the session still
works, it just gets noisy, so agent-sandbox doctor measures it.
--env <ref> (only file: exists today) loads a dotenv-subset file into the
launcher's own process. It grants nothing. Only what a profile's
environment.allow_vars lists is forwarded, so a variable reaches the agent
only if the agent profile names it. Exposing one to a command execd runs is a
separate edit to the command profile — with one exception:
AGENT_SANDBOX_EXECD_SOCKET must never appear there, under any name or
wildcard. A command that can reach the execd socket can recurse into
execd, which spawns handlers with no concurrency cap.
Development
mise install # Go + lefthook
go test ./... # unit + integration tests
go build ./...
mise run build # install a working-tree build via `go install`
Use mise run build, not go build or go run ./cmd/agent-sandbox. Only go install
puts the binary outside this working tree and on PATH, which is where it
has to be for a launch to work.
templates/minimal/ holds what agent-sandbox init serves. It is fetched from
main at run time rather than embedded, so a change there reaches users
without a release — and go test ./... runs nono profile validate against
those files, which is the only thing guaranteeing main stays usable. Do not
confuse them with this repository's own command-profile.json and
claude-profile.json, which are grown for this host and are not portable.
Commits follow Conventional Commits;
lefthook validates the title on commit-msg.
License
MIT © Yuya Nagai