![Spens logo](logo.png)

# Spens

Spens is a CLI toolchain for running AI coding agents inside sandboxed Docker containers with full network interception and audit control. It captures every LLM API call, tool invocation, and filesystem operation so you can review exactly what an agent did after (or during) a session.

Note: Spens is not designed to be 100% secure, but secure enough for most workloads.

## Why Spens Exists

If you work across many codebases and experiment with multiple agents and LLMs, you need a way to run them without polluting your machine, creating conflicts or having an agent blow away your home directory. Spens combines three tools to create reproducible, observable agent runtimes:

- **[Docker](https://docker.com)** - isolated runtime environments
- **[nono.sh](https://nono.sh/)** - sandboxed shell with filesystem access control and audit logging
- **[mitmproxy](https://www.mitmproxy.org/)** - SSL intercepting proxy that captures and decodes LLM API traffic

## Core Features

1. Run any agent against your code, in a contained, ephemerial way
2. Create full logs of what LLM/Agents did , including network traffic, tool calls, file changes and system access. 
3. Provide a lightweight security barier to prevent accidential user profile deletion
4. Run agents in yolo mode, with more confidence 

## Quick Start

### Prerequisites

- Python 3.11+
- Docker
- Orbstack (if on Mac)

### Install

```bash
pip install .
```

### Run an agent

```bash
spens node-20 codex .
```

This runs the **codex** agent inside a **Node 20** environment, mounting the current directory as the workspace.

### View session logs

```bash
spens log-viewer
```

Opens a local web UI at `http://127.0.0.1:7331` showing chat transcripts, audit logs, HTTP traffic, and file changes for every session.

## Usage

```
spens <environment> <agent> <workspace> [prompt]   Run an agent in an environment (optionally in yolo mode)
spens list                                          List available environments and agents
spens log-viewer [--port PORT] [--no-browser]       Launch the session log viewer
spens pricing [--refresh] [--model ID]              Inspect or refresh the model pricing data
spens <environment> <agent> <workspace> --rebuild   Rebuild images from scratch
```

### Custom session-state directory

By default spens stores session state (traces, audit data, summaries) in
`<workspace>/.spens`. Pass `--spens-dir` to keep it somewhere else — for
example outside the workspace entirely:

```bash
spens node-20 codex . --spens-dir ~/spens-state/myproject
spens log-viewer --spens-dir ~/spens-state/myproject
```

If the directory lives inside the workspace it is still hidden from the agent
inside the container, just like the default `.spens`.

### Yolo mode

Append a prompt as the final argument to run an agent non-interactively ("yolo" mode) — the agent executes the prompt and exits, with auto-approval configured per agent:

```bash
spens node-20 opencode . "add a docstring to every public function in src/"
```

Each agent template defines its own `yolo_command` (see below) with a `{prompt}` placeholder that spens fills in. If an agent has no `yolo_command`, passing a prompt is an error.

### Non-interactive mode (`--output`, `--accept-changes`, `--reject-changes`)

Every session — in every mode — writes a machine-readable event stream to
`<session_dir>/events.jsonl` and its current state to `<session_dir>/state.json`.
The `--output` flag only controls what is rendered to the CLI's stdout:

```
spens <env> <agent> <workspace> "prompt" --output jsonl     \
    (--accept-changes | --reject-changes) [--session-id ID]
spens <env> <agent> <workspace> "prompt" --output background \
    (--accept-changes | --reject-changes) [--session-id ID]
```

- `--output tty` (default) — pretty-printed `[spens] ...` output rendered
  with [rich](https://github.com/Textualize/rich): the `[spens]` token is
  highlighted on terminals, docker build output is streamed into a
  constrained live panel instead of flooding the terminal, and the session
  ends with a recap panel (tokens, cost, models, file changes). Piped
  output stays plain and verbatim.
- `--output jsonl` — the raw event stream, one JSON object per line, flushed
  per event (agent container output included as `agent_output` events; the
  terminal `finished` event carries the full session summary as a JSON
  dict in `data.summary`).
- `--output background` — prints the session id and exits 0 immediately; the
  session runs detached and its output lives entirely in the session's
  `events.jsonl`. Follow it with `spens attach`, query it with
  `spens status`, stop it with `spens cancel`.

`jsonl` and `background` modes require a prompt (yolo mode) and exactly one
of `--accept-changes` / `--reject-changes` — without an explicit decision the
nono save/rollback prompt baked into the agent entrypoint would block a
non-interactive session forever. `--reject-changes` runs the agent unattended
and reverts the workspace to its pre-session state (inside the container,
after the agent exits) via nono rollback.

`--session-id` replaces the random session id everywhere it appears (the
`.spens/sessions` folder, Docker object names). It must match
`^[a-z0-9][a-z0-9-]{0,31}$`, and reusing an id whose session directory already
exists is an error.

### Session lifecycle commands

```
spens status <session-id> [--spens-dir PATH]   # → {"state": ..., "exit_code": ...}
spens cancel <session-id>  [--spens-dir PATH]  # docker stop + mark state canceled
spens attach <session-id>  [--spens-dir PATH]  # follow events.jsonl until terminal
```

These are thin readers/writers over `state.json` + `events.jsonl` + docker and
work for every spens session regardless of output mode. Terminal states are
`finished`, `canceled` and `error`; the first writer of a terminal state wins,
so an external `spens cancel` can never be overwritten by the running session.

### Examples

```bash
# List all available environments and agents
spens list

# Run opencode in a Python 3.12 environment on the current directory
spens python-3.12 opencode .

# Run claude in Node 22 on a specific workspace
spens node-22 claude /path/to/project

# Run codex in yolo mode: execute a prompt non-interactively and exit
spens node-24 codex . "fix the failing tests in tests/test_parser.py"

# Rebuild Docker images without cache
spens node-24 codex . --rebuild

# Start log viewer on a custom port without opening a browser
spens log-viewer --port 8080 --no-browser

# Keep session state outside the workspace
spens node-24 codex . --spens-dir ~/.spens-state/myproject
```

## Environments

Environments define the base Docker image, system packages, and nono.sh installation. Built-in environments live in `spens/data/templates/environments/`:

| Environment | Base Image |
|---|---|
| `node-20` | `node:20.18.0-bookworm` |
| `node-22` | `node:22-bookworm` |
| `node-24` | `node:24-bookworm` |
| `python-3.11` | `python:3.11-bookworm` |
| `python-3.12` | `python:3.12-bookworm` |
| `python-3.13` | `python:3.13-bookworm` |

## Agents

Agents define how to install and configure a specific AI coding tool. Built-in agents live in `spens/data/templates/agents/`:

| Agent | Binary | Notes |
|---|---|---|
| `codex` | OpenAI Codex | Forced to HTTP-only mode via `config.toml` to ensure traffic is interceptable |
| `claude` | Claude Code | Mounts settings (incl. API key config), OAuth credentials, plugins, and user customizations from host; files under `<spens_dir>/.claude` override the global `~/.claude` ones |
| `opencode` | opencode | Mounts `~/.config/opencode/config.json` and `~/.local/state/opencode/models.json` from host; files under `<spens_dir>/.opencode` override them |
| `pi` | pi.dev | Mounts `~/.pi/agent/settings.json` and `models.json` from host; files under `<spens_dir>/.pi/agent` override them |

## Configuration

### `.spens.config.json`

An optional file placed in the workspace root to customize session behavior:

```json
{
  "nono_override": "profile_override.json",
  "addition_capture_urls": ["*opencode.ai*", "*api.fireworks.ai*"],
  "env": [],
  "pre_sandbox_commands": ["npm install"],
  "domain_rules": [
    {"pattern": "*api.github.com*", "allow": ["GET"]},
    {"pattern": "*pypi.org*", "allow": ["*"]}
  ],
  "inject_headers": [
    {
      "placeholder": "ANTHROPIC_API_KEY",
      "env_var": "ANTHROPIC_KEY",
      "for_domains": ["*api.anthropic.com*"]
    }
  ]
}
```

| Field | Description |
|---|---|
| `nono_override` | Path to a custom nono profile JSON file, overriding the auto-generated combined profile |
| `addition_capture_urls` | URL patterns to additionally capture in the interceptor (glob-style) |
| `env` | Environment variable names to forward from the host to the agent container with their real values (use for non-secret vars) |
| `egress` | `"enforced"` (default) or `"legacy"`. Enforced: the agent runs on an isolated internal Docker network whose only neighbor is the interceptor, so all egress (including DNS) is forced through it and non-proxied protocols fail closed. Legacy: the agent shares the interceptor's network namespace and proxy env vars are advisory only (not recommended — kept for migration) |
| `pre_sandbox_commands` | Shell commands to run inside the container before the nono sandbox starts the agent (e.g. `npm install`). These execute with full container privileges and route network traffic through the interceptor |
| `domain_rules` | Per-domain method restrictions. Each rule has a glob `pattern` (matched against the request **hostname** only) and an `allow` list of HTTP methods (or `["*"]` for all). The policy **fails closed**: a request whose hostname matches no rule is blocked — and the same applies to proxy `CONNECT` targets (an HTTPS tunnel to a host matching no rule is refused before it is established; the methods of requests sent *through* an allowed tunnel are still checked against the rule). When `domain_rules` is omitted entirely, no policy is enforced and all domains are allowed |
| `pricing` | Costing options. `{"live_fetch": false}` stops spens fetching the pricing dataset at the end of a session; prices then come from the vendored snapshot and the built-in table (see [Pricing data](#pricing-data)) |
| `inject_headers` | Secret substitution rules. Each entry has a `placeholder` (the literal string the agent is given as its "key"), an `env_var` (the host environment variable holding the real secret), and a `for_domains` list of glob patterns (e.g. `["*api.anthropic.com*"]`) scoping which URLs the secret may be substituted on. The agent container receives the placeholder as its env var value; the interceptor replaces the placeholder with the real value only when it appears in a request whose **hostname** matches a `for_domains` pattern, so the real secret never enters the agent — and can never be attached to a request to an unauthorized destination. A rule without `for_domains` (or with an empty list) authorizes no URLs, so its placeholder is never substituted. Any real secret value is masked (`***REDACTED***`) out of URLs before they are written to the request log or trace files, so a secret substituted into a query parameter never lands on disk |

An example lives in `config_example/.spens.config.json`.

### `spens init`

Instead of writing the config by hand, `spens init` generates one from a short questionnaire (stack, public GET access, tooling URLs, model provider(s), provider env vars) and makes sure `.spens` is ignored in `.gitignore`:

```bash
spens init
```

It can also run fully non-interactive. With `--yes` every unanswered question takes its default (public GET denied, tooling URLs allowed, provider env vars allowed) — stack and provider are required:

```bash
spens init --yes --stack node --provider openai,fireworks
```

Individual questions can also be answered with flags (`--stack`, `--provider`, `--public-get/--no-public-get`, `--tooling-urls/--no-tooling-urls`, `--inject-env/--no-inject-env`); anything left unanswered is asked interactively. Use `--dry-run` to preview the generated config on stdout, `--output <path>` to write somewhere other than `.spens.config.json`, and `--force` to overwrite an existing config without the confirmation prompt.

## Pricing data

Session costs are priced from the [Portkey pricing dataset](https://configs.portkey.ai/pricing/anthropic.json)
for six providers: `anthropic`, `openai`, `google`, `fireworks`, `openrouter`
and `bedrock`. A model listed by more than one of them (Claude is sold by
Anthropic, OpenRouter and Bedrock alike) is priced from the first in that
order, so a direct provider's rate wins over a reseller's.

For each model, the price is resolved from the first source that lists it:

1. **live** — fetched from `configs.portkey.ai` and cached under
   `~/.cache/spens/pricing` for 24h (`SPENS_PRICING_TTL` seconds);
2. **vendored** — the snapshots in `spens/data/pricing`, used when the fetch
   fails, the host is offline, or live fetching is off;
3. the built-in `MODEL_PRICING` table in `spens/summarizer.py`, which covers
   models the dataset has not caught up with;
4. nothing — an unpriced model is costed at **$0** rather than guessed.

Each session summary records which of those each provider's numbers came
from (`llm.pricing.providers`), so an archived cost figure can still be
explained later.

Beyond input/output rates, the dataset gives cached tokens their own price
where the provider publishes one, and prices some models (Gemini) in context
bands — spens picks the band from each call's own context size (fresh input
plus the cached prefix it read back). Where a cache rate is missing it falls
back to estimating from the input rate. Batch rates are ignored: a captured
trace does not say whether a call was billed at the batch rate.

```bash
spens pricing                              # where each provider's prices are coming from
spens pricing --refresh                    # re-fetch into the local cache
spens pricing --vendor                     # re-fetch and update the shipped snapshots
spens pricing --model claude-sonnet-4      # resolved price for one model id
spens pricing --model gemini-2.5-pro --tokens 400000
```

Fetching happens on the **host**, after the agent container has exited — it
is not agent traffic and does not pass through the interceptor. To keep
costing entirely offline, set `SPENS_PRICING_OFFLINE=1` or put
`"pricing": {"live_fetch": false}` in `.spens.config.json`.

## Log Viewer

The built-in log viewer (`spens log-viewer`) serves a web UI with four tabs:

- **Chat Transcript** - Decoded LLM API calls showing request messages, response content, tool calls, reasoning, and token usage. Supports OpenAI Chat Completions, OpenAI Responses API (used by Codex), and Anthropic Messages API.
- **Audit Log** - Session metadata, executable identity (path + SHA-256), tracked filesystem paths, audit events, integrity chain (Merkle root), and the full ledger.
- **Other HTTP** - Non-LLM HTTP requests and responses captured by the proxy.
- **File Changes** - Filesystem changes the agent made during the session, with a summary and per-file details for rollback.

## Custom Templates

Templates can be overridden or added by placing JSON files in a `templates/` directory inside your workspace. Local templates take precedence over built-in ones.

You can also pass a `.json` file directly as the environment or agent argument to load it as a local file instead of a built-in template:

```bash
spens my-env.json my-agent.json .
```

The path is resolved relative to the current directory or the workspace.

### Environment template

```json
{
  "template_type": "environment",
  "name": "my-env",
  "base-image": "node:20-bookworm",
  "nono-command": "curl -fsSL https://nono.sh/install.sh | sh",
  "packages": ["curl", "ca-certificates", "git"],
  "nono_base_config": "intentionally-left-nil/npm",
  "package_manager": "apt"
}
```

### Agent template

```json
{
  "template_type": "agent",
  "name": "my-agent",
  "installation_command": "curl -fsSL https://example.com/install | sh",
  "dependencies": [],
  "nono_base_config": "nolabs-ai/my-agent",
  "relocate_binary": {
    "from": "~/.local/bin/my-agent",
    "to": "/usr/local/bin/my-agent",
    "cleanup": ["~/.my-agent", "~/.local/bin/my-agent"]
  },
  "config_files": {
    "~/.my-agent/config.toml": "setting = true\n"
  },
  "configuration": [
    {
      "source": "~/.config/my-agent",
      "destination": "~/.config/my-agent",
      "include": ["config.json", "*.toml"],
      "exclude": ["secrets.*"]
    }
  ],
  "allow_folders": ["~/.cache/my-agent"],
  "env": ["MY_AGENT_CERT=/certs/mitmproxy-ca-cert.pem"],
  "packages": ["ripgrep"],
  "yolo_command": "my-agent run --yes {prompt}"
}
```
#### Environment template fields
| Field | Description |
|---|---|
| `name` | name of the environment |
| `base-image` | base-image to use |
| `nono-command` | command to install nono shell in that image |
| `packages` | suplemental packages to install |
| `package_manager` | name of package manager |
| `nono_base_config` | what base config to use for nono if user does not suply one |
| `agent_user` | The unprivileged user the agent runs as: `{"name", "uid", "home", "create"}`. Defaults to a created `spens` user (uid 1000, home `/home/spens`); the node environments reuse the image's existing `node` user. Paths in agent templates starting with `~` (or legacy `/root`) are expanded to this user's home |


#### Agent template fields

| Field | Description |
|---|---|
| `name` | name of the agent |
| `installation_command` | Shell command to install the agent binary (run as the unprivileged agent user, installing into its home) |
| `dependencies` | List of shell commands to run before installation (e.g. installing prerequisites). Run as root during build, with `HOME` pinned to the agent user's home |
| `nono_base_config` | nono.sh base profile to pull and extend |
| `relocate_binary` | Move the installed binary to a standard location and clean up install artifacts |
| `config_files` | Static files to write into the image at build time (map of container path to file content) |
| `configuration` | Ordered list of host config mounts: each entry binds a `source` dir to a `destination`, mounting files matching `include` (all files if omitted) minus `exclude`; only existing sources/files are mounted. When multiple entries target the same container file, the entry listed **first** wins and later duplicates are skipped (a container path is never mounted twice). `source` accepts the `{workspace}` token (expands to the resolved `--workspace` directory) and the `{spens_dir}` token (expands to the session's spens directory: `--spens-dir` when given, else `<workspace>/.spens`); plain relative sources resolve against the workspace. `destination` `~` paths expand to the agent user's home. The built-in agent templates use this to let `<spens_dir>/<agent>` files (e.g. `.spens/.pi/agent/settings.json`) override the global `~/.pi/agent` ones |
| `allow_folders` | Directories the nono shell should allow the agent to access |
| `env` | Environment variables to set in the container (`KEY=value` format) |
| `packages` | Additional system packages to install in the image |
| `yolo_command` | Command template (with a `{prompt}` placeholder) used to run the agent non-interactively in yolo mode when a prompt is passed on the CLI |

Built-in `yolo_command` values:

| Agent | Yolo command |
|---|---|
| `opencode` | `opencode run --auto {prompt}` |
| `codex` | `codex exec --sandbox danger-full-access {prompt}` |
| `claude` | `claude -p {prompt} --dangerously-skip-permissions` |
| `pi` | `pi -p {prompt}` |

The `{prompt}` placeholder is replaced at runtime with the prompt passed on the CLI (delivered to the container via the `SPENS_YOLO_PROMPT` environment variable), so the agent image is not rebuilt per prompt.

## Security Posture
Spens is a 'good enough' security posture. It is not a 100% fortified sandbox but generally works for most agent workflows. 
Its primary security is derivsed from a few key features

1. code is ran in docker , reducing impact to the system host
2. the agent runs non root and controlled by nono.sh 
3. networking is forced through an independent proxy with restirctions, key injection and logging 

### Non-root agent execution

The agent never runs as root inside its container. It runs as an
unprivileged user (a `spens` user created at build time on the Python
environments, or the image's existing `node` user on the Node environments,
both uid 1000), wrapped in the nono sandbox on top. Because bind-mount
permissions are enforced by numeric uid, the agent can only write to the
mounted workspace if the host directory is owned by (or writable by) uid
1000 — the default for the first regular user account on most Linux/macOS
systems. If your workspace is owned by a different uid, either chown it or
override `agent_user` (name/uid/home) in a custom environment template.

### Network & Egress Policy

By default (`"egress": "enforced"`), each session creates two Docker networks:

- an **internal** network (`docker network create --internal`) holding the agent and the interceptor — Docker drops all routed egress from it, so the agent's only reachable neighbor is the interceptor
- an **egress** network giving the interceptor (and only the interceptor) internet access

Every byte leaving the agent therefore flows through mitmproxy, where domain rules, secret substitution and trace capture apply. Clients that ignore or unset proxy environment variables, raw sockets, and non-HTTP protocols such as `git`-over-SSH simply have **no route** and fail closed. The proxy itself is not an open tunnel either: `CONNECT` targets are domain-checked (same fail-closed policy as requests) before a tunnel is established, and mitmproxy runs with raw TCP forwarding (`rawtcp`) disabled, so non-HTTP protocols tunneled through `CONNECT` (SSH on any port, or any other non-HTTP protocol) fail closed inside the proxy instead of being blindly forwarded. Every `CONNECT` decision (allowed or denied) is recorded in `traces/request_log.jsonl`. The agent's `/etc/resolv.conf` points at a logging DNS forwarder in the interceptor, so name resolution is captured to `traces/dns_log.jsonl` too.

Consequences of enforcement:

- use `https://` git remotes — SSH (port 22) is blocked
- `ping` does not work (the agent container runs without `NET_RAW`)

This is all implemented with standard Docker networking, so it behaves identically on Linux hosts, Docker Desktop on Windows, and OrbStack on macOS.

### Config & policy inputs are masked from the agent

The workspace is bind-mounted **read-write** into the agent, and it is also where spens reads its own trust-sensitive inputs on the next run: `.spens.config.json`, the `templates/` override directory, and the `nono_override` profile it references. Left visible and writable, an untrusted agent — or a cloned malicious repo — could rewrite the policy the *next* session honors: downgrade `egress` to `legacy`, clear the domain rules, add `pre_sandbox_commands`, define an environment template with `agent_user` uid 0, or an agent template whose `configuration` block mounts `~/.ssh` / `~/.aws` from the host.

By the time the agent starts, spens has already consumed those inputs (baked into the built image, the generated nono profile and the interceptor config), so the agent never needs to see them. Exactly like the session's `.spens` directory, each input is overlaid with a read-only empty mask inside the agent container — `/dev/null` for the config/override files, an empty hardened (`noexec,nosuid,nodev`) tmpfs for `templates/`. The host copies are untouched; the agent can neither read them nor persist a poisoned policy for a later run (anything it writes to a masked path is ephemeral and discarded when the container exits).

This blocks an agent from *tampering* with the policy across sessions. It does **not** vet a config that is malicious the first time you run against an untrusted repo — review `.spens.config.json`, `templates/` and any `nono_override` in a repo you don't trust before running spens on it.

## Nono Shell Isolation 
The agent runs inside a nono.sh configured based on the requriements of the project / environment. This adds further restrictions around what the agent can and cant do, and provides as a layer to remove access to senstive system internals.

## Project Structure

```
spens/
  cli.py              CLI entry point
  config.py           .spens.config.json loader
  templates.py        Environment and agent template loader
  builder.py          Dockerfile, nono profile, and entrypoint generation
  runner.py           Session orchestration (interceptor + agent containers)
  viewer.py           Log viewer web server and trace parser
  summarizer.py       Session summary stats and cost estimation
  ui.py               rich-based terminal rendering (build panel, recap panel)
  pricing.py          Portkey pricing dataset (fetch, cache, model lookup)
  data/
    mitmproxy_addon.py   mitmproxy addon (LLM capture, request log, header injection, domain filtering)
    dns_forwarder.py     logging DNS forwarder (the agent's resolv.conf points here)
    pricing/             Vendored pricing snapshots (offline fallback)
    templates/
      environments/     Built-in environment templates
      agents/           Built-in agent templates
```

## Tech

- **Python** - spens tool itself
- **[nono.sh](https://nono.sh/)** - sandboxed shell with audit logging
- **[Docker](https://docker.com)** - container runtime
- **[mitmproxy](https://www.mitmproxy.org/)** - SSL intercepting proxy for capturing LLM API traffic
