Metadata-Version: 2.5
Name: spens-ai
Version: 1.2.7
Summary: Toolchain for running and orchestrating agents in a safe environment with full audit control
License-Expression: MIT
Keywords: agents,audit,docker,nono,sandbox
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.11
Requires-Dist: rich>=13.7
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: ruff>=0.16; extra == 'dev'
Description-Content-Type: text/markdown

![Spens logo](logo.png)

# Spens

Spens is a command line tool for running AI coding agents securely, with full observability of everything they do.

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

## What spens gives you
![Spens Demo](spens_demo.gif)
Coding agents are useful, but risky to run directly on your own machine. They execute shell commands, change files, and make network requests. Spens runs every agent session in a contained environment and records everything that happens, so you can let agents work and still know exactly what they did.

- **Security.** The agent runs in a sandbox, isolated from your machine. It runs as an unprivileged user and cannot touch your home directory or your system files.
- **Observability.** Every LLM API call, tool invocation, HTTP request, and file change is captured. After a session you can review exactly what the agent did, line by line, in a local web viewer.
- **Network control.** You decide which domains the agent may contact and which HTTP methods it may use. Non-HTTP protocols are always blocked. With domain rules configured, any host that is not explicitly allowed is blocked.
- **Secret handling.** Your API keys never enter the agent's container. The agent only sees a placeholder, which is swapped for the real key only for requests to approved domains.
- **Confidence in autonomous runs.** Give the agent a prompt and let it run unattended ("yolo mode"), then review the full log and accept or roll back its file changes.

Spens works with existing coding agents out of the box and can be easily configured to support more: Codex, Claude Code, opencode, and pi.

Note: spens is not designed to be 100% secure as it uses docker, however it is secure enough for most agent workloads and can be run with gvisor to harden the docker instance.

## Installation

### Prerequisites

