Metadata-Version: 2.5
Name: aisan
Version: 0.1.1
Summary: Linux confinement and credential-aware egress for build and coding agents
Project-URL: Homepage, https://github.com/schuay/aisan
Project-URL: Repository, https://github.com/schuay/aisan
Project-URL: Issues, https://github.com/schuay/aisan/issues
Author: Jakob Linke
License-Expression: MIT
License-File: LICENSE
Keywords: bubblewrap,egress,reapi,sandbox,security
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Requires-Python: >=3.12
Requires-Dist: aiohttp>=3.9
Requires-Dist: h2>=4.1
Provides-Extra: google-auth
Requires-Dist: google-auth[requests]>=2.0; extra == 'google-auth'
Description-Content-Type: text/markdown

# aisan

aisan runs a coding agent with everything in the box: the harness, its
state, and your repo. Nothing else. The box has no network route and
holds no credential. Model calls still work: each harness gets a host-side
proxy that checks requests against an allowlist and attaches the real
credential to traffic the box never sees.

```sh
python -m pip install aisan   # or: uv tool install aisan
aisan claude /path/to/repo
```

That is a normal interactive Claude Code session (`aisan codex` and
`aisan opencode` work the same way), with three differences:

- **Zero credentials in the box.** `~/.claude/.credentials.json` is never
  mounted. The token the client sees is a per-box placeholder; the proxy
  drops it and attaches the host's real credential: the subscription login
  by default, or a static API key with `--api-key`. A test asserts from
  inside a real box that the credential file does not exist.
- **Zero 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. `--net` opts 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 `/proc` and `/dev`), a
  tmpfs over `$HOME` and `/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.

The same three commands, run on the host and then from inside the box:

<p align="center">
  <img src="https://raw.githubusercontent.com/schuay/aisan/main/docs/demo.gif" width="660"
       alt="On the host, reading the credential file, listing ~/.ssh, and fetching https://example.com all succeed. Inside an aisan box with permissions bypassed, the agent runs the same three and each one fails: no credential file, no key directory, no DNS.">
</p>

## Nothing on faith: `--explain`

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:

<p align="center">
  <img src="https://raw.githubusercontent.com/schuay/aisan/main/docs/explain.svg" width="660"
       alt="aisan claude /path/to/repo --explain: resolved binds, egress backends, and environment">
</p>

<details>
<summary>Text version</summary>

```
$ 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-proxy-59d1d1bc/anthropic.sock

== tmpfs mounts (mounted before binds; intended writable scratch) ==
  [ 39] /tmp  (2147483648)
  [ 43] /home/user  (1073741824  <- $HOME)

== binds in argv order (later shadows earlier on overlap) ==
  system    /usr /bin /lib /lib64 /sbin /etc /proc /dev
  [ 45] rw-root   /path/to/repo
  [ 63] rw        /home/user/.cache/aisan-claude/aisan-4475d1c31168
  [ 66] ro        /home/user/.config/git/config
  [ 69] ro        /tmp/aisan-proxy-59d1d1bc

== 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
  ...
