diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddaf6e5..8a4b119 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,4 +36,4 @@ jobs: - uses: actions/checkout@v7 - uses: DeterminateSystems/nix-installer-action@v22 - uses: DeterminateSystems/magic-nix-cache-action@v14 - - run: nix build .#agents.x86_64-linux.default.bundle --no-link --print-out-paths + - run: nix build .#agents.x86_64-linux.default.config.build.bundle --no-link --print-out-paths diff --git a/.gitignore b/.gitignore index 3c1c688..4c959d1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ artifacts/ # Nix result result-* + +# Internal design doc — kept locally, not tracked +PLAN.md diff --git a/AGENTS.md b/AGENTS.md index 1520357..c309fcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ bubblewrap. 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 + capabilities into `.#agents...config.build.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. @@ -30,7 +30,7 @@ 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 +nix build .#agents.x86_64-linux.default.config.build.bundle --no-link --print-out-paths ``` `uv` supplies `ruff` and `ty` from the `dev` dependency group; the `nix @@ -85,11 +85,23 @@ From `tartarus/jail.py` and `PLAN.md §8`: ## 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. +- Agents are NixOS-style module graphs passed to `tartarus.lib.tartarusAgent`, + which takes `{ system, modules, specialArgs }` like `nixpkgs.lib.nixosSystem`. + Reusable entries under `tartarus.modules` are ordinary agent modules: they can + set capabilities, prompts, shell packages, imports, or any other agent option. +- The package set comes from a NixOS-style `nixpkgs` module: `nixpkgs.hostPlatform` + defaults to `system`; set `nixpkgs.config`/`nixpkgs.overlays`/`nixpkgs.pkgs` in a + module to override it. Modules receive the result as `pkgs` — there is no `pkgs` + function argument. +- The `name` option labels the bundle derivation (`tartarus--bundle`), + mirroring `networking.hostName`. It defaults to `agent`; set it per agent + (conventionally matching the attr key) — the attr key is not auto-inherited. +- `tartarusAgent` returns the `evalModules` result (`config`/`options`/`pkgs`/ + `extendModules`), like `nixosSystem`. Build outputs are at + `config.build.{manifest,bundle,shell}`; assertions are checked when a build + output is forced (reading `config` is free), mirroring `system.build.toplevel`. +- Capabilities are keyed attrsets under `capabilities.`. Do not put + `name` in the capability body; the attrset key is the identity. - 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); diff --git a/README.md b/README.md index 86f0f58..304f77c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ returns to the prompt without corrupting the transcript. ## What You Get -- A realized agent bundle at `agents...bundle` containing +- A realized agent bundle at `agents...config.build.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`. @@ -64,7 +64,7 @@ runs freely. By default Tartarus builds and loads: ```text -path:.#agents..default.bundle +path:.#agents..default.config.build.bundle ``` Select another agent from the same flake with either an env var or an inline @@ -88,7 +88,7 @@ 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 build .#agents.x86_64-linux.default.config.build.bundle --no-link --print-out-paths nix copy --to /nix/store/...-bundle # On the receiving machine: @@ -102,64 +102,55 @@ come from the environment. ## Defining An Agent -Agents are ordinary Nix values. The reusable compiler lives in `lib/agents.nix`; -the shared coding-agent tool catalog lives in `agentModules`, and the example -agent is in `agent.nix`. +Agents are small Nix module systems. The reusable compiler lives in +`lib/agents.nix`; reusable agent modules live under `tartarus.modules`, and the +example agent is in `agent.nix`. ```nix { inputs.tartarus.url = "github:your-org/tartarus"; inputs.nixpkgs.follows = "tartarus/nixpkgs"; - outputs = { tartarus, nixpkgs, ... }: + outputs = { self, 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 = with tartarus.agentModules; [ - read - write - edit - list - glob - grep - bash - web_fetch - read_package_json - ]; - - model = { - provider = "openai-compat"; - baseUrl = "https://opencode.ai/zen/v1"; - name = "glm-5.2"; - maxTokens = 32768; - sampling = { temperature = 0.6; }; - }; - }; + agents.${system}.default = tartarus.lib.tartarusAgent { + inherit system; + modules = [ + tartarus.modules.coding + ({ pkgs, ... }: { + systemPrompt = "You are a careful coding agent."; + shell.packages = with pkgs; [ bash coreutils ]; + + capabilities.read_package_json = { + description = "Read package.json from the work tree."; + policy = "auto"; + params = { }; + grants.packages = [ pkgs.jq ]; + runner = "jq . package.json"; + }; + + model = { + provider = "openai-compat"; + baseUrl = "https://opencode.ai/zen/v1"; + name = "glm-5.2"; + maxTokens = 32768; + sampling = { temperature = 0.6; }; + }; + }) + ]; }; + + packages.${system}.default = self.agents.${system}.default.config.build.bundle; }; } ``` A capability declares: -- `name`, `description`, and model-facing `params` +- the attrset key as its name, plus `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 @@ -171,9 +162,27 @@ A capability declares: The baseline `shell` is shared by every jailed call, so keep it small. Put tool-specific programs in that capability's package grants. -`tartarus.agentModules` provides reusable module definitions for common -coding-agent tools: `read`, `list`, `write`, `edit`, `glob`, `grep`, `bash`, -and `web_fetch`. These are also the tool names exposed to the agent. +`tartarusAgent` mirrors `nixpkgs.lib.nixosSystem`: it takes +`{ system, modules, specialArgs }` and configures its package set through a +NixOS-style `nixpkgs` module. `nixpkgs.hostPlatform` defaults to `system`, and +any module may set `nixpkgs.config` (e.g. `allowUnfree`), `nixpkgs.overlays`, or +`nixpkgs.pkgs` to override it — every module then receives the result as `pkgs`. + +An agent's `name` option labels its bundle derivation (`tartarus--bundle`), +mirroring how `networking.hostName` names a NixOS system. It defaults to `agent`; +set it per agent (conventionally matching the `agents..` key) for +descriptive, non-colliding labels in multi-agent flakes. + +Like `nixosSystem`, `tartarusAgent` returns the module-evaluation result — +`config`, `options`, `pkgs`, and `extendModules` — and its build outputs live in +the config at `config.build.{manifest,bundle,shell}` (the agent analog of +`config.system.build.toplevel`). Hence `agents...config.build.bundle`. + +`tartarus.modules` is a flat catalog of ordinary agent modules. Some entries set +one capability (`read`, `list`, `write`, `edit`, `glob`, `grep`, `bash`, +`webFetch`), while others can set any valid agent options. +`tartarus.modules.coding` imports the common coding set, and +`tartarus.modules.default` aliases it. Task/subagent orchestration, todo state, human questions, and skill loading are intentionally not modeled as shell capabilities yet. @@ -235,7 +244,7 @@ decision, grant delta, command, exit code, output length, and errors. | Path | Purpose | |---|---| | `agent.nix` | Example agent and capabilities | -| `lib/agents.nix` | Nix compiler for `agents...bundle` | +| `lib/agents.nix` | Nix compiler for `agents...config.build.bundle` | | `tartarus/` | Python harness: config, bundle loading, provider, loop, broker, jail | | `tests/` | Unit and integration tests | | `PLAN.md` | Architecture, contract details, and implementation history | diff --git a/agent-modules/default.nix b/agent-modules/default.nix index fa3c7d5..aa47263 100644 --- a/agent-modules/default.nix +++ b/agent-modules/default.nix @@ -1,102 +1,94 @@ { lib }: -let - empty_grants = { - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; - }; -in -{ - bash = { pkgs, ... }: { - name = "bash"; - description = '' - Run a shell command using only tools available in the shell. The work - tree is writable and there is no network. Each call requires approval. - ''; - policy = "ask-always"; - params.command = { - type = "string"; - description = "The command line to run inside the jail."; - required = true; - enum = null; - }; - grants = empty_grants // { - packages = [ pkgs.bash ]; - writable = [ "." ]; +rec { + bash = + { pkgs, ... }: + { + capabilities.bash = { + description = '' + Run a shell command using only tools available in the shell. The work + tree is writable and there is no network. Each call requires approval. + ''; + policy = "ask-always"; + params.command = { + type = "string"; + description = "The command line to run inside the jail."; + required = true; + }; + grants = { + packages = [ pkgs.bash ]; + writable = [ "." ]; + }; + runner = "bash -c {command}"; + }; }; - runner = "bash -c {command}"; - }; - read = { pkgs, ... }: { - name = "read"; - description = "Read a file in the work tree, optionally limited by line range."; - policy = "auto"; - params = { - path = { - type = "string"; - description = "Path to read, relative to the work tree."; - required = true; - enum = null; - }; - start_line = { - type = "integer"; - description = "First line to read, 1-based. Defaults to 1."; - required = false; - enum = null; - }; - end_line = { - type = "integer"; - description = "Last line to read, inclusive. Defaults to the end of the file."; - required = false; - enum = null; + read = + { pkgs, ... }: + { + capabilities.read = { + description = "Read a file in the work tree, optionally limited by line range."; + policy = "auto"; + params = { + path = { + type = "string"; + description = "Path to read, relative to the work tree."; + required = true; + }; + start_line = { + type = "integer"; + description = "First line to read, 1-based. Defaults to 1."; + }; + end_line = { + type = "integer"; + description = "Last line to read, inclusive. Defaults to the end of the file."; + }; + }; + grants.packages = [ + pkgs.bash + pkgs.gnused + ]; + runner = '' + bash -c 'start=$1; end=$2; if [ -z "$start" ]; then start=1; fi; range="$start,\$"; if [ -n "$end" ]; then range="$start,$end"; fi; sed -n "$range"p "$3"' _ {start_line} {end_line} {path} + ''; }; }; - grants = empty_grants // { - packages = [ - pkgs.bash - pkgs.gnused - ]; - }; - runner = '' - bash -c 'start=$1; end=$2; if [ -z "$start" ]; then start=1; fi; range="$start,\$"; if [ -n "$end" ]; then range="$start,$end"; fi; sed -n "$range"p "$3"' _ {start_line} {end_line} {path} - ''; - }; - write = { pkgs, ... }: { - name = "write"; - description = "Create or overwrite a file in the work tree."; - policy = "ask-once"; - params = { - path = { - type = "string"; - description = "Path to write, relative to the work tree."; - required = true; - enum = null; - }; - content = { - type = "string"; - description = "Complete file content."; - required = true; - enum = null; + write = + { pkgs, ... }: + { + capabilities.write = { + description = "Create or overwrite a file in the work tree."; + policy = "ask-once"; + params = { + path = { + type = "string"; + description = "Path to write, relative to the work tree."; + required = true; + }; + content = { + type = "string"; + description = "Complete file content."; + required = true; + }; + }; + grants = { + packages = [ + pkgs.bash + pkgs.coreutils + ]; + writable = [ "." ]; + }; + runner = '' + bash -c 'mkdir -p "$(dirname "$1")"; printf %s "$2" > "$1"' _ {path} {content} + ''; }; }; - grants = empty_grants // { - packages = [ - pkgs.bash - pkgs.coreutils - ]; - writable = [ "." ]; - }; - runner = '' - bash -c 'mkdir -p "$(dirname "$1")"; printf %s "$2" > "$1"' _ {path} {content} - ''; - }; edit = { pkgs, ... }: let - edit-file = pkgs.writers.writePython3Bin "edit-file" { flakeIgnore = [ "E501" ]; } '' + editFile = pkgs.writers.writePython3Bin "edit-file" { flakeIgnore = [ "E501" ]; } '' import pathlib import sys @@ -129,46 +121,42 @@ in ''; in { - name = "edit"; - description = "Replace an exact string in a work-tree file. Defaults to requiring a single match; set replace_all to substitute every occurrence. Reports how many occurrences were replaced."; - policy = "ask-once"; - params = { - path = { - type = "string"; - description = "Path to edit, relative to the work tree."; - required = true; - enum = null; - }; - old_str = { - type = "string"; - description = "Exact text to replace. Unless replace_all is set, it must appear exactly once."; - required = true; - enum = null; - }; - new_str = { - type = "string"; - description = "Replacement text."; - required = true; - enum = null; + capabilities.edit = { + description = "Replace an exact string in a work-tree file. Defaults to requiring a single match; set replace_all to substitute every occurrence. Reports how many occurrences were replaced."; + policy = "ask-once"; + params = { + path = { + type = "string"; + description = "Path to edit, relative to the work tree."; + required = true; + }; + old_str = { + type = "string"; + description = "Exact text to replace. Unless replace_all is set, it must appear exactly once."; + required = true; + }; + new_str = { + type = "string"; + description = "Replacement text."; + required = true; + }; + replace_all = { + type = "boolean"; + description = "Replace every occurrence instead of requiring exactly one. Defaults to false."; + }; }; - replace_all = { - type = "boolean"; - description = "Replace every occurrence instead of requiring exactly one. Defaults to false."; - required = false; - enum = null; + grants = { + packages = [ editFile ]; + writable = [ "." ]; }; + runner = "edit-file {path} {old_str} {new_str} {replace_all}"; }; - grants = empty_grants // { - packages = [ edit-file ]; - writable = [ "." ]; - }; - runner = "edit-file {path} {old_str} {new_str} {replace_all}"; }; glob = { pkgs, ... }: let - glob-files = pkgs.writers.writePython3Bin "glob-files" { flakeIgnore = [ "E501" ]; } '' + globFiles = pkgs.writers.writePython3Bin "glob-files" { flakeIgnore = [ "E501" ]; } '' import pathlib import sys @@ -179,97 +167,105 @@ in ''; in { - name = "glob"; - description = "Find work-tree paths matching a glob pattern."; - policy = "auto"; - params = { - pattern = { - type = "string"; - description = "Glob pattern to match, such as '**/*.nix'."; - required = true; - enum = null; + capabilities.glob = { + description = "Find work-tree paths matching a glob pattern."; + policy = "auto"; + params = { + pattern = { + type = "string"; + description = "Glob pattern to match, such as '**/*.nix'."; + required = true; + }; + path = { + type = "string"; + description = "Root path to search from, relative to the work tree. Defaults to '.'."; + }; }; - path = { + grants.packages = [ globFiles ]; + runner = "glob-files {pattern} {path}"; + }; + }; + + list = + { pkgs, ... }: + { + capabilities.list = { + description = "List a directory in the work tree."; + policy = "auto"; + params.path = { type = "string"; - description = "Root path to search from, relative to the work tree. Defaults to '.'."; - required = false; - enum = null; + description = "Directory to list, relative to the work tree. Defaults to '.'."; }; + grants.packages = [ + pkgs.bash + pkgs.coreutils + ]; + runner = "bash -c 'path=$1; if [ -z \"$path\" ]; then path=.; fi; ls -la \"$path\"' _ {path}"; }; - grants = empty_grants // { - packages = [ glob-files ]; - }; - runner = "glob-files {pattern} {path}"; }; - list = { pkgs, ... }: { - name = "list"; - description = "List a directory in the work tree."; - policy = "auto"; - params.path = { - type = "string"; - description = "Directory to list, relative to the work tree. Defaults to '.'."; - required = false; - enum = null; - }; - grants = empty_grants // { - packages = [ - pkgs.bash - pkgs.coreutils - ]; + grep = + { pkgs, ... }: + { + capabilities.grep = { + description = "Search file contents in the work tree with ripgrep."; + policy = "auto"; + params = { + pattern = { + type = "string"; + description = "Regex pattern to search for."; + required = true; + }; + path = { + type = "string"; + description = "Path to search, relative to the work tree. Defaults to '.'."; + }; + glob = { + type = "string"; + description = "Optional ripgrep glob filter."; + }; + }; + grants.packages = [ + pkgs.bash + pkgs.ripgrep + ]; + runner = '' + bash -c 'target=$2; if [ -z "$target" ]; then target=.; fi; if [ -n "$3" ]; then rg --glob "$3" "$1" "$target"; else rg "$1" "$target"; fi' _ {pattern} {path} {glob} + ''; + }; }; - runner = "bash -c 'path=$1; if [ -z \"$path\" ]; then path=.; fi; ls -la \"$path\"' _ {path}"; - }; - grep = { pkgs, ... }: { - name = "grep"; - description = "Search file contents in the work tree with ripgrep."; - policy = "auto"; - params = { - pattern = { - type = "string"; - description = "Regex pattern to search for."; - required = true; - enum = null; - }; - path = { - type = "string"; - description = "Path to search, relative to the work tree. Defaults to '.'."; - required = false; - enum = null; - }; - glob = { - type = "string"; - description = "Optional ripgrep glob filter."; - required = false; - enum = null; + webFetch = + { pkgs, ... }: + { + capabilities.web_fetch = { + description = "Fetch any HTTP(S) URL through the scoped HTTP proxy after per-call approval."; + policy = "ask-always"; + params.url = { + type = "string"; + description = "Full HTTP(S) URL to fetch."; + required = true; + }; + grants = { + packages = [ pkgs.curl ]; + network.allowedHosts = [ "*" ]; + }; + runner = "curl -fsSL {url}"; }; }; - grants = empty_grants // { - packages = [ - pkgs.bash - pkgs.ripgrep - ]; - }; - runner = '' - bash -c 'target=$2; if [ -z "$target" ]; then target=.; fi; if [ -n "$3" ]; then rg --glob "$3" "$1" "$target"; else rg "$1" "$target"; fi' _ {pattern} {path} {glob} - ''; - }; - web_fetch = { pkgs, ... }: { - name = "web_fetch"; - description = "Fetch any HTTP(S) URL through the scoped HTTP proxy after per-call approval."; - policy = "ask-always"; - params.url = { - type = "string"; - description = "Full HTTP(S) URL to fetch."; - required = true; - enum = null; - }; - grants = empty_grants // { - packages = [ pkgs.curl ]; - network.allowedHosts = [ "*" ]; - }; - runner = "curl -fsSL {url}"; + coding = { + imports = [ + read + glob + list + grep + write + edit + bash + webFetch + ]; }; + + default = coding; } diff --git a/agent.nix b/agent.nix index 59466fa..23fe354 100644 --- a/agent.nix +++ b/agent.nix @@ -1,450 +1,285 @@ -# The example agent. One agent named `default`, whose `capabilities` is a plain -# list of capability specs and capability modules. Each spec self-identifies via -# `name`; the lib keys them by it. Common coding-agent tools come from -# `agentModules`; showcase-specific tools stay inline. The lib accepts both -# plain attrsets and module functions. To add a second agent, add another named -# entry alongside `default`. - -{ pkgs, agentModules }: - { - default = { - # The agent's model: the backend a model id is only meaningful within - # (`baseUrl`, `name`, optional `provider` type) plus its inference knobs - # (`maxTokens`, `sampling`). Optional — omit to inherit the harness defaults. - # API keys and request headers are never declared here; they stay in the - # environment (TARTARUS_API_KEY / OPENCODE_API_KEY, TARTARUS_EXTRA_HEADERS). A - # set env var still overrides these, so the same agent can be pointed at a - # different backend without editing the flake. - model = { - baseUrl = "https://opencode.ai/zen/v1"; - name = "glm-5.2"; - # Generous completion budget for multi-file edits (GLM serves up to 128k - # output); temperature 0.6 keeps coding focused without going robotic. - maxTokens = 32768; - sampling = { - temperature = 0.6; - }; - }; - - # The agent's shell: the baseline PATH every jailed tool call starts with, - # before per-capability grants are layered on. Declared inline here as a plain - # package list, which the lib wraps into a devShell. Omit `shell` entirely to - # fall back to the minimal default (bash + coreutils); set it to an existing - # devShell derivation to reuse one instead of declaring it in-line. Capabilities - # still carry their own `grants.packages`, so keep this lean. - shell = with pkgs; [ - bash - coreutils - ]; + pkgs, + tartarus, + ... +}: - # The agent's persona. Omit to fall back to the harness default. - systemPrompt = '' - You are an agent operating inside Tartarus, a capability-brokered - environment. Your tools are capabilities: each is a declared, auditable - reach beyond your sealed workspace, such as running a command, reading or - writing a path, or contacting a host. Use them whenever they serve the - user's goal. Coding is one domain among many; reading and reasoning over - data, searching, fetching information, and producing artifacts are all - first-class. +{ + imports = [ + tartarus.modules.coding + ]; - You run in a "shell" that holds only the tools declared for you: nothing - from the host, no ambient network, no filesystem beyond your work tree. Each - capability carries a policy. Some run automatically, some need the human to - approve the exact access first, some are denied. Treat approvals and denials - as normal; when denied, adapt instead of insisting. Binaries and network - access are granted only for the single call that needs them, so never call a - tool's packages permanently installed, and never assume reach you were not - given. + name = "default"; - Be precise and honest about what you did and what you could not. Reach for - the narrowest capability that does the job. - ''; + model = { + baseUrl = "https://opencode.ai/zen/v1"; + name = "glm-5.2"; + maxTokens = 32768; + sampling = { + temperature = 0.6; + }; + }; - capabilities = [ - # Read-only work-tree introspection. These run automatically because they - # only read /work and have no network or writable grants. - agentModules.read - agentModules.glob - agentModules.list - agentModules.grep + systemPrompt = '' + You are an agent operating inside Tartarus, a capability-brokered + environment. Your tools are capabilities: each is a declared, auditable + reach beyond your sealed workspace, such as running a command, reading or + writing a path, or contacting a host. Use them whenever they serve the + user's goal. Coding is one domain among many; reading and reasoning over + data, searching, fetching information, and producing artifacts are all + first-class. - { - name = "jq"; - description = "Run a jq query against a JSON file in the work tree."; - policy = "auto"; - params = { - path = { - type = "string"; - description = "JSON file to query, relative to the work tree."; - required = true; - enum = null; - }; - filter = { - type = "string"; - description = "jq filter expression, such as '.dependencies'."; - required = true; - enum = null; - }; - }; - grants = { - packages = [ pkgs.jq ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; - }; - runner = "jq {filter} {path}"; - } + You run in a "shell" that holds only the tools declared for you: nothing + from the host, no ambient network, no filesystem beyond your work tree. Each + capability carries a policy. Some run automatically, some need the human to + approve the exact access first, some are denied. Treat approvals and denials + as normal; when denied, adapt instead of insisting. Binaries and network + access are granted only for the single call that needs them, so never call a + tool's packages permanently installed, and never assume reach you were not + given. - # Repository state is read-only but high-value for coding agents. - { - name = "git_status"; - description = "Show concise Git working tree status."; - policy = "auto"; - params = { }; - grants = { - packages = [ pkgs.git ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; - }; - runner = "git status --short"; - } + Be precise and honest about what you did and what you could not. Reach for + the narrowest capability that does the job. + ''; - { - name = "git_diff"; - description = "Show unstaged Git diff for the whole work tree or one path."; - policy = "auto"; - params.path = { + capabilities = { + jq = { + description = "Run a jq query against a JSON file in the work tree."; + policy = "auto"; + params = { + path = { type = "string"; - description = "Optional path to diff, relative to the work tree."; - required = false; - enum = null; - }; - grants = { - packages = [ - pkgs.bash - pkgs.git - ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; - }; - runner = "bash -c 'if [ -n \"$1\" ]; then git diff -- \"$1\"; else git diff; fi' _ {path}"; - } - - { - name = "git_log"; - description = "Show recent Git commits, optionally limited to one path."; - policy = "auto"; - params = { - limit = { - type = "integer"; - description = "Maximum number of commits to show. Defaults to 20."; - required = false; - enum = null; - }; - path = { - type = "string"; - description = "Optional path to limit history to, relative to the work tree."; - required = false; - enum = null; - }; - }; - grants = { - packages = [ - pkgs.bash - pkgs.git - ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; - }; - runner = "bash -c 'limit=$1; path=$2; if [ -z \"$limit\" ]; then limit=20; fi; if [ -n \"$path\" ]; then git log --oneline -n \"$limit\" -- \"$path\"; else git log --oneline -n \"$limit\"; fi' _ {limit} {path}"; - } - - { - name = "git_show"; - description = "Show one Git revision or a file from one revision."; - policy = "auto"; - params = { - revision = { - type = "string"; - description = "Git revision to inspect. Defaults to HEAD."; - required = false; - enum = null; - }; - path = { - type = "string"; - description = "Optional file path to show from the revision."; - required = false; - enum = null; - }; + description = "JSON file to query, relative to the work tree."; + required = true; }; - grants = { - packages = [ - pkgs.bash - pkgs.git - ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; + filter = { + type = "string"; + description = "jq filter expression, such as '.dependencies'."; + required = true; }; - runner = "bash -c 'revision=$1; path=$2; if [ -z \"$revision\" ]; then revision=HEAD; fi; if [ -n \"$path\" ]; then git show --end-of-options \"$revision:$path\"; else git show --stat --patch --end-of-options \"$revision\"; fi' _ {revision} {path}"; - } + }; + grants.packages = [ pkgs.jq ]; + runner = "jq {filter} {path}"; + }; - # Work-tree mutation. These are ask-once so routine edit loops stay - # ergonomic while the human still approves write access per session. - agentModules.write - agentModules.edit + git_status = { + description = "Show concise Git working tree status."; + policy = "auto"; + grants.packages = [ pkgs.git ]; + runner = "git status --short"; + }; - # General command execution inside the shell. This is still jailed and - # networkless, but arbitrary shell commands deserve per-call approval. - agentModules.bash + git_diff = { + description = "Show unstaged Git diff for the whole work tree or one path."; + policy = "auto"; + params.path = { + type = "string"; + description = "Optional path to diff, relative to the work tree."; + }; + grants.packages = [ + pkgs.bash + pkgs.git + ]; + runner = "bash -c 'if [ -n \"$1\" ]; then git diff -- \"$1\"; else git diff; fi' _ {path}"; + }; - # Long-running work that should not block the turn. `kind = "background"` - # launches the command detached and returns a handle (such as bg-1) - # immediately; the control capabilities below inspect and stop it, and a - # completion notice is injected into the conversation when it exits. - { - name = "background_bash"; - description = '' - Start a shell command running in the background and return a task - handle (such as bg-1) right away, without waiting for it to finish. - Use bg_status and bg_output to follow it and bg_stop to end it. The - work tree is writable; there is no network. - ''; - policy = "ask-always"; - kind = "background"; - params.command = { - type = "string"; - description = "The command line to run detached inside the jail."; - required = true; - enum = null; + git_log = { + description = "Show recent Git commits, optionally limited to one path."; + policy = "auto"; + params = { + limit = { + type = "integer"; + description = "Maximum number of commits to show. Defaults to 20."; }; - grants = { - packages = [ pkgs.bash ]; - network.allowedHosts = [ ]; - writable = [ "." ]; - unrestricted = false; + path = { + type = "string"; + description = "Optional path to limit history to, relative to the work tree."; }; - runner = "bash -c {command}"; - } + }; + grants.packages = [ + pkgs.bash + pkgs.git + ]; + runner = "bash -c 'limit=$1; path=$2; if [ -z \"$limit\" ]; then limit=20; fi; if [ -n \"$path\" ]; then git log --oneline -n \"$limit\" -- \"$path\"; else git log --oneline -n \"$limit\"; fi' _ {limit} {path}"; + }; - # Control-plane tools. `kind = "control"` capabilities act on the background - # registry rather than the jail, so they carry no runner and no grants. - { - name = "bg_status"; - description = "Report whether a background task is still running, or its exit code."; - policy = "auto"; - kind = "control"; - control = "status"; - params.task = { + git_show = { + description = "Show one Git revision or a file from one revision."; + policy = "auto"; + params = { + revision = { type = "string"; - description = "Background task handle, such as bg-1."; - required = true; - enum = null; + description = "Git revision to inspect. Defaults to HEAD."; }; - grants = { - packages = [ ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; + path = { + type = "string"; + description = "Optional file path to show from the revision."; }; - runner = ""; - } + }; + grants.packages = [ + pkgs.bash + pkgs.git + ]; + runner = "bash -c 'revision=$1; path=$2; if [ -z \"$revision\" ]; then revision=HEAD; fi; if [ -n \"$path\" ]; then git show --end-of-options \"$revision:$path\"; else git show --stat --patch --end-of-options \"$revision\"; fi' _ {revision} {path}"; + }; - { - name = "bg_output"; - description = "Read the accumulated stdout/stderr of a background task."; - policy = "auto"; - kind = "control"; - control = "output"; - params = { - task = { - type = "string"; - description = "Background task handle, such as bg-1."; - required = true; - enum = null; - }; - offset = { - type = "integer"; - description = "Byte offset to read from. Defaults to 0 (the whole log)."; - required = false; - enum = null; - }; - }; - grants = { - packages = [ ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; - }; - runner = ""; - } + background_bash = { + description = '' + Start a shell command running in the background and return a task + handle (such as bg-1) right away, without waiting for it to finish. + Use bg_status and bg_output to follow it and bg_stop to end it. The + work tree is writable; there is no network. + ''; + policy = "ask-always"; + kind = "background"; + params.command = { + type = "string"; + description = "The command line to run detached inside the jail."; + required = true; + }; + grants = { + packages = [ pkgs.bash ]; + writable = [ "." ]; + }; + runner = "bash -c {command}"; + }; + + bg_status = { + description = "Report whether a background task is still running, or its exit code."; + policy = "auto"; + kind = "control"; + control = "status"; + params.task = { + type = "string"; + description = "Background task handle, such as bg-1."; + required = true; + }; + }; - { - name = "bg_stop"; - description = "Stop a running background task by signalling its process group."; - policy = "ask-once"; - kind = "control"; - control = "stop"; - params.task = { + bg_output = { + description = "Read the accumulated stdout/stderr of a background task."; + policy = "auto"; + kind = "control"; + control = "output"; + params = { + task = { type = "string"; description = "Background task handle, such as bg-1."; required = true; - enum = null; }; - grants = { - packages = [ ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = false; + offset = { + type = "integer"; + description = "Byte offset to read from. Defaults to 0 (the whole log)."; }; - runner = ""; - } + }; + }; - # Formats all .nix files in the work tree. It rewrites files, so it is - # gated with ask-once: the human approves write access once per session, - # then nixfmt runs freely inside the writable "." grant. - { - name = "format_nix"; - description = "Format Nix files in the work tree with nixfmt."; - policy = "ask-once"; - params = { }; - grants = { - packages = [ - pkgs.bash - pkgs.findutils - pkgs.nixfmt - ]; - network.allowedHosts = [ ]; - writable = [ "." ]; - unrestricted = false; - }; - runner = "bash -c 'find . -name \"*.nix\" -print0 | xargs -0 nixfmt'"; - } + bg_stop = { + description = "Stop a running background task by signalling its process group."; + policy = "ask-once"; + kind = "control"; + control = "stop"; + params.task = { + type = "string"; + description = "Background task handle, such as bg-1."; + required = true; + }; + }; - { - name = "pytest"; - description = "Run the project test suite without network access."; - policy = "ask-once"; - # Capabilities run unbounded by default; a full test run is the rare case - # that wants a ceiling, so it caps itself at five minutes. Omit `timeout` - # to let a capability run without a limit. - timeout = 300; - params.filter = { - type = "string"; - description = "Optional pytest filter expression."; - required = false; - enum = null; - }; - grants = { - packages = [ - pkgs.bash - pkgs.python3Packages.pytest - ]; - network.allowedHosts = [ ]; - writable = [ "." ]; - unrestricted = false; - }; - runner = "bash -c 'if [ -n \"$1\" ]; then pytest -k \"$1\"; else pytest; fi' _ {filter}"; - } + format_nix = { + description = "Format Nix files in the work tree with nixfmt."; + policy = "ask-once"; + grants = { + packages = [ + pkgs.bash + pkgs.findutils + pkgs.nixfmt + ]; + writable = [ "." ]; + }; + runner = "bash -c 'find . -name \"*.nix\" -print0 | xargs -0 nixfmt'"; + }; - # Narrow artifact output. This demonstrates granting a specific writable - # subdirectory instead of making the whole work tree writable. - { - name = "write_artifact"; - description = "Write a file under the work tree's artifacts directory."; - policy = "ask-always"; - params = { - path = { - type = "string"; - description = "Artifact path under the configured artifacts directory."; - required = true; - enum = null; - }; - content = { - type = "string"; - description = "Complete artifact content."; - required = true; - enum = null; - }; - }; - grants = { - packages = [ - pkgs.bash - pkgs.coreutils - ]; - network.allowedHosts = [ ]; - writable = [ "artifacts" ]; - unrestricted = false; - }; - runner = '' - bash -c 'case "$1" in /*|*..*) echo "artifact path must stay under artifacts" >&2; exit 2;; esac; mkdir -p artifacts "$(dirname "artifacts/$1")"; printf %s "$2" > "artifacts/$1"' _ {path} {content} - ''; - } + pytest = { + description = "Run the project test suite without network access."; + policy = "ask-once"; + timeout = 300; + params.filter = { + type = "string"; + description = "Optional pytest filter expression."; + }; + grants = { + packages = [ + pkgs.bash + pkgs.python3Packages.pytest + ]; + writable = [ "." ]; + }; + runner = "bash -c 'if [ -n \"$1\" ]; then pytest -k \"$1\"; else pytest; fi' _ {filter}"; + }; - # Scoped HTTP egress through the filtering proxy. - { - name = "pypi_versions"; - description = "Query Python package versions through the scoped HTTP proxy."; - policy = "ask-once"; - params.package = { + write_artifact = { + description = "Write a file under the work tree's artifacts directory."; + policy = "ask-always"; + params = { + path = { type = "string"; - description = "Python package name or requirement to inspect."; + description = "Artifact path under the configured artifacts directory."; required = true; - enum = null; - }; - grants = { - packages = [ pkgs.python3Packages.pip ]; - network.allowedHosts = [ - "pypi.org:443" - ]; - writable = [ ]; - unrestricted = false; }; - runner = "pip --no-cache-dir index versions {package}"; - } - - { - name = "fetch_rfc"; - description = "Fetch a plain-text RFC from rfc-editor.org through the scoped HTTP proxy."; - policy = "auto"; - params.number = { - type = "integer"; - description = "RFC number to fetch."; + content = { + type = "string"; + description = "Complete artifact content."; required = true; - enum = null; - }; - grants = { - packages = [ pkgs.curl ]; - network.allowedHosts = [ "www.rfc-editor.org:443" ]; - writable = [ ]; - unrestricted = false; }; - runner = "curl -fsSL https://www.rfc-editor.org/rfc/rfc{number}.txt"; - } + }; + grants = { + packages = [ + pkgs.bash + pkgs.coreutils + ]; + writable = [ "artifacts" ]; + }; + runner = '' + bash -c 'case "$1" in /*|*..*) echo "artifact path must stay under artifacts" >&2; exit 2;; esac; mkdir -p artifacts "$(dirname "artifacts/$1")"; printf %s "$2" > "artifacts/$1"' _ {path} {content} + ''; + }; - # Wildcard HTTP egress is useful for research, but always prompt and - # audit the actual destination reported by the proxy. - agentModules.web_fetch + pypi_versions = { + description = "Query Python package versions through the scoped HTTP proxy."; + policy = "ask-once"; + params.package = { + type = "string"; + description = "Python package name or requirement to inspect."; + required = true; + }; + grants = { + packages = [ pkgs.python3Packages.pip ]; + network.allowedHosts = [ "pypi.org:443" ]; + }; + runner = "pip --no-cache-dir index versions {package}"; + }; - # The big red button. Trusted overlays may flip this to ask-always; the - # manifest validator rejects unrestricted + auto. - { - name = "shell_escape"; - description = "Disabled unrestricted host escape for trusted overlays only."; - policy = "deny"; - params = { }; - grants = { - packages = [ ]; - network.allowedHosts = [ ]; - writable = [ ]; - unrestricted = true; - }; - runner = "bash"; - } - ]; + fetch_rfc = { + description = "Fetch a plain-text RFC from rfc-editor.org through the scoped HTTP proxy."; + policy = "auto"; + params.number = { + type = "integer"; + description = "RFC number to fetch."; + required = true; + }; + grants = { + packages = [ pkgs.curl ]; + network.allowedHosts = [ "www.rfc-editor.org:443" ]; + }; + runner = "curl -fsSL https://www.rfc-editor.org/rfc/rfc{number}.txt"; + }; + + shell_escape = { + description = "Disabled unrestricted host escape for trusted overlays only."; + policy = "deny"; + grants.unrestricted = true; + runner = "bash"; + }; }; } diff --git a/flake.nix b/flake.nix index e6f7282..85c3a0c 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,7 @@ outputs = { + self, nixpkgs, pyproject-nix, uv2nix, @@ -33,49 +34,131 @@ ]; inherit (nixpkgs) lib; eachSystem = lib.genAttrs supportedSystems; - pkgsFor = system: import nixpkgs { inherit system; }; + pkgsFor = system: nixpkgs.legacyPackages.${system}; - # The reusable compiler from real Nix capabilities to agent bundles. - agentsLib = import ./lib/agents.nix { inherit lib; }; - agentModules = import ./agent-modules { inherit lib; }; + agentsLib = import ./lib/agents.nix { inherit lib nixpkgs; }; + modules = import ./agent-modules { inherit lib; }; in { lib = agentsLib; - inherit agentModules; + inherit modules; - # `.#agents...bundle` is the shareable runtime boundary the - # Python harness consumes. We ship one agent named `default`; the lib - # supports many, so downstream flakes call `agentsLib.mkAgents` with their - # own named set. - agents = eachSystem ( - system: - let - pkgs = pkgsFor system; - in - agentsLib.mkAgents { inherit pkgs; } (import ./agent.nix { inherit pkgs agentModules; }) - ); + agents = eachSystem (system: { + default = agentsLib.tartarusAgent { + inherit system; + modules = [ ./agent.nix ]; + specialArgs = { + tartarus = self; + }; + }; + }); checks = eachSystem ( system: let pkgs = pkgsFor system; - moduleNames = [ - "bash" - "read" - "write" - "edit" - "glob" - "list" - "grep" - "web_fetch" + evalModuleAgent = + moduleList: + agentsLib.tartarusAgent { + inherit system; + modules = moduleList; + specialArgs = { + tartarus = self; + }; + }; + evalFails = agent: !(builtins.tryEval (builtins.deepSeq agent.config.build.manifest true)).success; + # A single-capability agent named `bad`, for the failure-case checks. + badCap = capability: evalModuleAgent [ { capabilities.bad = capability; } ]; + defaultManifest = self.agents.${system}.default.config.build.manifest; + minimalAgent = evalModuleAgent [ + { + capabilities.read_package_json = { + description = "Read package.json from the work tree."; + policy = "auto"; + runner = "cat package.json"; + }; + } + ]; + profileAgent = evalModuleAgent [ + modules.coding ]; - resolvedCatalog = agentsLib.resolveCapabilities { inherit pkgs; } ( - map (moduleName: agentModules.${moduleName}) moduleNames - ); - defaultManifest = - (agentsLib.mkAgents { inherit pkgs; } (import ./agent.nix { inherit pkgs agentModules; })) - .default.manifest; - catalogCapabilityNames = lib.attrNames resolvedCatalog; + inlineAgent = evalModuleAgent [ + { + capabilities.read_package_json = { + description = "Read package.json with jq."; + policy = "auto"; + params = { }; + grants.packages = [ pkgs.jq ]; + runner = "jq . package.json"; + }; + } + ]; + multipleAgents = { + default = evalModuleAgent [ modules.read ]; + research = evalModuleAgent [ modules.webFetch ]; + }; + # A bad capability is rejected by one of two fail-closed layers, tested + # separately so a regression in either surfaces on its own. + + # Layer 1: the module schema — option types and the required `policy` + # option reject a malformed declaration before any rule runs. + schemaFailureAgents = { + missing-policy = badCap { runner = "true"; }; + invalid-policy = badCap { + policy = "sometimes"; + runner = "true"; + }; + invalid-grants = badCap { + policy = "auto"; + runner = "true"; + grants = "bad"; + }; + }; + + # Layer 2: capabilityAssertions — each case is otherwise type-valid, so + # it can only fail via the one capability rule it names. Every rule has a + # case here. + validationFailureAgents = { + unrestricted-auto = badCap { + policy = "auto"; + runner = "true"; + grants.unrestricted = true; + }; + background-timeout = badCap { + policy = "ask-always"; + kind = "background"; + timeout = 1; + runner = "true"; + }; + background-unrestricted = badCap { + policy = "ask-always"; + kind = "background"; + runner = "true"; + grants.unrestricted = true; + }; + control-missing-control = badCap { + policy = "auto"; + kind = "control"; + }; + control-on-command = badCap { + policy = "auto"; + control = "status"; + runner = "true"; + }; + control-runner = badCap { + policy = "auto"; + kind = "control"; + control = "status"; + runner = "true"; + }; + control-grants = badCap { + policy = "auto"; + kind = "control"; + control = "status"; + grants.packages = [ pkgs.bash ]; + }; + command-missing-runner = badCap { policy = "auto"; }; + }; expectedCapabilityNames = [ "bash" "edit" @@ -116,9 +199,19 @@ ]; checksPassed = lib.assertMsg ( - (builtins.sort builtins.lessThan catalogCapabilityNames) + (builtins.sort builtins.lessThan (lib.attrNames profileAgent.config.capabilities)) == (builtins.sort builtins.lessThan expectedCapabilityNames) - ) "agentModules must resolve to the expected capability names" + ) "coding profile must expose the expected capability names" + && lib.assertMsg ( + minimalAgent.config.build.manifest.capabilities ? read_package_json + ) "minimal module-authored agent must compile" + && lib.assertMsg ( + inlineAgent.config.build.manifest.capabilities.read_package_json.grants.packageBins != [ ] + ) "inline module capability must compile package grants" + && lib.assertMsg ( + multipleAgents.default.config.build.manifest.capabilities ? read + && multipleAgents.research.config.build.manifest.capabilities ? web_fetch + ) "multiple agents under agents. must compile" && lib.assertMsg ( (builtins.sort builtins.lessThan defaultToolNames) == (builtins.sort builtins.lessThan expectedDefaultTools) @@ -133,7 +226,9 @@ && lib.assertMsg ( defaultManifest.capabilities.shell_escape.grants.unrestricted && !(builtins.elem "shell_escape" defaultToolNames) - ) "shell_escape must stay denied and absent from tools"; + ) "shell_escape must stay denied and absent from tools" + && lib.assertMsg (lib.all evalFails (lib.attrValues schemaFailureAgents)) "malformed capability declarations must fail the module schema" + && lib.assertMsg (lib.all evalFails (lib.attrValues validationFailureAgents)) "type-valid capabilities that violate a capability rule must fail capabilityAssertions"; in { agent-modules = pkgs.runCommand "tartarus-agent-modules-check" { } '' @@ -153,7 +248,7 @@ }; in { - default = tartarus; + default = self.agents.${system}.default.config.build.bundle; inherit tartarus; } ); diff --git a/lib/agents.nix b/lib/agents.nix index 9d9b1c3..aa75ca5 100644 --- a/lib/agents.nix +++ b/lib/agents.nix @@ -1,6 +1,11 @@ -{ lib }: +{ + lib, + nixpkgs, +}: let + inherit (lib) types; + paramSchema = param: { @@ -20,14 +25,6 @@ let parameters = paramsToSchema capability.params; }; - packageBin = - package: - let - binRoot = packageBinRoot package; - hasBinDir = builtins.pathExists (binRoot + "/bin"); - in - if hasBinDir then "${binRoot}/bin" else "${package}/bin"; - packageBinRoot = package: let @@ -35,35 +32,35 @@ let in if builtins.pathExists (binOutput + "/bin") then binOutput else package; - # The closure of a grant's packages, emitted as a store path to the - # newline-list `closureInfo` produces. The harness binds exactly these paths - # into the jail, so a capability reaches its declared closure and nothing else. - # A store path string, not IFD: the file is realized by the `grantClosures` - # build and read by the harness afterward, never during eval. - closureFile = pkgs: roots: "${pkgs.closureInfo { rootPaths = roots; }}/store-paths"; + # The bin path is always "/bin"; `packageBinRoot` already resolved which + # store path holds it, so no second probe is needed. + packageBin = package: "${packageBinRoot package}/bin"; + # `info` is a capability's precomputed grant info: the resolved package roots + # and the single `closureInfo` derivation built from them (see `tartarusAgent`), + # so the closure is realized once and shared between the manifest and the bundle. grantToJson = - pkgs: grant: - let - packageRoots = map packageBinRoot (grant.packages or [ ]); - in + info: grant: builtins.removeAttrs grant [ "packages" ] // { - packageBins = map packageBin (grant.packages or [ ]); - closure = closureFile pkgs packageRoots; + packageBins = map (root: "${root}/bin") info.roots; + closure = "${info.closure}/store-paths"; }; capabilityToJson = - pkgs: capability: - capability + info: capability: + (builtins.removeAttrs capability [ "runner" ]) // { - grants = grantToJson pkgs capability.grants; - }; + grants = grantToJson info capability.grants; + } + // lib.optionalAttrs (capability.runner != null) { inherit (capability) runner; }; compileManifest = - pkgs: capabilities: + grantInfo: capabilities: let - compiledCapabilities = lib.mapAttrs (_: capability: capabilityToJson pkgs capability) capabilities; + compiledCapabilities = lib.mapAttrs ( + name: capability: capabilityToJson grantInfo.${name} capability + ) capabilities; exposed = lib.filterAttrs (_: capability: capability.policy != "deny") compiledCapabilities; in { @@ -71,152 +68,389 @@ let capabilities = compiledCapabilities; }; - # A capability self-identifies via `name`. Accept either a plain attrset (when - # `pkgs` is already in scope) or a function of `moduleArgs` (to share it across - # flakes). `name` is stripped from the body because the attr key carries it. - resolveCapabilities = - moduleArgs: capabilityModules: - lib.foldl' ( - resolved: capabilityModule: - let - capability = - if lib.isFunction capabilityModule then capabilityModule moduleArgs else capabilityModule; - name = capability.name or (throw "Tartarus: a capability is missing its `name`"); - in - if resolved ? ${name} then - throw "Tartarus: duplicate capability name '${name}'" - else - resolved // { ${name} = builtins.removeAttrs capability [ "name" ]; } - ) { } capabilityModules; - - # The default shell: the always-present baseline PATH inside every jailed call, - # before any capability grant is layered on. Kept deliberately minimal so the - # shell reflects the capability-OS model — each tool brings its own packages via - # `grants.packages`. Agents that want a richer baseline declare their own `shell`. - defaultShellPackages = pkgs: [ - pkgs.bash - pkgs.coreutils - ]; - - # An agent's `shell` may be omitted (use the minimal default), given as a plain - # list of packages (wrapped into a shell here), or given as a devShell - # derivation directly (reused from the flake or declared inline). Its PATH is - # baked into the bundle manifest; the shell output remains useful for humans. - resolveShell = - pkgs: shell: - if shell == null then - pkgs.mkShellNoCC { packages = defaultShellPackages pkgs; } - else if lib.isList shell then - pkgs.mkShellNoCC { packages = shell; } - else - shell; - - # The packages whose closure must be bound for the baseline shell PATH to work - # inside the jail. For the list and default forms we know the packages exactly; - # for a devShell-derivation `shell` we fall back to its inputs (so a custom - # devShell must declare its runtime PATH deps as packages — by design). - shellPackagesOf = - pkgs: shell: - if shell == null then - defaultShellPackages pkgs - else if lib.isList shell then - shell - else - (shell.buildInputs or [ ]) ++ (shell.nativeBuildInputs or [ ]); - - mkAgent = - moduleArgs: + modelType = types.submodule { + options = { + provider = lib.mkOption { + type = types.nullOr types.str; + default = null; + }; + baseUrl = lib.mkOption { + type = types.nullOr types.str; + default = null; + }; + name = lib.mkOption { + type = types.nullOr types.str; + default = null; + }; + maxTokens = lib.mkOption { + type = types.nullOr types.ints.positive; + default = null; + }; + sampling = lib.mkOption { + type = types.nullOr (types.attrsOf types.number); + default = null; + }; + }; + }; + + paramType = types.submodule { + options = { + type = lib.mkOption { + type = types.enum [ + "string" + "integer" + "boolean" + "array" + ]; + }; + description = lib.mkOption { + type = types.str; + default = ""; + }; + required = lib.mkOption { + type = types.bool; + default = false; + }; + enum = lib.mkOption { + type = types.nullOr (types.listOf types.anything); + default = null; + }; + }; + }; + + grantOpensReach = + grant: + grant.packages != [ ] + || grant.network.allowedHosts != [ ] + || grant.writable != [ ] + || grant.unrestricted; + + capabilityType = { + options = { + description = lib.mkOption { + type = types.str; + default = ""; + }; + policy = lib.mkOption { + type = types.enum [ + "auto" + "ask-once" + "ask-always" + "deny" + ]; + }; + params = lib.mkOption { + type = types.attrsOf paramType; + default = { }; + }; + grants = { + packages = lib.mkOption { + type = types.listOf types.package; + default = [ ]; + }; + network.allowedHosts = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + }; + writable = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + }; + unrestricted = lib.mkOption { + type = types.bool; + default = false; + }; + }; + runner = lib.mkOption { + type = types.nullOr types.str; + default = null; + }; + kind = lib.mkOption { + type = types.enum [ + "command" + "background" + "control" + ]; + default = "command"; + }; + timeout = lib.mkOption { + type = types.nullOr types.ints.positive; + default = null; + }; + control = lib.mkOption { + type = types.nullOr ( + types.enum [ + "status" + "output" + "stop" + ] + ); + default = null; + }; + }; + }; + + # The cross-field rules Nix types cannot express on their own. Returns the full + # rule list (assertion + message) per capability; the `assertions` option + # aggregates them and build outputs check them lazily via `assertWarn`. + # Standard NixOS shape, so downstream modules can contribute their own. + capabilityAssertions = + name: capability: + [ + { + assertion = !(capability.grants.unrestricted && capability.policy == "auto"); + message = "Tartarus capability '${name}' cannot combine unrestricted = true with policy = \"auto\"."; + } + { + assertion = capability.kind != "background" || capability.timeout == null; + message = "Tartarus background capability '${name}' cannot declare timeout."; + } + { + assertion = capability.kind != "background" || !capability.grants.unrestricted; + message = "Tartarus background capability '${name}' cannot be unrestricted."; + } + { + assertion = capability.kind != "control" || capability.control != null; + message = "Tartarus control capability '${name}' must declare control."; + } + { + assertion = capability.kind == "control" || capability.control == null; + message = "Tartarus capability '${name}' can declare control only when kind = \"control\"."; + } + { + assertion = capability.kind != "control" || capability.runner == null; + message = "Tartarus control capability '${name}' must not declare runner."; + } + { + assertion = capability.kind != "control" || !grantOpensReach capability.grants; + message = "Tartarus control capability '${name}' must not declare grants."; + } + { + assertion = capability.kind == "control" || capability.runner != null; + message = "Tartarus capability '${name}' must declare runner."; + } + ]; + + # A trimmed subset of NixOS's `nixpkgs` module: an agent (or any of its + # modules) configures its package set declaratively, and every module receives + # the result as `pkgs` via `_module.args`. + nixpkgsModule = + { config, ... }: + let + cfg = config.nixpkgs; + in { - capabilities, - systemPrompt ? null, - shell ? null, - grantEnvName ? "tartarus-nix-grants", - # The agent's model: one coherent unit holding the backend a model id is - # only meaningful within (`provider` type, `baseUrl`, `name`) plus its - # inference knobs (`maxTokens`, `sampling`). Optional — an agent that omits - # it inherits the harness defaults (PLAN.md §9). API keys and request - # headers are never declared here: they stay in the environment. - model ? null, - }: + options.nixpkgs = { + hostPlatform = lib.mkOption { + type = types.str; + }; + config = lib.mkOption { + type = types.attrs; + default = { }; + }; + overlays = lib.mkOption { + type = types.listOf ( + lib.mkOptionType { + name = "nixpkgs-overlay"; + description = "nixpkgs overlay"; + check = lib.isFunction; + merge = lib.mergeOneOption; + } + ); + default = [ ]; + }; + pkgs = lib.mkOption { + type = types.raw; + # Reuse the flake's memoized legacyPackages when nothing is customized + # (cheap, shared across the flake); otherwise import per config/overlays. + default = + if cfg.config == { } && cfg.overlays == [ ] then + nixpkgs.legacyPackages.${cfg.hostPlatform} + else + import nixpkgs { + localSystem = cfg.hostPlatform; + inherit (cfg) config overlays; + }; + }; + }; + + config._module.args.pkgs = cfg.pkgs; + }; + + agentModule = + { pkgs, config, ... }: + { + options = { + # The agent's identity: names the bundle derivation, mirroring how + # `config.system.name` (← `networking.hostName`) names a NixOS toplevel. + # Defaults to a constant; set it per agent for descriptive, non-colliding + # bundle labels across multi-agent flakes. + name = lib.mkOption { + type = types.str; + default = "agent"; + }; + systemPrompt = lib.mkOption { + type = types.nullOr types.str; + default = null; + }; + model = lib.mkOption { + type = types.nullOr modelType; + default = null; + }; + shell.packages = lib.mkOption { + type = types.listOf types.package; + default = with pkgs; [ + bash + coreutils + ]; + }; + capabilities = lib.mkOption { + type = types.attrsOf (types.submodule capabilityType); + default = { }; + }; + assertions = lib.mkOption { + type = types.listOf ( + types.submodule { + options = { + assertion = lib.mkOption { type = types.bool; }; + message = lib.mkOption { type = types.str; }; + }; + } + ); + default = [ ]; + internal = true; + }; + warnings = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + internal = true; + }; + }; + + config.assertions = lib.concatLists ( + lib.mapAttrsToList capabilityAssertions config.capabilities + ); + }; + + # The agent's build outputs, living in the module graph at `config.build.*` — + # the agent analog of NixOS's `config.system.build.toplevel`. Assertions and + # warnings are evaluated NixOS-style when a build output is forced: reading + # `config` is free, but realizing the manifest or bundle checks the contract. + buildModule = + { config, pkgs, ... }: let - resolved = resolveCapabilities moduleArgs capabilities; - pkgs = moduleArgs.pkgs; - # The baseline PATH packages: the declared shell plus bashInteractive (the - # shell `nix develop` used to run). The baked `shellPath` and the bound - # `shellClosure` share these roots, so PATH never advertises a binary the - # jail does not bind. cacert carries no bin; it rides the closure only, for - # the CA bundle so TLS works inside the jail for network grants. - shellBinPackages = shellPackagesOf pkgs shell ++ [ pkgs.bashInteractive ]; - shellRoots = map packageBinRoot shellBinPackages ++ [ pkgs.cacert ]; - shellClosureDrv = pkgs.closureInfo { rootPaths = shellRoots; }; - grantClosureDrvs = map ( - capability: - pkgs.closureInfo { - rootPaths = map packageBinRoot (capability.grants.packages or [ ]); + capabilities = config.capabilities; + # One `closureInfo` per capability, shared between the manifest `closure` + # pointer and the bundle's symlinks. `roots` is reused for `packageBins`. + grantInfo = lib.mapAttrs ( + _: capability: + let + roots = map packageBinRoot capability.grants.packages; + in + { + inherit roots; + closure = pkgs.closureInfo { rootPaths = roots; }; } - ) (lib.attrValues resolved); - - # The compiled, fully-resolved manifest: tool/capability contract plus the - # baked baseline PATH, the shell closure pointer, the CA bundle, and the - # optional persona/model. Serialized verbatim into the bundle below. + ) capabilities; + shellBinPackages = config.shell.packages ++ [ pkgs.bashInteractive ]; + shellRootList = map packageBinRoot shellBinPackages; + shellRoots = shellRootList ++ [ pkgs.cacert ]; + shellClosureDrv = pkgs.closureInfo { rootPaths = shellRoots; }; compiledManifest = - compileManifest pkgs resolved + compileManifest grantInfo capabilities // { caBundle = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; shellClosure = "${shellClosureDrv}/store-paths"; - shellPath = lib.concatStringsSep ":" (lib.unique (map packageBin shellBinPackages)); + shellPath = lib.concatStringsSep ":" (lib.unique (map (root: "${root}/bin") shellRootList)); } - // lib.optionalAttrs (systemPrompt != null) { inherit systemPrompt; } - // lib.optionalAttrs (model != null) { inherit model; }; + // lib.optionalAttrs (config.systemPrompt != null) { inherit (config) systemPrompt; } + // lib.optionalAttrs (config.model != null) { inherit (config) model; }; + assertWarn = + result: + let + failed = lib.filter (assertion: !assertion.assertion) config.assertions; + in + if failed != [ ] then + throw ( + "Tartarus agent assertion failures:\n" + + lib.concatMapStringsSep "\n" (assertion: " - ${assertion.message}") failed + ) + else + lib.showWarnings config.warnings result; in { - capabilities = resolved; - - # The agent owns its shell: a devShell kept for `nix develop` ergonomics. - # The harness no longer resolves it — the baseline PATH is baked into the - # manifest's `shellPath` — but it stays a convenient entry point. - shell = resolveShell pkgs shell; - - manifest = compiledManifest; - - # The shippable agent: one derivation whose runtime closure is the whole - # agent. Writing the manifest JSON into $out makes the output reference - # every store path it names (package bins, each grant's `closure` - # store-paths file, the shell closure, the CA bundle, the baked PATH - # entries), so `nix copy ` pulls the complete closure. The symlinks - # force realization and aid debugging. The harness reads - # /manifest.json with no nix calls (tartarus/bundle.py). - bundle = - pkgs.runCommand "${grantEnvName}-bundle" - { - manifestJson = builtins.toJSON compiledManifest; - passAsFile = [ "manifestJson" ]; - closures = [ shellClosureDrv ] ++ grantClosureDrvs; + options.build = { + manifest = lib.mkOption { + type = types.raw; + readOnly = true; + }; + bundle = lib.mkOption { + type = types.package; + readOnly = true; + }; + shell = lib.mkOption { + type = types.package; + readOnly = true; + }; + }; + + config.build = { + manifest = assertWarn compiledManifest; + + shell = assertWarn ( + pkgs.mkShellNoCC { + packages = config.shell.packages; } - '' - mkdir -p "$out/closures" - cp "$manifestJsonPath" "$out/manifest.json" - ln -s ${shellClosureDrv} "$out/closures/shell" - n=0 - for closure in $closures; do - ln -s "$closure" "$out/closures/grant-$n" - n=$((n + 1)) - done - ''; + ); + + bundle = assertWarn ( + pkgs.runCommand "tartarus-${config.name}-bundle" + { + manifestJson = builtins.toJSON compiledManifest; + passAsFile = [ "manifestJson" ]; + closures = [ shellClosureDrv ] ++ map (info: info.closure) (lib.attrValues grantInfo); + } + '' + mkdir -p "$out/closures" + cp "$manifestJsonPath" "$out/manifest.json" + ln -s ${shellClosureDrv} "$out/closures/shell" + n=0 + for closure in $closures; do + ln -s "$closure" "$out/closures/grant-$n" + n=$((n + 1)) + done + '' + ); + }; }; + + # Evaluate an agent's module graph. Returns the `lib.evalModules` result — + # `config`, `options`, `extendModules`, `class`, `type`, `_module` — plus + # `pkgs`, mirroring `nixpkgs.lib.nixosSystem`. Build outputs live at + # `result.config.build.{manifest,bundle,shell}`. + tartarusAgent = + { + system, + modules, + specialArgs ? { }, + }: + let + evaluated = lib.evalModules { + class = "tartarus"; + inherit specialArgs; + modules = [ + agentModule + nixpkgsModule + buildModule + { nixpkgs.hostPlatform = lib.mkDefault system; } + ] + ++ modules; + }; + in + evaluated // { pkgs = evaluated.config.nixpkgs.pkgs; }; + + evalAgentConfig = args: (tartarusAgent args).config; in { - inherit mkAgent resolveCapabilities; - - mkAgents = - moduleArgs: agents: - lib.mapAttrs ( - agentName: agentConfig: - mkAgent moduleArgs ( - agentConfig - // { - grantEnvName = agentConfig.grantEnvName or "tartarus-nix-${agentName}-grants"; - } - ) - ) agents; + inherit tartarusAgent evalAgentConfig; } diff --git a/tartarus/bundle.py b/tartarus/bundle.py index d2c932f..4754dc7 100644 --- a/tartarus/bundle.py +++ b/tartarus/bundle.py @@ -40,7 +40,7 @@ def resolve_bundle(config: Config) -> str: return config.bundle_path system = host_system() - attr = f"{config.flake_ref}#agents.{system}.{config.agent_name}.bundle" + attr = f"{config.flake_ref}#agents.{system}.{config.agent_name}.config.build.bundle" try: out = run_checked(["nix", "build", attr, "--no-link", "--print-out-paths"]) except ProcessError as error: diff --git a/templates/default/agent.nix b/templates/default/agent.nix index 8f67873..1db7fc8 100644 --- a/templates/default/agent.nix +++ b/templates/default/agent.nix @@ -1,33 +1,28 @@ -{ pkgs, agentModules }: +{ + pkgs, + tartarus, + ... +}: { - default = { - systemPrompt = '' - You are a careful coding agent running inside Tartarus. Use only the - tools you have been granted, and prefer the narrowest capability that - does the job. - ''; + imports = [ + tartarus.modules.coding + ]; - shell = with pkgs; [ bash coreutils ]; + name = "default"; - model = { - baseUrl = "https://opencode.ai/zen/v1"; - name = "glm-5.2"; - maxTokens = 32768; - sampling = { - temperature = 0.6; - }; - }; + systemPrompt = '' + You are a careful coding agent running inside Tartarus. Use only the + tools you have been granted, and prefer the narrowest capability that + does the job. + ''; - capabilities = with agentModules; [ - read - list - write - edit - glob - grep - bash - web_fetch - ]; + model = { + baseUrl = "https://opencode.ai/zen/v1"; + name = "glm-5.2"; + maxTokens = 32768; + sampling = { + temperature = 0.6; + }; }; } diff --git a/templates/default/flake.nix b/templates/default/flake.nix index 9e40a92..57b8c30 100644 --- a/templates/default/flake.nix +++ b/templates/default/flake.nix @@ -6,7 +6,13 @@ tartarus.url = "github:alyraffauf/tartarus"; }; - outputs = { nixpkgs, tartarus, self, ... }: + outputs = + { + nixpkgs, + tartarus, + self, + ... + }: let supportedSystems = [ "x86_64-linux" @@ -15,18 +21,15 @@ forEachSystem = nixpkgs.lib.genAttrs supportedSystems; in { - agents = forEachSystem ( - system: - let - pkgs = nixpkgs.legacyPackages.${system}; - in - tartarus.lib.mkAgents { inherit pkgs; } ( - import ./agent.nix { - inherit pkgs; - agentModules = tartarus.agentModules; - } - ) - ); + agents = forEachSystem (system: { + default = tartarus.lib.tartarusAgent { + inherit system; + modules = [ ./agent.nix ]; + specialArgs = { + inherit tartarus; + }; + }; + }); devShells = forEachSystem ( system: @@ -40,11 +43,8 @@ } ); - packages = forEachSystem ( - system: - { - default = self.agents.${system}.default.bundle; - } - ); + packages = forEachSystem (system: { + default = self.agents.${system}.default.config.build.bundle; + }); }; } diff --git a/tests/test_bundle.py b/tests/test_bundle.py index b97afbe..cd24a29 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -174,7 +174,7 @@ def test_resolve_bundle_builds_from_flake(monkeypatch): [ "nix", "build", - f"path:.#agents.{system}.research.bundle", + f"path:.#agents.{system}.research.config.build.bundle", "--no-link", "--print-out-paths", ] diff --git a/tests/test_manifest_loader.py b/tests/test_manifest_loader.py index 1af5848..bafb2e6 100644 --- a/tests/test_manifest_loader.py +++ b/tests/test_manifest_loader.py @@ -340,7 +340,11 @@ def test_non_string_capability_description_is_rejected(): (("echo", "params"), "bad", "params.*object"), (("echo", "params", "message"), "bad", "params.message"), (("echo", "params", "message", "required"), "yes", "required.*valid boolean"), - (("echo", "params", "message", "description"), ["bad"], "description.*valid string"), + ( + ("echo", "params", "message", "description"), + ["bad"], + "description.*valid string", + ), (("echo", "params", "message", "enum"), "red", "enum.*valid list"), ], ) diff --git a/tests/test_provider.py b/tests/test_provider.py index abd0d00..469b59c 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -261,7 +261,9 @@ def test_stream_yields_text_deltas_then_turn_complete(monkeypatch): ] _configure_fake_stream(chunks) - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) + monkeypatch.setattr( + "tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient + ) async def collect(): return [e async for e in provider.stream("sys", [], [])] @@ -286,7 +288,9 @@ def test_stream_raises_on_bad_sse_chunk(monkeypatch, bad_line, expected_msg): provider = _provider() _configure_fake_stream([bad_line]) - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) + monkeypatch.setattr( + "tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient + ) async def collect(): return [e async for e in provider.stream("sys", [], [])] @@ -300,7 +304,9 @@ def test_stream_raises_on_http_error(monkeypatch): provider = _provider() _configure_fake_stream([], status_code=500) - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) + monkeypatch.setattr( + "tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient + ) async def collect(): return [e async for e in provider.stream("sys", [], [])] @@ -322,7 +328,9 @@ def test_stream_passes_tool_call_deltas(monkeypatch): ] _configure_fake_stream(chunks) - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) + monkeypatch.setattr( + "tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient + ) async def collect(): return [e async for e in provider.stream("sys", [], [])]