- Python 3.11 or newer
- Docker, installed and running. On macOS, [OrbStack](https://orbstack.dev) is required

Docker is only needed when you run a session. It is not needed to install the package.

### Install with pip

The package is published to PyPI as `spens-ai` (the `spens` name is already taken there):

```bash
pip install spens-ai
```

It is also published to the GitLab package registry of the spens project: https://gitlab.com/refwd1/spens. The project is public, so no login or token is needed.

```bash
pip install spens-ai --extra-index-url https://gitlab.com/api/v4/projects/refwd1%2Fspens/packages/pypi/simple
```

### Install with uv

```bash
uv tool install spens-ai
```

or from the GitLab registry:

```bash
uv tool install spens-ai --index https://gitlab.com/api/v4/projects/refwd1%2Fspens/packages/pypi/simple
```

`uv tool install` installs spens into its own environment and puts the `spens` command on your PATH. The distribution is named `spens-ai`, but the command and the Python package are still `spens`.

### Verify the install

```bash
spens list
```

This prints the available environments and agents.

## Quick start

Three steps: configure the workspace, run an agent, review the session.

### 1. Configure the workspace

Run this in your project directory:

```bash
cd my-project
spens init
```


`spens init` asks a short set of questions:

1. What type of stack is used (node, python, dotnet, rust, go, java, manual)
2. Do you want to allow public GET access from the internet
3. Do you want to allow access to common URLs for tooling (package registries and similar, based on your stack)
4. What model provider you are using (fireworks, anthropic, openrouter, openai, manual; you can pick several)
5. Do you want to inject API keys for your provider (say no if you are using configurations or subscriptions instead of API keys)
6. Do you want to configure common environment variables for the provider

It then writes a `.spens.config.json` file with the matching domain rules, key injection rules, and capture URLs, and adds `.spens` to your `.gitignore`.

It can also run without prompts:

```bash
spens init --yes --stack node --provider anthropic
```

Make sure your provider API key is set in your host environment (for example `ANTHROPIC_API_KEY`). Spens gives the agent a placeholder value and injects the real key only for approved domains, so the real key never enters the container.

If you skip this step and provide no config, the network policy fails closed: with no domain rules configured, every HTTP request is denied. See [Default network policy](#default-network-policy).

### 2. Run an agent

```bash
spens node-24 pi .
```

The format is: `spens <environment> <agent> <workspace>`.

- The first run builds the Docker images for the environment, the agent, and the interceptor. This takes a few minutes. Later runs reuse the cached images.
- The current directory (`.`) is mounted as the workspace.
- The pi agent starts in an interactive session.
- When the session ends, you choose whether to keep the agent's file changes or roll the workspace back to its pre-session state.

You can also give the agent a prompt and let it run unattended (yolo mode). The agent executes the prompt and exits:

```bash
spens node-24 pi . "explain what this repository does"
```

### 3. Review the session

```bash
spens log-viewer
```

This opens a local web UI at `http://127.0.0.1:7331` with four tabs:

- **Chat transcript** - every LLM call, decoded: request messages, responses, tool calls, reasoning, and token usage
- **Audit log** - session metadata, executable identity, tracked paths, audit events, and the integrity chain
- **Other HTTP** - all non-LLM HTTP requests and responses captured during the session
- **File changes** - every file the agent changed, with diffs and rollback information

The session summary includes an estimated cost. See [Cost estimation](#cost-estimation).

## Out of the box environments and agents

Run `spens list` to print everything that is available.

### Environments

| Environment | Base image |
|---|---|
| `node-20` | `node:20.18.0-bookworm` |
| `node-22` | `node:22.16.0-bookworm` |
| `node-24` | `node:24.18.0-trixie` |
| `python-3.11` | `python:3.11-bookworm` |
| `python-3.12` | `python:3.12-bookworm` |
| `python-3.13` | `python:3.13-bookworm` |

### Agents

| Agent | Tool | Notes |
|---|---|---|
| `codex` | OpenAI Codex | Forced to HTTP-only mode via `config.toml` so its traffic can be intercepted. Mounts `~/.codex/settings.json` from the host; a file at `.spens/.codex/settings.json` overrides it |
| `claude` | Claude Code | Mounts `~/.claude/settings.json` from the host; a file at `.spens/.claude/settings.json` overrides it |
| `opencode` | opencode | Mounts `~/.config/opencode/config.json` and `~/.local/state/opencode/models.json` from the host; files under `.spens/.opencode` override them |
| `pi` | pi.dev | Mounts `~/.pi/agent/settings.json` and `models.json` from the host; files under `.spens/.pi/agent` override them |

Any environment can be combined with any agent.

## Cost estimation

Every session summary includes an estimated cost, computed from the token usage in the captured LLM traces.

Spens prices tokens with the [Portkey pricing dataset](https://configs.portkey.ai/pricing/anthropic.json), covering six providers: anthropic, openai, google, fireworks-ai, openrouter, and bedrock. A model listed by more than one provider is priced from the first provider in that order that lists it, so a direct provider's rate wins over a reseller's.

You normally do not need to do anything. After each session, spens fetches the dataset on the host, caches it under `~/.cache/spens/pricing` for 24 hours, and prices the session from it. For each model, the price is resolved from the first source that lists it:

1. the live Portkey dataset (cached for 24 hours)
2. the vendored snapshot shipped inside the package at `spens/data/pricing`, used when the fetch fails or you are offline
3. a small built-in price table
4. nothing - a model that no source lists is costed at $0 rather than guessed

Each session summary records which source each provider's numbers came from, so an archived cost figure can still be explained later.

Use `spens pricing` to inspect or refresh the catalog on demand:

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

The full option reference is in [spens pricing](#spens-pricing).

To keep costing entirely offline, set `SPENS_PRICING_OFFLINE=1` in your environment or put `"pricing": {"live_fetch": false}` in `.spens.config.json`.

## Advanced configuration

Spens runs every session as two Docker containers - the agent container and an interceptor container - on an isolated internal Docker network, so all agent traffic, including DNS, is forced through the interceptor where it is filtered, logged, and decoded. For the full picture, including image generation, the interceptor internals, the nono sandbox, and policy masking, see [ARCHITECTURE.md](ARCHITECTURE.md).

### Custom environments and agents

There are two ways to add your own templates:

1. Put JSON template files in a `templates/` directory inside your workspace. Local templates take precedence over built-in templates with the same name.
2. Pass a `.json` file directly as the environment or agent argument. The path is resolved relative to the current directory or the workspace.

#### Example: a custom Go environment

Create `templates/go-1.24.json` in your workspace:

```json
{
  "template_type": "environment",
  "name": "go-1.24",
  "base-image": "golang:1.24-bookworm",
  "nono-command": "curl -fsSL https://nono.sh/install.sh | sh",
  "packages": ["curl", "ca-certificates", "git"],
  "package_manager": "apt",
  "agent_user": {
    "name": "spens",
    "uid": 1000,
    "home": "/home/spens",
    "create": true
  }
}
```

Then run it by name (picked up from `templates/`) or by file path:

```bash
spens go-1.24 pi .
spens go-1.24.json pi .
```

See [Custom environment parameters](#custom-environment-parameters) for the full field reference.

#### Example: a custom agent

Create `templates/my-agent.json`:

```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}"
}
```

Then run it:

```bash
spens node-24 my-agent .
```

See [Custom agent parameters](#custom-agent-parameters) for the full field reference.

## Command line arguments

### Run a session

```
spens <environment> <agent> <workspace> [prompt] [options]
```

| Argument or option | Description |
|---|---|
| `environment` | Environment template name (e.g. `node-24`), or a path to a `.json` template file |
| `agent` | Agent template name (e.g. `pi`), or a path to a `.json` template file |
| `workspace` | Path to the directory to mount as the workspace |
| `prompt` | Optional fourth argument. Runs the agent non-interactively (yolo mode): the agent executes the prompt and exits. The agent template must define a `yolo_command` |
| `--rebuild` | Rebuild the Docker images from scratch, without cache |
| `--spens-dir PATH` | Directory where session state is stored. Default: `<workspace>/.spens` |
| `--config PATH` | Use a different spens config file for the session. Default: `<workspace>/.spens.config.json`. Relative paths resolve against the current directory. The file must exist when given; it is an error otherwise |
| `--accept-changes` | Auto-accept the agent's workspace changes when the session ends (skips the review prompt) |
| `--reject-changes` | Roll the workspace back to its pre-session state after the agent exits |
| `--output MODE` | Output mode: `tty` (default, pretty terminal output), `jsonl` (stream JSON events to stdout), or `background` (print the session id and exit; the session runs detached) |
| `--session-id ID` | Use a specific session id instead of a random one. Must match `^[a-z0-9][a-z0-9-]{0,31}$`; reusing an existing id is an error |
| `--bypass-config-mounts` | Do not mount any of the agent template's `configuration` files into the agent container. The agent starts with no host configuration/credentials and must log in (or be configured) from inside the session; that state is ephemeral and lost when the container exits |

Notes:

- `jsonl` and `background` modes require a prompt and exactly one of `--accept-changes` / `--reject-changes`. Without an explicit decision, the end-of-session change review would block a non-interactive session forever.
- Every session writes a machine-readable event stream to `<session_dir>/events.jsonl` and its current state to `<session_dir>/state.json`, regardless of output mode.

### Session lifecycle commands

```
spens status <session-id> [--spens-dir PATH]    # print session state and exit code
spens cancel <session-id>  [--spens-dir PATH]   # stop the session and mark it canceled
spens attach <session-id>  [--spens-dir PATH]   # follow the session event stream until it ends
```

These work for every spens session, regardless of output mode. Terminal states are `finished`, `canceled`, and `error`.

### spens init

```
spens init [options]
```

| Option | Description |
|---|---|
| `--yes`, `-y` | Skip all prompts and use defaults for unanswered questions. Requires `--stack` and `--provider` |
| `--stack` | Stack used: `node`, `python`, `dotnet`, `rust`, `go`, `java`, `manual` |
| `--provider` | Model provider(s): `fireworks`, `anthropic`, `openrouter`, `openai`, `manual`. Comma-separated for multiple (e.g. `openai,fireworks`) |
| `--public-get` / `--no-public-get` | Allow or deny public GET access (default: deny) |
| `--tooling-urls` / `--no-tooling-urls` | Allow or deny the stack's common tooling URLs (default: allow) |
| `--inject-api-keys` / `--no-inject-api-keys` | Inject the provider's API keys for approved domains (default: allow). Say no when using configurations or subscriptions instead of API keys |
| `--inject-env` / `--no-inject-env` | Configure the provider's common env vars: forward non-secret vars such as base URLs (default: allow) |
| `--output PATH` | Write the config somewhere other than `.spens.config.json` |
| `--force` | Overwrite an existing config without asking |
| `--dry-run` | Print the generated config to stdout without writing any file |

### spens list

```
spens list
```

Prints all available environments and agents, including local templates from the workspace `templates/` directory.

### spens log-viewer

```
spens log-viewer [workspace] [options]
```

| Option | Description |
|---|---|
| `--port PORT` | Port for the web server (default: 7331) |
| `--host HOST` | Host to bind (default: 127.0.0.1) |
| `--no-browser` | Do not open a browser automatically |
| `--spens-dir PATH` | Read sessions from a specific spens directory instead of searching for `.spens` near the workspace |

### spens pricing

```
spens pricing [options]
```

| Option | Description |
|---|---|
| `--refresh` | Re-fetch every provider's pricing dataset into the local cache |
| `--vendor` | Re-fetch and also update the snapshots shipped in `spens/data/pricing` (needs a writable checkout) |
| `--provider NAME` | Limit to one provider (repeatable). Providers: anthropic, openai, google, fireworks-ai, openrouter, bedrock |
| `--model ID` | Show the resolved price for one model id |
| `--tokens N` | Context size used to pick a banded model's price with `--model` |
| `--offline` | Do not fetch; report what cached and vendored data provide |

How costs are computed is described in [Cost estimation](#cost-estimation).

## Spens config parameters

Spens reads an optional `.spens.config.json` from the workspace root. `spens init` generates one for you; you can also write it by hand. To use a different config file for a single session, pass `--config PATH` (e.g. `spens node-24 pi . --config .spens.config.ocr.json`). A full example:

```json
{
  "egress": "enforced",
  "nono_override": "profile_override.json",
  "additional_capture_urls": ["*opencode.ai*"],
  "exclude_capture_urls": ["*models.opencode.ai*"],
  "env": ["OPENAI_BASE_URL"],
  "pre_sandbox_commands": ["npm install"],
  "domain_rules": [
    {"pattern": "*api.github.com*", "allow": ["GET"]},
    {"pattern": "*pypi.org*", "allow": ["*"]},
    {"pattern": "*api.anthropic.com*", "allow": ["GET", "POST"]}
  ],
  "inject_headers": [
    {
      "placeholder": "ANTHROPIC_API_KEY",
      "env_var": "ANTHROPIC_API_KEY",
      "for_domains": ["*api.anthropic.com*"]
    }
  ],
  "pricing": {"live_fetch": false}
}
```

### Extending the generated nono profile

When you use `nono_override`, the generated profile is replaced at runtime. The generated profile is still preserved inside the image as `spens-base.json`, so your override can extend it rather than recreating all the Spens internals. For example, a `profile_override.json` that only adds an extra allowed folder:

```json
{
  "extends": ["spens-base"],
  "allow_folders": ["/tmp/custom-cache"]
}
```

This inherits the proxy environment variables, cache sanitization, placeholder injection, and any base-pack extensions that Spens generates, and layers your own rules on top.

### Default network policy

Two mechanisms control the agent's network access, and both fail closed:

- **Non-HTTP protocols always fail closed.** The agent's Docker network has no route to the internet except through the interceptor, and the interceptor refuses tunnels that do not speak HTTP. SSH, raw sockets, and other non-HTTP protocols cannot get out, regardless of configuration.
- **HTTP/HTTPS filtering depends on `domain_rules`.** If `domain_rules` is omitted or empty, every domain is denied (fail closed). Traffic is still routed through the interceptor, so any request that is allowed by an explicit rule is captured and logged. Once any rule is configured, the policy stays fail closed: a hostname that matches no rule is blocked, and HTTPS tunnels to unmatched hosts are refused before they are established.

If you want allow-by-default HTTP egress, add a wildcard rule such as `{"pattern": "*", "allow": ["*"]}` to `domain_rules`. `spens init` generates a ruleset for your stack and provider; answering `manual` for both the stack and the provider produces an empty ruleset, which means deny-all.

When several rules match a hostname, the **most specific** one governs the request, regardless of the order the rules are declared in. For example, with a public-GET catch-all `{"pattern": "*", "allow": ["GET"]}` plus `{"pattern": "*api.anthropic.com*", "allow": ["GET", "POST"]}`, POST requests to `api.anthropic.com` are allowed while every other host is still limited to GET. Specificity is the number of domain labels in the pattern, so `api.anthropic.com` overrides `anthropic.com`, which overrides `*`. If two matching rules are equally specific (e.g. `*.anthropic.com` and `anthropic.com`), the first one declared wins.

A rule whose `allow` key is missing or is not a list is malformed and never widens permissions: it is skipped entirely, so the next most specific well-formed matching rule governs instead, and the request is denied if none does. An explicitly empty `allow` list (`[]`) is well-formed and denies every method on that host without falling back.

### Field reference

| Field | Description |
|---|---|
| `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 |
| `nono_override` | Path to a custom nono profile JSON file, replacing the auto-generated combined profile. The generated profile is always preserved inside the image as `spens-base.json`, so your override can use `"extends": ["spens-base"]` to layer your own rules on top of Spens internals (proxy env vars, cache sanitization, placeholders, base-pack extensions) instead of recreating them |
| `additional_capture_urls` | Glob-style URL patterns to additionally capture in the interceptor. The old typo `addition_capture_urls` is still accepted for backward compatibility |
| `exclude_capture_urls` | Glob-style URL patterns to skip in the interceptor |
| `env` | Environment variable names to forward from the host to the agent container with their real values. Use for non-secret variables only |
| `pre_sandbox_commands` | Shell commands to run inside the container before the sandbox starts the agent (e.g. `npm install`). They run unsandboxed as the unprivileged agent user, and their network traffic goes 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 methods. When multiple rules match, the most specific pattern governs the request (see [Default network policy](#default-network-policy)). Fails closed: a hostname that matches no rule is blocked, and omitted or empty `domain_rules` means deny-all. A rule with a missing or non-list `allow` is skipped (it never defaults to allow-all) |
| `inject_headers` | Secret substitution rules. See [How inject_headers works](#how-inject_headers-works) |
| `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 |

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

### How inject_headers works

`inject_headers` rules keep real secrets out of the agent container. Each rule has three parts:

- `placeholder` - the literal string the agent is given as its "key"
- `env_var` - the host environment variable that holds the real secret
- `for_domains` - the hostnames the secret may be substituted on

A worked example. Suppose your host environment has `ANTHROPIC_API_KEY=sk-ant-secret123` and your config contains the rule from the example above. At runtime:

1. The agent container is started with `ANTHROPIC_API_KEY=ANTHROPIC_API_KEY`. The variable name is real, but its value is the literal placeholder string. The agent reads it and sends it in its API requests, but it holds no secret.
2. The interceptor container is started with the real value of `ANTHROPIC_API_KEY` from your host.
3. When the agent sends a request whose hostname matches `for_domains` (here, `api.anthropic.com`), the interceptor replaces every occurrence of the placeholder string with the real secret before forwarding the request upstream.
4. On any other hostname, the placeholder is left untouched, so the real secret can never be attached to a request to a destination you did not authorize.
5. Real secret values are masked out of the request log and trace files before anything is written to disk.

`placeholder` and `env_var` do not have to share a name. If your host stores the secret under `ANTHROPIC_KEY`, write `"placeholder": "ANTHROPIC_API_KEY", "env_var": "ANTHROPIC_KEY"` - the agent still sees `ANTHROPIC_API_KEY`, and the interceptor reads the real value from `ANTHROPIC_KEY`. `spens init` generates rules where both names are the provider's conventional variable name.

A rule without `for_domains` (or with an empty list) authorizes no hostnames, so its placeholder is never substituted.

## Custom environment parameters

An environment template is a JSON file with `"template_type": "environment"`. It defines the base Docker image, system packages, and the nono.sh installation.

| Field | Required | Description |
|---|---|---|
| `template_type` | yes | Must be `"environment"` |
| `name` | yes | Name used on the command line |
| `base-image` | yes | Docker base image for the environment |
| `nono-command` | yes | Shell command that installs the nono shell in that image |
| `packages` | no | Additional system packages to install |
| `package_manager` | no | Package manager used to install packages: `apt` (default) or `apk` |
| `nono_base_config` | no | nono.sh base profile to pull and extend. Omit to use only the agent's profile |
| `agent_user` | no | 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 `~` expand to this user's home |

## Custom agent parameters

An agent template is a JSON file with `"template_type": "agent"`. It defines how to install and configure a specific coding agent.

| Field | Required | Description |
|---|---|---|
| `template_type` | yes | Must be `"agent"` |
| `name` | yes | Name used on the command line. It is also the command used to start the agent |
| `installation_command` | yes | Shell command that installs the agent binary. Runs as the unprivileged agent user, installing into its home |
| `dependencies` | no | Shell commands to run before installation (e.g. installing prerequisites). Runs as root during build, with `HOME` pinned to the agent user's home |
| `nono_base_config` | no | nono.sh base profile to pull and extend |
| `relocate_binary` | no | Moves the installed binary to a standard location and cleans up install artifacts: `{"from", "to", "cleanup"}`. `cleanup` is a path or list of paths to remove |
| `config_files` | no | Static files written into the image at build time. A map of container path to file content |
| `configuration` | no | Ordered list of host config mounts. Each entry has a `source`, a `destination`, an optional `include` glob list, and an optional `exclude` glob list. Only existing sources and matching files are mounted. `source` accepts the `{workspace}` token (the resolved workspace directory) and the `{spens_dir}` token (the session's spens directory). `destination` paths starting with `~` expand to the agent user's home. When multiple entries target the same container file, the entry listed first wins |
| `allow_folders` | no | Directories the agent is allowed to access |
| `env` | no | Environment variables to set in the container, in `KEY=value` format |
| `packages` | no | Additional system packages to install in the image |
| `yolo_command` | no | Command template with a `{prompt}` placeholder, used to run the agent non-interactively when a prompt is passed on the CLI. The placeholder is filled at runtime, so the image is not rebuilt per prompt. If omitted, passing a prompt is an error |

The built-in agents use these yolo commands:

| 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}` |

## Contributing

For development setup, running the tests, linting, and the project layout, see [CONTRIBUTING.md](CONTRIBUTING.md).

## Architecture

For a technical explanation of how spens works - the two-container setup, network enforcement, the interceptor, the nono sandbox, policy masking, and the session data model - see [ARCHITECTURE.md](ARCHITECTURE.md).

## License

Spens is MIT licensed. See `license.md`.