```
</details>

## User bind specs

Presets cover the harness; `--binds FILE` (repeatable, TOML) covers your
project. The keys are `ro`, `rw`, `overlay`, and `path` entries prepended to
the box PATH:

```toml
ro      = ["~/depot_tools"]
overlay = ["~/.cache/vpython-root.1000"]
path    = ["~/depot_tools"]
```

The `path` key grants nothing on its own: every entry must be covered by a
mount the same file names. `include` pulls in other spec files, expanded in
place and before the including file's own keys, so a growing collection
composes in an order the files state rather than one the command line
implies. [`examples/depot_tools.toml`](examples/depot_tools.toml) is a worked
example with the reasoning written down.

## As a library: unattended API jobs

The same mechanism drives headless workloads. A preset is a pure
`args -> BoxSpec` function; `Box` compiles the spec, starts the backends, and
returns argv. The Vertex backend mints short-lived tokens host-side through
ADC impersonation, so a batch job's box carries no Google credential either.
This package was extracted from an autonomous patch pipeline that runs
model-driven build/test jobs against V8 worktrees; that pipeline remains its
first consumer.

The REAPI transport is the largest specialized core component. Remote build
clients such as siso can speak plaintext HTTP/2 to a local endpoint while the
real bearer stays on the host. It checks `:authority` and `:path` together,
injects credentials per HTTP/2 stream, and refuses in gRPC's own terms so a
policy decision is not mistaken for a retryable network failure.

## Design rules

- **`BoxSpec`** is frozen, non-defaulting data. A reviewer can read a call site
  and see what is mounted without simulating default resolution. `Limits` is
  the exception: an unset resource cap is not an unstated mount.
- **One ordered bind list, later wins**, matching Bubblewrap's mount behavior.
  `Bind`, `Seal`, `Overlay`, and `BindOver` read top to bottom.
- **Credential-aware egress in both network modes.** Isolated boxes reach host
  proxies through Unix sockets and in-box loopback relays. Interactive boxes
  started with `--net` reach 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 name the policy reason rather than an internal callback.
- **Presets as pure `args -> BoxSpec` functions**, rather than project switches
  hidden inside the sandbox compiler.

The model- and client-neutral core is roughly 3,100 lines of Python. That count
covers `BoxSpec`, the sandbox compiler, git bind policy, lifecycle and launcher,
inspection, the backend interface, relay, fail-closed policy, and the REAPI
transport. Provider/client adapters, presets, interactive session launchers,
and MCP importers are integrations outside that core count. The number is an
audit bound, not a comparison with another project's total source size.

## Requirements

- 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 --user` is optional. Cgroup limits are skipped when the command
  is absent. On a host without a usable user manager, disable them explicitly
  with `Limits(use_cgroup=False)`.
- Interactive sessions require the corresponding host CLI (`claude`, `codex`,
  or `opencode`) 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-auth` extra 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.

## Installation

```sh
python -m pip install aisan
```

For Vertex credential minting:

```sh
python -m pip install 'aisan[google-auth]'
```

This installs one human-facing command with inspection and interactive
subcommands:

```sh
aisan explain --help
aisan claude /path/to/repo
aisan codex /path/to/repo
aisan opencode /path/to/repo
aisan codex /path/to/repo --net
```

Launcher options come before a literal `--`; arguments after it are passed to
the underlying client unchanged.

`--grant NAME` adds a named grant: the mounts, PATH entries and environment
some tree needs inside a box with no network route.

```sh
aisan claude /path/to/v8 --grant depot_tools
```

Today the one grant is `depot_tools`, which supplies the checkout found through
`autoninja` on your PATH, vpython's venv store as an overlay, and the two
variables that stop depot_tools reaching for a network it has not got --
without the first of them `gclient` exits 255 on a `git fetch` it cannot make,
which reads as a broken checkout. The environment is why this is a grant rather
than a bind spec: a `--binds` file names paths, and the value that turns off an
auto-update is not one. Grants are applied before `--binds`, so a user file
still shadows them, and `--explain` renders the result. The name is not
tool-specific on purpose -- a CA bundle and the variable naming it, or a device
node and the library path that finds it, are the same shape.

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.

### Local checks

Prepare the development environment while network access is available:

```sh
uv sync
```

Enable the repository's offline pre-commit checks with:

```sh
git config core.hooksPath .githooks
```

The 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.

## Plugin commands

Out-of-tree commands register in the `aisan.commands` entry point group:

```toml
[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:

```sh
uv tool install aisan --with git+ssh://example.com/aisan-corp
aisan jetski /path/to/repo
```

The 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.

Four rules the dispatcher enforces:

- **Built-ins are not overridable.** Installing a plugin installs its whole
  dependency closure, and any distribution in it can register in this group
  though only the plugin was trusted. A claim on `claude`, `codex`, `opencode`
  or `explain` is 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 claude`
  scans no metadata and imports no plugin.
- **A broken plugin is not a broken aisan.** The import happens on the path
  that asked for it; a failure names the plugin and leaves every other command
  working.
- **Duplicate names resolve by sorting**, not by `sys.path` order, and the
  plugin that loses is named.

Plugins get no separate audit path: `--explain` belongs to the launcher, so a
plugin that builds a box should accept it and print the resolved profile the
same way the built-in launchers do. Note also that aisan binds its own venv
read-only into every box with egress, so a plugin installed beside it is
readable from inside the box.

## Relationship to sandbox-runtime

[Anthropic's sandbox-runtime](https://github.com/anthropic-experimental/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.

## Status

Pre-1.0. Treat the API as unstable.

## License

MIT (see [LICENSE](LICENSE)).
