aisan runs a coding agent, its state, and your repository inside a box. The box has no network route or credentials. Model calls pass through a host-side proxy that checks each request and adds the real credential.
python -m pip install aisan # or: uv tool install aisan
aisan claude /path/to/repoThe command starts an interactive Claude Code session. aisan codex and
aisan opencode provide the same confinement:
- No credentials in the box.
~/.claude/.credentials.jsonis never mounted. The client sends a per-box placeholder, and the proxy replaces it with the host subscription login or the static API key selected by--api-key. - No network by default. The box gets its own network namespace with no
route off the machine. The one egress is a loopback relay to the model
proxy over a Unix socket.
--netopts back into host networking when a task needs it; credential files stay unmounted and model calls still pass through the authenticated proxy. - Selected filesystem slices. The repo is bound rw at its real absolute
path, system directories ro (
/usr,/etc; fresh/procand/dev), a tmpfs over$HOMEand/tmp, and nothing else unless a bind spec names it. Local stdio MCP servers declared on the host are started inside the box, where they inherit its filesystem, cleared environment, and network namespace; remote MCP declarations and their authentication state stay on the host. Only servers a bind spec names are started at all.
The same three commands, run on the host and then from inside the box:
Every launcher takes --explain: it prints the resolved profile and the
exact Bubblewrap argv from the same Box object used to launch, then exits.
Trimmed:
Text version
$ aisan claude /path/to/repo --explain
== inputs ==
harness claude-code
repo /path/to/repo
network own namespace (no route off the machine)
== egress backends (host half on a socket, in-box on loopback) ==
anthropic 127.0.0.1:8713 -> /tmp/aisan-1000/proxy-59d1d1bc/anthropic.sock
== tmpfs mounts (intended writable scratch; binds below land on top) ==
[ 39] /tmp (2147483648)
[ 43] /home/user (1073741824 <- $HOME)
[ 45] /tmp/aisan-1000 (no size)
== binds by destination (deeper wins; * marks a guard: nothing plain below) ==
system /usr /bin /lib /lib64 /sbin /etc /proc /dev
[ 63] rw * /home/user/.cache/aisan-claude/aisan-4475d1c31168
[ 66] ro * /home/user/.config/git/config
[ 69] rw-root /path/to/repo
[ 72] ro * /tmp/aisan-1000/proxy-59d1d1bc
[ 75] seal-ro * /tmp/aisan-1000
== environment (the box's complete environment; --clearenv first) ==
CLAUDE_CONFIG_DIR=/path/to/repo/.aisan-claude-state
GIT_PAGER=cat
HOME=/home/user
PATH=/usr/bin
...
Presets cover the harness; --binds FILE (repeatable, TOML) covers your
project. The keys are ro, rw, overlay, path entries prepended to the
box PATH, and mcp:
ro = ["~/depot_tools"]
overlay = ["~/.cache/vpython-root.1000"]
path = ["~/depot_tools"]
mcp = ["v8-mcp"]The path key grants no filesystem access. Each entry must be covered by a
mount in the same file. include expands other spec files in place before the
including file's keys, which makes precedence explicit.
examples/depot_tools.toml shows a complete file.
mcp names the host MCP servers this box may start, by server name, by the
command, or by the command's full path; ["*"] admits every local stdio
declaration. A command admits every declaration that runs it, so name a
server launched through npx or uvx by its server name. A box whose specs name none starts none, and the launcher says
which it withheld. A host client config is one list shared by every box: an
unattended box should not gain a channel to the outside because a server was
added for interactive work.
examples/mcp.toml shows the interactive-only split.
The same mechanism supports headless workloads. A preset maps arguments to a
BoxSpec; Box compiles the spec, starts its backends, and returns the command
arguments. The Vertex backend uses ADC impersonation to mint short-lived tokens
on the host, so batch boxes don't receive Google credentials.
Remote build clients such as Siso use plaintext HTTP/2 to a local REAPI
endpoint while the bearer stays on the host. The proxy checks :authority and
:path, adds credentials per stream, and returns policy refusals as gRPC
errors.
BoxSpecis frozen, non-defaulting data. Call sites state every mount.Limitsmay leave resource caps unset.- Mounts form a tree keyed by destination. Order in a
BoxSpeccarries no meaning: an ancestor is mounted before its descendants, so the deeper entry wins. Two entries at one path must agree, or be identity binds, where the stricter mode wins. A guard (the default forBindandOverlay; always forSealandBindOver) admits only other guards below it, so a user bind cannot reopen part of a policy mount. User bind files andextra_roare plain. The fixed system surface (/usr,/etc,/proc,/dev, the journal socket) is in the tree too: a bind at one of its paths conflicts, and a bind above one is refused because bwrap mounts the surface first. - Credential-aware egress in both network modes. Isolated boxes reach host
proxies through Unix sockets and in-box loopback relays. Interactive boxes
started with
--netreach authenticated host-loopback TCP listeners directly; a private runtime file supplies the per-session proxy token without placing it in process arguments. - Fail-closed request policy. A policy exception denies the request. Refusal messages state the policy reason and omit internal callback names.
- Presets are pure functions from arguments to
BoxSpecvalues.
The model-neutral core covers BoxSpec, sandbox compilation, Git bind policy,
lifecycle, inspection, backend interfaces, relays, request policy, and REAPI.
Provider adapters, client presets, interactive launchers, and MCP importers are
separate integrations.
- Linux, Python 3.12 or newer, and
bubblewrap(bwrap). User namespaces must be available to the invoking user; some distributions restrict unprivileged user namespaces by default. systemd-run --useris optional. Cgroup limits are skipped when the command is absent. On a host without a usable user manager, disable them explicitly withLimits(use_cgroup=False).- Interactive sessions require the corresponding host CLI (
claude,codex, oropencode) to be installed and already logged in. - RBE/V8 use additionally requires the relevant siso/depot_tools environment
and
luci-auth. - Vertex credential minting requires the
google-authextra and Application Default Credentials.
Boxes have no general network access by default. Interactive claude, codex,
and opencode sessions accept --net before the literal -- to share the
host network namespace. That exposes the internet, LAN/VPN routes, and
host-local services in both directions; configured model credential files stay
unmounted and model calls still pass through authenticated host proxies.
python -m pip install aisanFor Vertex credential minting:
python -m pip install 'aisan[google-auth]'This installs one human-facing command with inspection and interactive subcommands:
aisan explain --help
aisan claude /path/to/repo
aisan codex /path/to/repo
aisan opencode /path/to/repo
aisan codex /path/to/repo --netLauncher options come before a literal --; arguments after it are passed to
the underlying client unchanged.
Codex uses its built-in OpenAI provider through the host proxy, including model
discovery and Daybreak controls for entitled accounts. The launcher imports the
host's boolean daybreak preference alongside model and reasoning choices.
Responses use inspected HTTP JSON/SSE; WebSocket attempts receive HTTP 426, and
request compression is disabled. Context compaction runs through /responses.
OpenCode selects model from the host config, then the most recent model in
$XDG_STATE_HOME/opencode/model.json (default ~/.local/state/opencode).
--provider and --model override AISAN_OPENCODE_PROVIDER and
AISAN_OPENCODE_MODEL, which override that preference. With no preference,
the launcher requires an explicit selection. All launches use a private server
(--standalone) so they cannot collide with the host background service.
Custom providers use the selected host opencode.json or .jsonc:
aisan opencode /path/to/repo --provider local --model example-model --no-upstream-authThe tested custom-provider client is OpenCode v2.0.29. A legacy provider
definition must use npm: "@ai-sdk/openai-compatible", an HTTP(S)
options.baseURL, and an explicit selected model, for example:
{
"provider": {
"local": {
"npm": "@ai-sdk/openai-compatible",
"options": {"baseURL": "http://inference.example:8080/v1"},
"models": {
"example-model": {
"name": "Example Model",
"limit": {"context": 16384, "output": 2048}
}
}
}
}
}Set budgets for the server's loaded context allocation. Architectural context
capacity can be larger; aisan neither probes nor changes the server allocation.
An optional model id maps the selected key to an upstream model name. Only
text input/output and client-side tools are supported. Native V2 providers
import and other SDKs are unsupported.
OPENCODE_CONFIG selects a host file; otherwise aisan chooses opencode.json
before opencode.jsonc under $XDG_CONFIG_HOME/opencode (default
~/.config/opencode). It does not merge repository or inline host config into
upstream routing. --upstream, then AISAN_OPENCODE_UPSTREAM, overrides the
host options.baseURL; the fetched catalog supplies the final fallback.
Interpolation, URL userinfo, queries, and fragments are refused. Host keys,
headers, and arbitrary request options are never imported.
--no-upstream-auth skips host credential reads and sends no Authorization
header upstream. The default still requires a static key in host auth.json.
Shared networking (--net) always authenticates clients to the proxy, but also
allows direct host/LAN connections. Isolated networking confines model traffic
to the proxy. Launches use --standalone --auto; custom providers select the same
model for title generation, which can add a concurrent request.
aisan opencode ... --explain reads config/catalog files and reports the route
origin, source, model budgets, ignored fields, and auth mode. It never reads
auth.json, contacts the upstream, or starts OpenCode. Backend construction and
client_env() remain file-free for programmatic callers.
The optional external-server test requires AISAN_TEST_OLLAMA_URL,
AISAN_TEST_OLLAMA_MODEL, AISAN_TEST_OLLAMA_CONTEXT, and
AISAN_TEST_OLLAMA_OUTPUT. Set them for your deployment; the repository's
examples and fake-server tests use invented values.
--grant NAME adds a named grant: the mounts, PATH entries and environment
some tree needs inside a box with no network route.
aisan claude /path/to/v8 --grant depot_toolsThe depot_tools grant finds the checkout through autoninja, overlays the
vpython cache, and disables network-dependent updates and presubmit checks.
These environment settings require a grant because bind files contain paths
only. Grants apply before --binds, so user files retain precedence.
Runtime dependencies are limited to aiohttp and h2. The Google credential
chain is optional. A boundary test walks the package AST and fails when a module
imports an undeclared third-party dependency.
Prepare the development environment while network access is available:
uv syncEnable the repository's offline pre-commit checks with:
git config core.hooksPath .githooksThe hook runs staged-file checks with uv run --offline --no-sync: committing
does not resolve, install, update, or download dependencies. The checks do not
rewrite files; run Ruff or scripts/add-license-headers.py explicitly to apply
a reported fix.
Out-of-tree commands register in the aisan.commands entry point group:
[project.entry-points."aisan.commands"]
jetski = "aisan_corp.cli.jetski:main"Installed alongside aisan, they are dispatched by name and need no wrapper binary of their own:
uv tool install aisan --with git+ssh://example.com/aisan-corp
aisan jetski /path/to/repoThe contract is the one the built-in launchers already follow: a callable
taking the tokens after the command name and returning an exit status, with
LaunchRefused handled by the dispatcher. Everything else a plugin imports
from aisan is internal and may change between versions.
The dispatcher enforces four rules:
- Plugins can't override built-ins. Claims on
claude,codex,opencode, orexplainare refused and reported. - Discovery costs nothing on the built-in path. The group is read only when
the first token names no built-in, and when help is printed.
aisan claudescans no metadata and imports no plugin. - Plugin failures are isolated. A failed import names the plugin and leaves other commands working.
- Duplicate names resolve by provider name. The losing plugin is reported.
Plugins that build boxes should support --explain and print the resolved
profile like built-in launchers. Aisan's read-only runtime binds also make
plugins installed in the same environment readable inside boxes with egress.
Anthropic's sandbox-runtime is the broader cross-platform tool for a general confined coding agent; aisan is Linux-only and concentrates on whole-harness confinement, explicit mount composition, and credential-aware transports such as the plaintext HTTP/2 REAPI proxy.
Pre-1.0. Treat the API as unstable.
MIT (see LICENSE).
