A declarative, hermetic harness for Nix-defined agents.
0

Configure Feed

Select the types of activity you want to include in your feed.

nix: add capability compiler, flake, and default agent

Aly Raffauf (Jun 27, 2026, 2:55 PM EDT) 61489d1d 5e9936d8

+1157
+845
agent.nix
··· 1 + # The example agent. One agent named `default`, whose `capabilities` is a plain 2 + # list of capability specs and capability modules. Each spec self-identifies via 3 + # `name`; the lib keys them by it. `count_lines` and `edit_file` are written as 4 + # modules — functions of `{ pkgs, packages, ... }` — so they can be lifted out and 5 + # shared across flakes. The rest are plain attrsets because `pkgs` is already in 6 + # scope here. The lib accepts both forms. To add a second agent, add another named 7 + # entry alongside `default`. 8 + 9 + { pkgs }: 10 + 11 + { 12 + default = { 13 + # The agent's model: the backend a model id is only meaningful within 14 + # (`baseUrl`, `name`, optional `provider` type) plus its inference knobs 15 + # (`maxTokens`, `sampling`). Optional — omit to inherit the harness defaults. 16 + # API keys and request headers are never declared here; they stay in the 17 + # environment (TARTARUS_API_KEY / OPENCODE_API_KEY, TARTARUS_EXTRA_HEADERS). A 18 + # set env var still overrides these, so the same agent can be pointed at a 19 + # different backend without editing the flake. 20 + model = { 21 + baseUrl = "https://opencode.ai/zen/v1"; 22 + name = "glm-5.2"; 23 + # Generous completion budget for multi-file edits (GLM serves up to 128k 24 + # output); temperature 0.6 keeps coding focused without going robotic. 25 + maxTokens = 32768; 26 + sampling = { 27 + temperature = 0.6; 28 + }; 29 + }; 30 + 31 + # The agent's shell: the baseline PATH every jailed tool call starts with, 32 + # before per-capability grants are layered on. Declared inline here as a plain 33 + # package list, which the lib wraps into a devShell. Omit `shell` entirely to 34 + # fall back to the minimal default (bash + coreutils); set it to an existing 35 + # devShell derivation to reuse one instead of declaring it in-line. Capabilities 36 + # still carry their own `grants.packages`, so keep this lean. 37 + shell = with pkgs; [ 38 + bash 39 + coreutils 40 + ]; 41 + 42 + # The agent's persona. Omit to fall back to the harness default. 43 + systemPrompt = '' 44 + You are an agent operating inside Tartarus, a capability-brokered 45 + environment. Your tools are capabilities: each is a declared, auditable 46 + reach beyond your sealed workspace, such as running a command, reading or 47 + writing a path, or contacting a host. Use them whenever they serve the 48 + user's goal. Coding is one domain among many; reading and reasoning over 49 + data, searching, fetching information, and producing artifacts are all 50 + first-class. 51 + 52 + You run in a "shell" that holds only the tools declared for you: nothing 53 + from the host, no ambient network, no filesystem beyond your work tree. Each 54 + capability carries a policy. Some run automatically, some need the human to 55 + approve the exact access first, some are denied. Treat approvals and denials 56 + as normal; when denied, adapt instead of insisting. Binaries and network 57 + access are granted only for the single call that needs them, so never call a 58 + tool's packages permanently installed, and never assume reach you were not 59 + given. 60 + 61 + Be precise and honest about what you did and what you could not. Reach for 62 + the narrowest capability that does the job. 63 + ''; 64 + 65 + capabilities = [ 66 + # A self-contained module: it defines its own helper derivation from the 67 + # passed-in `pkgs`, so the whole capability can be copied into another flake. 68 + ( 69 + { pkgs, ... }: 70 + let 71 + count-lines = pkgs.writeShellApplication { 72 + name = "count-lines"; 73 + runtimeInputs = [ 74 + pkgs.coreutils 75 + pkgs.findutils 76 + ]; 77 + text = '' 78 + target="''${1:-.}" 79 + if [ -d "$target" ]; then 80 + find "$target" -type f \ 81 + -not -path './.git/*' \ 82 + -not -path './.tartarus/*' \ 83 + -not -path './.direnv/*' \ 84 + -print0 | 85 + xargs -0 wc -l | 86 + tail -n 1 87 + else 88 + wc -l < "$target" 89 + fi 90 + ''; 91 + }; 92 + in 93 + { 94 + name = "count_lines"; 95 + description = "Count source lines with a tiny package exported by this flake."; 96 + policy = "auto"; 97 + params.path = { 98 + type = "string"; 99 + description = "File or directory to count, relative to the work tree. Defaults to '.'."; 100 + required = false; 101 + enum = null; 102 + }; 103 + grants = { 104 + packages = [ 105 + pkgs.bash 106 + count-lines 107 + ]; 108 + network.allowedHosts = [ ]; 109 + writable = [ ]; 110 + unrestricted = false; 111 + }; 112 + runner = "bash -c 'path=$1; if [ -z \"$path\" ]; then count-lines; else count-lines \"$path\"; fi' _ {path}"; 113 + } 114 + ) 115 + 116 + # Read-only work-tree introspection. These run automatically because they 117 + # only read /work and have no network, writable path, or package grants. 118 + { 119 + name = "read_file"; 120 + description = "Read a file in the work tree, optionally limited by line range."; 121 + policy = "auto"; 122 + params = { 123 + path = { 124 + type = "string"; 125 + description = "Path to read, relative to the work tree."; 126 + required = true; 127 + enum = null; 128 + }; 129 + start_line = { 130 + type = "integer"; 131 + description = "First line to read, 1-based. Defaults to 1."; 132 + required = false; 133 + enum = null; 134 + }; 135 + end_line = { 136 + type = "integer"; 137 + description = "Last line to read, inclusive. Defaults to the end of the file."; 138 + required = false; 139 + enum = null; 140 + }; 141 + }; 142 + grants = { 143 + packages = [ 144 + pkgs.bash 145 + pkgs.gnused 146 + ]; 147 + network.allowedHosts = [ ]; 148 + writable = [ ]; 149 + unrestricted = false; 150 + }; 151 + runner = '' 152 + 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} 153 + ''; 154 + } 155 + 156 + { 157 + name = "list_dir"; 158 + description = "List a directory in the work tree."; 159 + policy = "auto"; 160 + params.path = { 161 + type = "string"; 162 + description = "Directory to list, relative to the work tree. Defaults to '.'."; 163 + required = false; 164 + enum = null; 165 + }; 166 + grants = { 167 + packages = [ 168 + pkgs.bash 169 + pkgs.coreutils 170 + ]; 171 + network.allowedHosts = [ ]; 172 + writable = [ ]; 173 + unrestricted = false; 174 + }; 175 + runner = "bash -c 'path=$1; if [ -z \"$path\" ]; then path=.; fi; ls -la \"$path\"' _ {path}"; 176 + } 177 + 178 + { 179 + name = "search"; 180 + description = "Search file contents in the work tree with ripgrep."; 181 + policy = "auto"; 182 + params = { 183 + pattern = { 184 + type = "string"; 185 + description = "Regex pattern to search for."; 186 + required = true; 187 + enum = null; 188 + }; 189 + path = { 190 + type = "string"; 191 + description = "Path to search, relative to the work tree. Defaults to '.'."; 192 + required = false; 193 + enum = null; 194 + }; 195 + glob = { 196 + type = "string"; 197 + description = "Optional ripgrep glob filter."; 198 + required = false; 199 + enum = null; 200 + }; 201 + }; 202 + grants = { 203 + packages = [ 204 + pkgs.bash 205 + pkgs.ripgrep 206 + ]; 207 + network.allowedHosts = [ ]; 208 + writable = [ ]; 209 + unrestricted = false; 210 + }; 211 + runner = '' 212 + 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} 213 + ''; 214 + } 215 + 216 + { 217 + name = "read_json"; 218 + description = "Read and validate a JSON file in the work tree, printing formatted JSON."; 219 + policy = "auto"; 220 + params.path = { 221 + type = "string"; 222 + description = "JSON file to read, relative to the work tree."; 223 + required = true; 224 + enum = null; 225 + }; 226 + grants = { 227 + packages = [ pkgs.python3 ]; 228 + network.allowedHosts = [ ]; 229 + writable = [ ]; 230 + unrestricted = false; 231 + }; 232 + runner = "python3 -m json.tool {path}"; 233 + } 234 + 235 + { 236 + name = "query_json"; 237 + description = "Run a jq query against a JSON file in the work tree."; 238 + policy = "auto"; 239 + params = { 240 + path = { 241 + type = "string"; 242 + description = "JSON file to query, relative to the work tree."; 243 + required = true; 244 + enum = null; 245 + }; 246 + filter = { 247 + type = "string"; 248 + description = "jq filter expression, such as '.dependencies'."; 249 + required = true; 250 + enum = null; 251 + }; 252 + }; 253 + grants = { 254 + packages = [ pkgs.jq ]; 255 + network.allowedHosts = [ ]; 256 + writable = [ ]; 257 + unrestricted = false; 258 + }; 259 + runner = "jq {filter} {path}"; 260 + } 261 + 262 + # Repository state is read-only but high-value for coding agents. 263 + { 264 + name = "git_status"; 265 + description = "Show concise Git working tree status."; 266 + policy = "auto"; 267 + params = { }; 268 + grants = { 269 + packages = [ pkgs.git ]; 270 + network.allowedHosts = [ ]; 271 + writable = [ ]; 272 + unrestricted = false; 273 + }; 274 + runner = "git status --short"; 275 + } 276 + 277 + { 278 + name = "git_diff"; 279 + description = "Show unstaged Git diff for the whole work tree or one path."; 280 + policy = "auto"; 281 + params.path = { 282 + type = "string"; 283 + description = "Optional path to diff, relative to the work tree."; 284 + required = false; 285 + enum = null; 286 + }; 287 + grants = { 288 + packages = [ 289 + pkgs.bash 290 + pkgs.git 291 + ]; 292 + network.allowedHosts = [ ]; 293 + writable = [ ]; 294 + unrestricted = false; 295 + }; 296 + runner = "bash -c 'if [ -n \"$1\" ]; then git diff -- \"$1\"; else git diff; fi' _ {path}"; 297 + } 298 + 299 + { 300 + name = "git_log"; 301 + description = "Show recent Git commits, optionally limited to one path."; 302 + policy = "auto"; 303 + params = { 304 + limit = { 305 + type = "integer"; 306 + description = "Maximum number of commits to show. Defaults to 20."; 307 + required = false; 308 + enum = null; 309 + }; 310 + path = { 311 + type = "string"; 312 + description = "Optional path to limit history to, relative to the work tree."; 313 + required = false; 314 + enum = null; 315 + }; 316 + }; 317 + grants = { 318 + packages = [ 319 + pkgs.bash 320 + pkgs.git 321 + ]; 322 + network.allowedHosts = [ ]; 323 + writable = [ ]; 324 + unrestricted = false; 325 + }; 326 + 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}"; 327 + } 328 + 329 + { 330 + name = "git_show"; 331 + description = "Show one Git revision or a file from one revision."; 332 + policy = "auto"; 333 + params = { 334 + revision = { 335 + type = "string"; 336 + description = "Git revision to inspect. Defaults to HEAD."; 337 + required = false; 338 + enum = null; 339 + }; 340 + path = { 341 + type = "string"; 342 + description = "Optional file path to show from the revision."; 343 + required = false; 344 + enum = null; 345 + }; 346 + }; 347 + grants = { 348 + packages = [ 349 + pkgs.bash 350 + pkgs.git 351 + ]; 352 + network.allowedHosts = [ ]; 353 + writable = [ ]; 354 + unrestricted = false; 355 + }; 356 + 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}"; 357 + } 358 + 359 + # Work-tree mutation. These are ask-once so routine edit loops stay 360 + # ergonomic while the human still approves write access per session. 361 + { 362 + name = "write_file"; 363 + description = "Create or overwrite a file in the work tree."; 364 + policy = "ask-once"; 365 + params = { 366 + path = { 367 + type = "string"; 368 + description = "Path to write, relative to the work tree."; 369 + required = true; 370 + enum = null; 371 + }; 372 + content = { 373 + type = "string"; 374 + description = "Complete file content."; 375 + required = true; 376 + enum = null; 377 + }; 378 + }; 379 + grants = { 380 + packages = [ 381 + pkgs.bash 382 + pkgs.coreutils 383 + ]; 384 + network.allowedHosts = [ ]; 385 + writable = [ "." ]; 386 + unrestricted = false; 387 + }; 388 + runner = '' 389 + bash -c 'mkdir -p "$(dirname "$1")"; printf %s "$2" > "$1"' _ {path} {content} 390 + ''; 391 + } 392 + 393 + # Like count_lines, this capability defines its own helper derivation so the 394 + # edit logic lives in a readable, testable script rather than a shell 395 + # one-liner, while staying self-contained for copying into another flake. 396 + ( 397 + { pkgs, ... }: 398 + let 399 + edit-file = pkgs.writers.writePython3Bin "edit-file" { flakeIgnore = [ "E501" ]; } '' 400 + import pathlib 401 + import sys 402 + 403 + path = pathlib.Path(sys.argv[1]) 404 + old_text = sys.argv[2] 405 + new_text = sys.argv[3] 406 + replace_all = sys.argv[4] == "True" 407 + 408 + text = path.read_text() 409 + count = text.count(old_text) 410 + 411 + if count == 0: 412 + sys.exit("no occurrence of old_str found") 413 + 414 + # Require an unambiguous target unless the caller opts into a sweep. 415 + # Reporting each match's line lets the model widen old_str instead of 416 + # falling back to a whole-file write_file. 417 + if not replace_all and count != 1: 418 + lines = [ 419 + text.count("\n", 0, i) + 1 420 + for i in range(len(text)) 421 + if text.startswith(old_text, i) 422 + ] 423 + locations = ", ".join(str(line) for line in lines) 424 + sys.exit( 425 + f"expected exactly one match, found {count} at lines " 426 + f"{locations}; add surrounding context or set replace_all" 427 + ) 428 + 429 + replacements = count if replace_all else 1 430 + path.write_text(text.replace(old_text, new_text, replacements)) 431 + print(f"replaced {replacements} occurrence(s) in {path}") 432 + ''; 433 + in 434 + { 435 + name = "edit_file"; 436 + 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."; 437 + policy = "ask-once"; 438 + params = { 439 + path = { 440 + type = "string"; 441 + description = "Path to edit, relative to the work tree."; 442 + required = true; 443 + enum = null; 444 + }; 445 + old_str = { 446 + type = "string"; 447 + description = "Exact text to replace. Unless replace_all is set, it must appear exactly once."; 448 + required = true; 449 + enum = null; 450 + }; 451 + new_str = { 452 + type = "string"; 453 + description = "Replacement text."; 454 + required = true; 455 + enum = null; 456 + }; 457 + replace_all = { 458 + type = "boolean"; 459 + description = "Replace every occurrence instead of requiring exactly one. Defaults to false."; 460 + required = false; 461 + enum = null; 462 + }; 463 + }; 464 + grants = { 465 + packages = [ edit-file ]; 466 + network.allowedHosts = [ ]; 467 + writable = [ "." ]; 468 + unrestricted = false; 469 + }; 470 + runner = "edit-file {path} {old_str} {new_str} {replace_all}"; 471 + } 472 + ) 473 + 474 + # General command execution inside the shell. This is still jailed and 475 + # networkless, but arbitrary shell commands deserve per-call approval. 476 + { 477 + name = "run_command"; 478 + description = '' 479 + Run a shell command using only tools available in the shell. The work 480 + tree is writable and there is no network. Git is available for local 481 + repository inspection. Each call requires approval. 482 + ''; 483 + policy = "ask-always"; 484 + params.command = { 485 + type = "string"; 486 + description = "The command line to run inside the jail."; 487 + required = true; 488 + enum = null; 489 + }; 490 + grants = { 491 + packages = [ 492 + pkgs.bash 493 + ]; 494 + network.allowedHosts = [ ]; 495 + writable = [ "." ]; 496 + unrestricted = false; 497 + }; 498 + runner = "bash -c {command}"; 499 + } 500 + 501 + # Long-running work that should not block the turn. `kind = "background"` 502 + # launches the command detached and returns a handle (such as bg-1) 503 + # immediately; the control capabilities below inspect and stop it, and a 504 + # completion notice is injected into the conversation when it exits. 505 + { 506 + name = "run_background_command"; 507 + description = '' 508 + Start a shell command running in the background and return a task 509 + handle (such as bg-1) right away, without waiting for it to finish. 510 + Use bg_status and bg_output to follow it and bg_stop to end it. The 511 + work tree is writable; there is no network. 512 + ''; 513 + policy = "ask-always"; 514 + kind = "background"; 515 + params.command = { 516 + type = "string"; 517 + description = "The command line to run detached inside the jail."; 518 + required = true; 519 + enum = null; 520 + }; 521 + grants = { 522 + packages = [ pkgs.bash ]; 523 + network.allowedHosts = [ ]; 524 + writable = [ "." ]; 525 + unrestricted = false; 526 + }; 527 + runner = "bash -c {command}"; 528 + } 529 + 530 + # Control-plane tools. `kind = "control"` capabilities act on the background 531 + # registry rather than the jail, so they carry no runner and no grants. 532 + { 533 + name = "bg_status"; 534 + description = "Report whether a background task is still running, or its exit code."; 535 + policy = "auto"; 536 + kind = "control"; 537 + control = "status"; 538 + params.task = { 539 + type = "string"; 540 + description = "Background task handle, such as bg-1."; 541 + required = true; 542 + enum = null; 543 + }; 544 + grants = { 545 + packages = [ ]; 546 + network.allowedHosts = [ ]; 547 + writable = [ ]; 548 + unrestricted = false; 549 + }; 550 + runner = ""; 551 + } 552 + 553 + { 554 + name = "bg_output"; 555 + description = "Read the accumulated stdout/stderr of a background task."; 556 + policy = "auto"; 557 + kind = "control"; 558 + control = "output"; 559 + params = { 560 + task = { 561 + type = "string"; 562 + description = "Background task handle, such as bg-1."; 563 + required = true; 564 + enum = null; 565 + }; 566 + offset = { 567 + type = "integer"; 568 + description = "Byte offset to read from. Defaults to 0 (the whole log)."; 569 + required = false; 570 + enum = null; 571 + }; 572 + }; 573 + grants = { 574 + packages = [ ]; 575 + network.allowedHosts = [ ]; 576 + writable = [ ]; 577 + unrestricted = false; 578 + }; 579 + runner = ""; 580 + } 581 + 582 + { 583 + name = "bg_stop"; 584 + description = "Stop a running background task by signalling its process group."; 585 + policy = "ask-once"; 586 + kind = "control"; 587 + control = "stop"; 588 + params.task = { 589 + type = "string"; 590 + description = "Background task handle, such as bg-1."; 591 + required = true; 592 + enum = null; 593 + }; 594 + grants = { 595 + packages = [ ]; 596 + network.allowedHosts = [ ]; 597 + writable = [ ]; 598 + unrestricted = false; 599 + }; 600 + runner = ""; 601 + } 602 + 603 + { 604 + name = "format_code"; 605 + description = "Format Nix files in the work tree with nixfmt."; 606 + policy = "auto"; 607 + params = { }; 608 + grants = { 609 + packages = [ 610 + pkgs.bash 611 + pkgs.findutils 612 + pkgs.nixfmt 613 + ]; 614 + network.allowedHosts = [ ]; 615 + writable = [ "." ]; 616 + unrestricted = false; 617 + }; 618 + runner = "bash -c 'find . -name \"*.nix\" -print0 | xargs -0 nixfmt'"; 619 + } 620 + 621 + { 622 + name = "run_tests"; 623 + description = "Run the project test suite without network access."; 624 + policy = "ask-once"; 625 + # Capabilities run unbounded by default; a full test run is the rare case 626 + # that wants a ceiling, so it caps itself at five minutes. Omit `timeout` 627 + # to let a capability run without a limit. 628 + timeout = 300; 629 + params.filter = { 630 + type = "string"; 631 + description = "Optional pytest filter expression."; 632 + required = false; 633 + enum = null; 634 + }; 635 + grants = { 636 + packages = [ 637 + pkgs.bash 638 + pkgs.python3Packages.pytest 639 + ]; 640 + network.allowedHosts = [ ]; 641 + writable = [ "." ]; 642 + unrestricted = false; 643 + }; 644 + runner = "bash -c 'if [ -n \"$1\" ]; then pytest -k \"$1\"; else pytest; fi' _ {filter}"; 645 + } 646 + 647 + # Per-call package expansion. The package bins are appended only for this 648 + # invocation; they do not become part of the permanent shell. 649 + { 650 + name = "run_ephemeral_command"; 651 + description = "Run a shell command with allow-listed Nix package binaries available for this jailed call only."; 652 + policy = "ask-always"; 653 + params = { 654 + package = { 655 + type = "string"; 656 + description = "Allow-listed package the command intends to use."; 657 + required = true; 658 + enum = [ 659 + "shellcheck" 660 + "tree" 661 + ]; 662 + }; 663 + command = { 664 + type = "string"; 665 + description = "Command line to run with the ephemeral package set on PATH."; 666 + required = true; 667 + enum = null; 668 + }; 669 + }; 670 + grants = { 671 + packages = [ 672 + pkgs.shellcheck 673 + pkgs.tree 674 + ]; 675 + network.allowedHosts = [ ]; 676 + writable = [ ]; 677 + unrestricted = false; 678 + }; 679 + runner = "bash -c {command}"; 680 + } 681 + 682 + # Narrow artifact output. This demonstrates granting a specific writable 683 + # subdirectory instead of making the whole work tree writable. 684 + { 685 + name = "write_artifact"; 686 + description = "Write a file under the work tree's artifacts directory."; 687 + policy = "ask-always"; 688 + params = { 689 + path = { 690 + type = "string"; 691 + description = "Artifact path under the configured artifacts directory."; 692 + required = true; 693 + enum = null; 694 + }; 695 + content = { 696 + type = "string"; 697 + description = "Complete artifact content."; 698 + required = true; 699 + enum = null; 700 + }; 701 + }; 702 + grants = { 703 + packages = [ 704 + pkgs.bash 705 + pkgs.coreutils 706 + ]; 707 + network.allowedHosts = [ ]; 708 + writable = [ "artifacts" ]; 709 + unrestricted = false; 710 + }; 711 + runner = '' 712 + 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} 713 + ''; 714 + } 715 + 716 + # Scoped HTTP egress through the filtering proxy. 717 + { 718 + name = "fetch_dependency"; 719 + description = "Query Python package metadata through the scoped HTTP proxy."; 720 + policy = "ask-once"; 721 + params.package = { 722 + type = "string"; 723 + description = "Package requirement to fetch."; 724 + required = true; 725 + enum = null; 726 + }; 727 + grants = { 728 + packages = [ pkgs.python3Packages.pip ]; 729 + network.allowedHosts = [ 730 + "pypi.org:443" 731 + "files.pythonhosted.org:443" 732 + ]; 733 + writable = [ ]; 734 + unrestricted = false; 735 + }; 736 + runner = "pip --no-cache-dir index versions {package}"; 737 + } 738 + 739 + # Pre-approved fetch for Wikipedia. The host enum and network allow-list are 740 + # the same boundary, so this can be auto without opening general web egress. 741 + { 742 + name = "fetch_wikipedia"; 743 + description = "Fetch from Wikipedia through the scoped HTTP proxy."; 744 + policy = "auto"; 745 + params = { 746 + host = { 747 + type = "string"; 748 + description = "Allow-listed host to fetch."; 749 + required = true; 750 + enum = [ 751 + "en.wikipedia.org" 752 + "wikipedia.org" 753 + ]; 754 + }; 755 + path = { 756 + type = "string"; 757 + description = "URL path beginning with '/'."; 758 + required = true; 759 + enum = null; 760 + }; 761 + }; 762 + grants = { 763 + packages = [ pkgs.curl ]; 764 + network.allowedHosts = [ 765 + "en.wikipedia.org:443" 766 + "wikipedia.org:443" 767 + ]; 768 + writable = [ ]; 769 + unrestricted = false; 770 + }; 771 + runner = "curl -fsSL https://{host}{path}"; 772 + } 773 + 774 + # Wildcard HTTP egress is useful for research, but always prompt and 775 + # audit the actual destination reported by the proxy. 776 + { 777 + name = "fetch_any_url"; 778 + description = "Fetch any HTTP(S) URL through the scoped HTTP proxy after per-call approval."; 779 + policy = "ask-always"; 780 + params.url = { 781 + type = "string"; 782 + description = "Full HTTP(S) URL to fetch."; 783 + required = true; 784 + enum = null; 785 + }; 786 + grants = { 787 + packages = [ pkgs.curl ]; 788 + # Security note: wildcard opens any HTTP(S) host through the proxy. 789 + network.allowedHosts = [ "*" ]; 790 + writable = [ ]; 791 + unrestricted = false; 792 + }; 793 + runner = "curl -fsSL {url}"; 794 + } 795 + 796 + # Plain TCP clients do not obey the HTTP proxy. Keep examples like this 797 + # denied until a network namespace/firewall supervisor exists. 798 + { 799 + name = "run_migration"; 800 + description = "Disabled until plain TCP database egress has namespace-level routing."; 801 + policy = "deny"; 802 + params = { 803 + direction = { 804 + type = "string"; 805 + description = "Migration direction."; 806 + required = true; 807 + enum = [ 808 + "up" 809 + "down" 810 + ]; 811 + }; 812 + steps = { 813 + type = "integer"; 814 + description = "Number of migration steps."; 815 + required = false; 816 + enum = null; 817 + }; 818 + }; 819 + grants = { 820 + packages = [ pkgs.postgresql ]; 821 + network.allowedHosts = [ "localhost:5432" ]; 822 + writable = [ "migrations" ]; 823 + unrestricted = false; 824 + }; 825 + runner = "psql -h localhost -p 5432 -f migrations/{direction}/latest.sql"; 826 + } 827 + 828 + # The big red button. Trusted overlays may flip this to ask-always; the 829 + # manifest validator rejects unrestricted + auto. 830 + { 831 + name = "shell_escape"; 832 + description = "Disabled unrestricted host escape for trusted overlays only."; 833 + policy = "deny"; 834 + params = { }; 835 + grants = { 836 + packages = [ ]; 837 + network.allowedHosts = [ ]; 838 + writable = [ ]; 839 + unrestricted = true; 840 + }; 841 + runner = "bash"; 842 + } 843 + ]; 844 + }; 845 + }
+25
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1782233679, 6 + "narHash": "sha256-QyuGP5+QOtmXpy4i2X4DhBVBaySBdDKQEhqKcphcp34=", 7 + "rev": "667d5cf1c59585031d743c78b394b0a647537c35", 8 + "revCount": 1007414, 9 + "type": "tarball", 10 + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1007414%2Brev-667d5cf1c59585031d743c78b394b0a647537c35/019f0001-c3e4-7132-bec5-e3972fb9ab0a/source.tar.gz" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://flakehub.com/f/NixOS/nixpkgs/0" 15 + } 16 + }, 17 + "root": { 18 + "inputs": { 19 + "nixpkgs": "nixpkgs" 20 + } 21 + } 22 + }, 23 + "root": "root", 24 + "version": 7 25 + }
+65
flake.nix
··· 1 + { 2 + description = "Tartarus: a Nix-defined containment runtime for auditable agents"; 3 + 4 + inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0"; 5 + 6 + outputs = 7 + { nixpkgs, ... }: 8 + let 9 + supportedSystems = [ 10 + "x86_64-linux" 11 + "aarch64-linux" 12 + "aarch64-darwin" 13 + ]; 14 + inherit (nixpkgs) lib; 15 + eachSystem = lib.genAttrs supportedSystems; 16 + pkgsFor = system: import nixpkgs { inherit system; }; 17 + 18 + # The reusable compiler from real Nix capabilities to agent bundles. 19 + agentsLib = import ./lib/agents.nix { inherit lib; }; 20 + in 21 + { 22 + lib = agentsLib; 23 + 24 + # `.#agents.<system>.<name>.bundle` is the shareable runtime boundary the 25 + # Python harness consumes. We ship one agent named `default`; the lib 26 + # supports many, so downstream flakes call `agentsLib.mkAgents` with their 27 + # own named set. 28 + agents = eachSystem ( 29 + system: 30 + let 31 + pkgs = pkgsFor system; 32 + packages = { }; # optional: add this flake's own derivations when capabilities need them 33 + in 34 + agentsLib.mkAgents { inherit pkgs packages; } (import ./agent.nix { inherit pkgs; }) 35 + ); 36 + 37 + # The developer shell for hacking on this harness (Python + pytest). This is 38 + # distinct from an agent's own `shell`, whose PATH is baked into its bundle. 39 + devShells = eachSystem ( 40 + system: 41 + let 42 + pkgs = pkgsFor system; 43 + in 44 + { 45 + default = pkgs.mkShellNoCC { 46 + packages = with pkgs; [ 47 + bash 48 + coreutils 49 + findutils 50 + git 51 + jq 52 + nixfmt 53 + ripgrep 54 + gnused 55 + (python3.withPackages (pythonPackages: [ 56 + pythonPackages.httpx 57 + pythonPackages.pip 58 + pythonPackages.pytest 59 + ])) 60 + ]; 61 + }; 62 + } 63 + ); 64 + }; 65 + }
+222
lib/agents.nix
··· 1 + { lib }: 2 + 3 + let 4 + paramSchema = 5 + param: 6 + { 7 + inherit (param) type description; 8 + } 9 + // lib.optionalAttrs (param.enum != null) { inherit (param) enum; }; 10 + 11 + paramsToSchema = params: { 12 + type = "object"; 13 + properties = lib.mapAttrs (_: paramSchema) params; 14 + required = lib.attrNames (lib.filterAttrs (_: param: param.required) params); 15 + }; 16 + 17 + toolOf = name: capability: { 18 + inherit name; 19 + inherit (capability) description; 20 + parameters = paramsToSchema capability.params; 21 + }; 22 + 23 + packageBin = 24 + package: 25 + let 26 + binRoot = packageBinRoot package; 27 + hasBinDir = builtins.pathExists (binRoot + "/bin"); 28 + in 29 + if hasBinDir then "${binRoot}/bin" else "${package}/bin"; 30 + 31 + packageBinRoot = 32 + package: 33 + let 34 + binOutput = lib.getBin package; 35 + in 36 + if builtins.pathExists (binOutput + "/bin") then binOutput else package; 37 + 38 + # The closure of a grant's packages, emitted as a store path to the 39 + # newline-list `closureInfo` produces. The harness binds exactly these paths 40 + # into the jail, so a capability reaches its declared closure and nothing else. 41 + # A store path string, not IFD: the file is realized by the `grantClosures` 42 + # build and read by the harness afterward, never during eval. 43 + closureFile = pkgs: roots: "${pkgs.closureInfo { rootPaths = roots; }}/store-paths"; 44 + 45 + grantToJson = 46 + pkgs: grant: 47 + let 48 + packageRoots = map packageBinRoot (grant.packages or [ ]); 49 + in 50 + builtins.removeAttrs grant [ "packages" ] 51 + // { 52 + packageBins = map packageBin (grant.packages or [ ]); 53 + closure = closureFile pkgs packageRoots; 54 + }; 55 + 56 + capabilityToJson = 57 + pkgs: capability: 58 + capability 59 + // { 60 + grants = grantToJson pkgs capability.grants; 61 + }; 62 + 63 + compileManifest = 64 + pkgs: capabilities: 65 + let 66 + compiledCapabilities = lib.mapAttrs (_: capability: capabilityToJson pkgs capability) capabilities; 67 + exposed = lib.filterAttrs (_: capability: capability.policy != "deny") compiledCapabilities; 68 + in 69 + { 70 + tools = lib.mapAttrsToList toolOf exposed; 71 + capabilities = compiledCapabilities; 72 + }; 73 + 74 + # A capability self-identifies via `name`. Accept either a plain attrset (when 75 + # `pkgs` is already in scope) or a function of `moduleArgs` (to share it across 76 + # flakes). `name` is stripped from the body because the attr key carries it. 77 + resolveCapabilities = 78 + moduleArgs: capabilityModules: 79 + lib.foldl' ( 80 + resolved: capabilityModule: 81 + let 82 + capability = 83 + if lib.isFunction capabilityModule then capabilityModule moduleArgs else capabilityModule; 84 + name = capability.name or (throw "Tartarus: a capability is missing its `name`"); 85 + in 86 + if resolved ? ${name} then 87 + throw "Tartarus: duplicate capability name '${name}'" 88 + else 89 + resolved // { ${name} = builtins.removeAttrs capability [ "name" ]; } 90 + ) { } capabilityModules; 91 + 92 + # The default shell: the always-present baseline PATH inside every jailed call, 93 + # before any capability grant is layered on. Kept deliberately minimal so the 94 + # shell reflects the capability-OS model — each tool brings its own packages via 95 + # `grants.packages`. Agents that want a richer baseline declare their own `shell`. 96 + defaultShellPackages = pkgs: [ 97 + pkgs.bash 98 + pkgs.coreutils 99 + ]; 100 + 101 + # An agent's `shell` may be omitted (use the minimal default), given as a plain 102 + # list of packages (wrapped into a shell here), or given as a devShell 103 + # derivation directly (reused from the flake or declared inline). Its PATH is 104 + # baked into the bundle manifest; the shell output remains useful for humans. 105 + resolveShell = 106 + pkgs: shell: 107 + if shell == null then 108 + pkgs.mkShellNoCC { packages = defaultShellPackages pkgs; } 109 + else if lib.isList shell then 110 + pkgs.mkShellNoCC { packages = shell; } 111 + else 112 + shell; 113 + 114 + # The packages whose closure must be bound for the baseline shell PATH to work 115 + # inside the jail. For the list and default forms we know the packages exactly; 116 + # for a devShell-derivation `shell` we fall back to its inputs (so a custom 117 + # devShell must declare its runtime PATH deps as packages — by design). 118 + shellPackagesOf = 119 + pkgs: shell: 120 + if shell == null then 121 + defaultShellPackages pkgs 122 + else if lib.isList shell then 123 + shell 124 + else 125 + (shell.buildInputs or [ ]) ++ (shell.nativeBuildInputs or [ ]); 126 + 127 + mkAgent = 128 + moduleArgs: 129 + { 130 + capabilities, 131 + systemPrompt ? null, 132 + shell ? null, 133 + grantEnvName ? "tartarus-nix-grants", 134 + # The agent's model: one coherent unit holding the backend a model id is 135 + # only meaningful within (`provider` type, `baseUrl`, `name`) plus its 136 + # inference knobs (`maxTokens`, `sampling`). Optional — an agent that omits 137 + # it inherits the harness defaults (PLAN.md §9). API keys and request 138 + # headers are never declared here: they stay in the environment. 139 + model ? null, 140 + }: 141 + let 142 + resolved = resolveCapabilities moduleArgs capabilities; 143 + pkgs = moduleArgs.pkgs; 144 + # The baseline PATH packages: the declared shell plus bashInteractive (the 145 + # shell `nix develop` used to run). The baked `shellPath` and the bound 146 + # `shellClosure` share these roots, so PATH never advertises a binary the 147 + # jail does not bind. cacert carries no bin; it rides the closure only, for 148 + # the CA bundle so TLS works inside the jail for network grants. 149 + shellBinPackages = shellPackagesOf pkgs shell ++ [ pkgs.bashInteractive ]; 150 + shellRoots = map packageBinRoot shellBinPackages ++ [ pkgs.cacert ]; 151 + shellClosureDrv = pkgs.closureInfo { rootPaths = shellRoots; }; 152 + grantClosureDrvs = map ( 153 + capability: 154 + pkgs.closureInfo { 155 + rootPaths = map packageBinRoot (capability.grants.packages or [ ]); 156 + } 157 + ) (lib.attrValues resolved); 158 + 159 + # The compiled, fully-resolved manifest: tool/capability contract plus the 160 + # baked baseline PATH, the shell closure pointer, the CA bundle, and the 161 + # optional persona/model. Serialized verbatim into the bundle below. 162 + compiledManifest = 163 + compileManifest pkgs resolved 164 + // { 165 + caBundle = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; 166 + shellClosure = "${shellClosureDrv}/store-paths"; 167 + shellPath = lib.concatStringsSep ":" (lib.unique (map packageBin shellBinPackages)); 168 + } 169 + // lib.optionalAttrs (systemPrompt != null) { inherit systemPrompt; } 170 + // lib.optionalAttrs (model != null) { inherit model; }; 171 + in 172 + { 173 + capabilities = resolved; 174 + 175 + # The agent owns its shell: a devShell kept for `nix develop` ergonomics. 176 + # The harness no longer resolves it — the baseline PATH is baked into the 177 + # manifest's `shellPath` — but it stays a convenient entry point. 178 + shell = resolveShell pkgs shell; 179 + 180 + manifest = compiledManifest; 181 + 182 + # The shippable agent: one derivation whose runtime closure is the whole 183 + # agent. Writing the manifest JSON into $out makes the output reference 184 + # every store path it names (package bins, each grant's `closure` 185 + # store-paths file, the shell closure, the CA bundle, the baked PATH 186 + # entries), so `nix copy <bundle>` pulls the complete closure. The symlinks 187 + # force realization and aid debugging. The harness reads 188 + # <bundle>/manifest.json with no nix calls (tartarus/bundle.py). 189 + bundle = 190 + pkgs.runCommand "${grantEnvName}-bundle" 191 + { 192 + manifestJson = builtins.toJSON compiledManifest; 193 + passAsFile = [ "manifestJson" ]; 194 + closures = [ shellClosureDrv ] ++ grantClosureDrvs; 195 + } 196 + '' 197 + mkdir -p "$out/closures" 198 + cp "$manifestJsonPath" "$out/manifest.json" 199 + ln -s ${shellClosureDrv} "$out/closures/shell" 200 + n=0 201 + for closure in $closures; do 202 + ln -s "$closure" "$out/closures/grant-$n" 203 + n=$((n + 1)) 204 + done 205 + ''; 206 + }; 207 + in 208 + { 209 + inherit mkAgent resolveCapabilities; 210 + 211 + mkAgents = 212 + moduleArgs: agents: 213 + lib.mapAttrs ( 214 + agentName: agentConfig: 215 + mkAgent moduleArgs ( 216 + agentConfig 217 + // { 218 + grantEnvName = agentConfig.grantEnvName or "tartarus-nix-${agentName}-grants"; 219 + } 220 + ) 221 + ) agents; 222 + }