From 85c1492fbed5c4df8627036fd8f5c8b565bd6fbf Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sat, 27 Jun 2026 20:55:46 -0400 Subject: [PATCH] docs: add readme and agents guide --- AGENTS.md | 110 ++++++++++++++++++++++++++ README.md | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 AGENTS.md create mode 100644 README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1520357 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md + +Tartarus: a Nix-defined containment runtime for auditable agents. Agents are +declared in a Nix flake; Nix compiles them to a `manifest.json` bundle; the +Python harness (`tartarus/`) runs the agent loop and jails every tool call in +bubblewrap. + +## Architecture (the boundary that matters) + +Two languages, one contract between them: + +- **Nix side** (`lib/agents.nix`, `agent.nix`, `flake.nix`): compiles an agent's + capabilities into `.#agents...bundle` — a store path containing + `manifest.json` plus symlinks to each grant's closure. The manifest + references every store path it names, so `nix copy ` pulls the full + closure. +- **Python side** (`tartarus/`): reads `/manifest.json` and runs. The + harness makes **no `nix` calls at runtime**; the manifest is the only + bridge. Entry point is `main.py` → `tartarus.cli:main`. +- `PLAN.md` is the authoritative design doc (architecture, manifest contract, + security invariants, glossary). README is the user-facing quick start. If + prose and code disagree, code wins. + +## Developer commands + +```sh +uv run pytest # full suite +uv run pytest tests/test_cli.py # one file +uv run pytest -k "parse_agent" # one test / pattern +uv run ruff check . # lint (passes clean on defaults) +uv run ty check # typecheck (Astral ty, NOT mypy; passes clean) +uv run python main.py "prompt" # run the agent (see below) +nix build .#agents.x86_64-linux.default.bundle --no-link --print-out-paths +``` + +`uv` supplies `ruff` and `ty` from the `dev` dependency group; the `nix +develop` shell does **not** provide them. `pyproject.toml` configures only +pytest (`pythonpath=["."]`, `testpaths=["tests"]`); ruff/ty run on defaults. +Python `>=3.13`. + +## Running the agent + +`uv run python main.py` builds the selected flake agent via `nix` on first run, +so it needs **nix + network + an API key**. Skip the build by pointing at a +prebuilt bundle: `TARTARUS_BUNDLE=/nix/store/...-bundle uv run python main.py`. + +Required env: `TARTARUS_API_KEY` (or `OPENCODE_API_KEY`). Defaults target +OpenCode Zen (`https://opencode.ai/zen/v1`, model `glm-5.2`). Per-field +precedence: **explicit env var > agent's `model` block > built-in default**. +API keys and request headers are **env-only** — never put them in the flake. + +Session flags: `--continue`, `--resume `, `--no-session`, `--list-sessions`. +Agent selector: `.#` as the first positional arg, or `TARTARUS_AGENT`. + +## Test prerequisites and quirks + +- `tests/test_jail.py` integration tests require **Linux + `bwrap` + `nix`** + and `@pytest.mark.skipif`-skip otherwise. They also call + `tartarus.shell.resolve_minimal_shell_path`, which runs + `nix build nixpkgs#coreutils nixpkgs#bash` — so they need **nix network + access**, not just the `bwrap` binary. Without it these tests skip or fail. +- No `conftest.py`. Shared manifest fixtures live in + `tests/manifest_fixtures.py` — import it directly. +- Pure-Python tests (e.g. `test_cli.py`, `test_config.py`) run hermetically and + fast; use them for tight loops. + +## Sandbox / security invariants (do not break these) + +From `tartarus/jail.py` and `PLAN.md §8`: + +- Every brokered tool call runs under `bwrap --unshare-all` with only the + declared closure's store paths bound **read-only** — never the whole + `/nix/store`. A capability reaches only its declared closure. +- Work tree is mounted at `/work`, read-only unless a `writable` grant re-binds + a path. Writable paths must be relative and stay under the work tree. +- Package grants append to PATH for **that one call only**; they are not + permanent. Network grants route through a filtering HTTP proxy with an + allow-list of `host:port`. **Raw TCP egress is not contained** (no network + namespace firewall) — that's why plain-TCP capabilities like `run_migration` + ship as `policy = "deny"`. +- `unrestricted = true` grants **skip bwrap entirely** after policy approval + (the "big red button"); the manifest validator rejects `unrestricted + auto`. +- Policies: `auto`, `ask-once`, `ask-always`, `deny`. `deny` capabilities are + never exposed as tools. `TARTARUS_HEADLESS=1` makes `ask-*` fail closed. + +## Editing the agent and capabilities (`agent.nix`, `lib/agents.nix`) + +- A capability is either a plain attrset (when `pkgs` is in scope) or a + module **function** `{ pkgs, ... }: { ... }` (self-contained, copyable across + flakes). Both forms are accepted; `resolveCapabilities` handles either. +- `name` is **stripped from the body** — the attrset key carries it. Duplicate + capability names throw at compile time. +- The agent's `shell` is the baseline PATH baked into the manifest; keep it + minimal. Tool-specific programs go in that capability's `grants.packages`. +- `kind = "background"` launches detached (handle returned immediately); + `kind = "control"` capabilities (`bg_status`/`bg_output`/`bg_stop`) carry no + runner and no grants — they act on the background registry, not the jail. + +## Repo conventions + +- `.tartarus/` (audit logs, sessions, background logs) and + `result`/`result-*` (Nix build outputs) are gitignored runtime/build state. +- Commit style: lowercase conventional-commit prefixes with a scope + (`tests:`, `jail:`, `agent:`, `manifest_loader:`, `nix/agent:`), short + subject, no brackets. +- The shipped `default` agent's tools live in `agent.nix`: read/search/git + inspection, `write_file`/`edit_file` (ask-once), `run_command`/ + `run_background_command` (ask-always), `bg_*` controls, `format_code` + (nixfmt), `run_tests` (pytest, 300s timeout), `run_ephemeral_command`, + `write_artifact`, and the networked `fetch_*` examples. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f920d7 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Tartarus + +A framework for building composable, hermetic, and shareable AI agents with Nix. + +Tartarus turns an agent definition into a runnable, sandboxed system. You +declare the agent's tools, permissions, and policies in a Nix flake. Nix +builds that into a self-contained bundle, and Tartarus handles the agent +loop so that every tool call executes inside a sandbox with only the +access declared for that tool. + +- Define an agent's shell, tools, model, and permissions in one Nix flake. +- Compose tools from ordinary nixpkgs packages, granting each tool only + the binaries it needs so the agent never touches your host machine. +- Share the agent as one Nix closure and run it the same way anywhere. + +## Quick Start + +Prerequisites: + +- Nix with flakes enabled +- Python 3.13+ and `uv` +- Linux with `bubblewrap` available for jailed execution +- An OpenAI-compatible chat-completions endpoint + +Set an API key and ask the default agent a question: + +```sh +export OPENCODE_API_KEY=... +uv run python main.py "summarize the uncommitted changes in this repo" +``` + +With no prompt argument, Tartarus starts an interactive REPL: + +```sh +uv run python main.py +``` + +Assistant text, tool starts/finishes, and foreground command output stream live. +Ctrl-C cancels the in-flight turn, tears down any running jailed process, and +returns to the prompt without corrupting the transcript. + +## What You Get + +- A realized agent bundle at `agents...bundle` containing + `manifest.json`, baked shell PATH, CA bundle, and every referenced store path. +- A provider-neutral agent loop for OpenAI-compatible backends. +- Tool policies: `auto`, `ask-once`, `ask-always`, and `deny`. +- Sandboxed command execution with closure-scoped `/nix/store` bindings and + scoped work-tree writes. +- Optional network grants through a filtering HTTP proxy. +- Background tools for long-running tasks, with `bg_status`, `bg_output`, and + `bg_stop` controls. +- Append-only audit logs and resumable session transcripts under `.tartarus/`. + +The shipped `default` agent includes read/search tools, Git inspection tools, +JSON helpers, scoped file editing, formatting/test commands, sealed shell +commands, package-scoped ephemeral commands, artifact writing, and a few +networked fetch examples. + +## Running Agents + +By default Tartarus builds and loads: + +```text +path:.#agents..default.bundle +``` + +Select another agent from the same flake with either an env var or an inline +selector: + +```sh +export TARTARUS_AGENT=research +uv run python main.py "inspect this project" + +uv run python main.py .#default "what packages are available on PyPI for typer?" +``` + +Point Tartarus at another flake: + +```sh +export TARTARUS_FLAKE_REF=github:your-org/your-agents +export TARTARUS_AGENT=default +uv run python main.py +``` + +Use a prebuilt/copied bundle without needing the source flake at runtime: + +```sh +nix build .#agents.x86_64-linux.default.bundle --no-link --print-out-paths +nix copy --to /nix/store/...-bundle + +# On the receiving machine: +nix copy --from /nix/store/...-bundle +export TARTARUS_BUNDLE=/nix/store/...-bundle +uv run python main.py +``` + +Secrets are never part of the bundle. API keys and deployment-specific headers +come from the environment. + +## Defining An Agent + +Agents are ordinary Nix values. The reusable compiler lives in `lib/agents.nix`; +the example agent is in `agent.nix`. + +```nix +{ + inputs.tartarus.url = "github:your-org/tartarus"; + inputs.nixpkgs.follows = "tartarus/nixpkgs"; + + outputs = { tartarus, nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + + read_package_json = { pkgs, ... }: { + name = "read_package_json"; + description = "Read package.json from the work tree."; + policy = "auto"; + params = { }; + grants.packages = [ pkgs.jq ]; + grants.network.allowedHosts = [ ]; + grants.writable = [ ]; + runner = "jq . package.json"; + }; + in + { + agents.${system} = tartarus.lib.mkAgents { inherit pkgs; } { + default = { + systemPrompt = "You are a careful coding agent."; + shell = with pkgs; [ bash coreutils ]; + capabilities = [ read_package_json ]; + + model = { + provider = "openai-compat"; + baseUrl = "https://opencode.ai/zen/v1"; + name = "glm-5.2"; + maxTokens = 32768; + sampling = { temperature = 0.6; }; + }; + }; + }; + }; +} +``` + +A capability declares: + +- `name`, `description`, and model-facing `params` +- `policy`: `auto`, `ask-once`, `ask-always`, or `deny` +- `grants.packages`: package binaries available only to that tool +- `grants.network.allowedHosts`: proxy-allowed HTTP(S) hosts +- `grants.writable`: work-tree-relative write scopes +- `runner`: the command template +- optional `timeout`, `kind`, and `control` fields for long-running/background + capabilities + +The baseline `shell` is shared by every jailed call, so keep it small. Put +tool-specific programs in that capability's package grants. + +## Configuration + +Backend settings can come from the environment or from the agent's `model` block. +Precedence is per field: + +```text +explicit env var > agent model field > built-in default +``` + +API keys and extra request headers are environment-only. + +| Env var | Default | Purpose | +|---|---|---| +| `TARTARUS_API_KEY` / `OPENCODE_API_KEY` | required | Bearer key for the backend | +| `TARTARUS_BASE_URL` | `https://opencode.ai/zen/v1` | OpenAI-compatible base URL | +| `TARTARUS_MODEL` | `glm-5.2` | Model id | +| `TARTARUS_MAX_TOKENS` | `16384` | Max completion tokens | +| `TARTARUS_PROVIDER` | `openai-compat` | Provider adapter | +| `TARTARUS_EXTRA_HEADERS` | `{}` | JSON object merged into provider request headers | +| `TARTARUS_FLAKE_REF` | `path:.` | Flake containing the selected agent | +| `TARTARUS_AGENT` | `default` | Agent name under `agents.` | +| `TARTARUS_BUNDLE` | unset | Realized bundle path; skips flake build when set | +| `TARTARUS_WORK_TREE` | current directory | Work tree mounted into the jail as `/work` | +| `TARTARUS_HEADLESS` | `false` | Make `ask-*` policies fail closed | +| `TARTARUS_AUDIT_PATH` | `/.tartarus/audit.jsonl` | Audit log path | +| `TARTARUS_SESSIONS_DIR` | `/.tartarus/sessions` | Session transcript directory | +| `TARTARUS_OUTPUT_TRUNCATE` | `10000` | Tool output truncation limit in characters | + +To use a local OpenAI-compatible server: + +```sh +export TARTARUS_API_KEY=not-used +export TARTARUS_BASE_URL=http://localhost:11434/v1 +export TARTARUS_MODEL=llama3.1 +uv run python main.py +``` + +## Sessions And Audit Logs + +Every normal run persists its transcript and prints a session id. Resume or +inspect sessions with: + +```sh +uv run python main.py "remember the number 42" +uv run python main.py --continue "what number?" +uv run python main.py --resume 20260627-1430 "continue here" +uv run python main.py --list-sessions +uv run python main.py --no-session "one-off" +``` + +Every brokered tool call appends one JSONL audit record, including policy +decision, grant delta, command, exit code, output length, and errors. + +## Repository Map + +| Path | Purpose | +|---|---| +| `agent.nix` | Example agent and capabilities | +| `lib/agents.nix` | Nix compiler for `agents...bundle` | +| `tartarus/` | Python harness: config, bundle loading, provider, loop, broker, jail | +| `tests/` | Unit and integration tests | +| `PLAN.md` | Architecture, contract details, and implementation history | + +## Development + +Run the test suite: + +```sh +uv run pytest +``` -- 2.51.2