···11+{
22+ description = "Tartarus: a Nix-defined containment runtime for auditable agents";
33+44+ inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0";
55+66+ outputs =
77+ { nixpkgs, ... }:
88+ let
99+ supportedSystems = [
1010+ "x86_64-linux"
1111+ "aarch64-linux"
1212+ "aarch64-darwin"
1313+ ];
1414+ inherit (nixpkgs) lib;
1515+ eachSystem = lib.genAttrs supportedSystems;
1616+ pkgsFor = system: import nixpkgs { inherit system; };
1717+1818+ # The reusable compiler from real Nix capabilities to agent bundles.
1919+ agentsLib = import ./lib/agents.nix { inherit lib; };
2020+ in
2121+ {
2222+ lib = agentsLib;
2323+2424+ # `.#agents.<system>.<name>.bundle` is the shareable runtime boundary the
2525+ # Python harness consumes. We ship one agent named `default`; the lib
2626+ # supports many, so downstream flakes call `agentsLib.mkAgents` with their
2727+ # own named set.
2828+ agents = eachSystem (
2929+ system:
3030+ let
3131+ pkgs = pkgsFor system;
3232+ packages = { }; # optional: add this flake's own derivations when capabilities need them
3333+ in
3434+ agentsLib.mkAgents { inherit pkgs packages; } (import ./agent.nix { inherit pkgs; })
3535+ );
3636+3737+ # The developer shell for hacking on this harness (Python + pytest). This is
3838+ # distinct from an agent's own `shell`, whose PATH is baked into its bundle.
3939+ devShells = eachSystem (
4040+ system:
4141+ let
4242+ pkgs = pkgsFor system;
4343+ in
4444+ {
4545+ default = pkgs.mkShellNoCC {
4646+ packages = with pkgs; [
4747+ bash
4848+ coreutils
4949+ findutils
5050+ git
5151+ jq
5252+ nixfmt
5353+ ripgrep
5454+ gnused
5555+ (python3.withPackages (pythonPackages: [
5656+ pythonPackages.httpx
5757+ pythonPackages.pip
5858+ pythonPackages.pytest
5959+ ]))
6060+ ];
6161+ };
6262+ }
6363+ );
6464+ };
6565+}
+222
lib/agents.nix
···11+{ lib }:
22+33+let
44+ paramSchema =
55+ param:
66+ {
77+ inherit (param) type description;
88+ }
99+ // lib.optionalAttrs (param.enum != null) { inherit (param) enum; };
1010+1111+ paramsToSchema = params: {
1212+ type = "object";
1313+ properties = lib.mapAttrs (_: paramSchema) params;
1414+ required = lib.attrNames (lib.filterAttrs (_: param: param.required) params);
1515+ };
1616+1717+ toolOf = name: capability: {
1818+ inherit name;
1919+ inherit (capability) description;
2020+ parameters = paramsToSchema capability.params;
2121+ };
2222+2323+ packageBin =
2424+ package:
2525+ let
2626+ binRoot = packageBinRoot package;
2727+ hasBinDir = builtins.pathExists (binRoot + "/bin");
2828+ in
2929+ if hasBinDir then "${binRoot}/bin" else "${package}/bin";
3030+3131+ packageBinRoot =
3232+ package:
3333+ let
3434+ binOutput = lib.getBin package;
3535+ in
3636+ if builtins.pathExists (binOutput + "/bin") then binOutput else package;
3737+3838+ # The closure of a grant's packages, emitted as a store path to the
3939+ # newline-list `closureInfo` produces. The harness binds exactly these paths
4040+ # into the jail, so a capability reaches its declared closure and nothing else.
4141+ # A store path string, not IFD: the file is realized by the `grantClosures`
4242+ # build and read by the harness afterward, never during eval.
4343+ closureFile = pkgs: roots: "${pkgs.closureInfo { rootPaths = roots; }}/store-paths";
4444+4545+ grantToJson =
4646+ pkgs: grant:
4747+ let
4848+ packageRoots = map packageBinRoot (grant.packages or [ ]);
4949+ in
5050+ builtins.removeAttrs grant [ "packages" ]
5151+ // {
5252+ packageBins = map packageBin (grant.packages or [ ]);
5353+ closure = closureFile pkgs packageRoots;
5454+ };
5555+5656+ capabilityToJson =
5757+ pkgs: capability:
5858+ capability
5959+ // {
6060+ grants = grantToJson pkgs capability.grants;
6161+ };
6262+6363+ compileManifest =
6464+ pkgs: capabilities:
6565+ let
6666+ compiledCapabilities = lib.mapAttrs (_: capability: capabilityToJson pkgs capability) capabilities;
6767+ exposed = lib.filterAttrs (_: capability: capability.policy != "deny") compiledCapabilities;
6868+ in
6969+ {
7070+ tools = lib.mapAttrsToList toolOf exposed;
7171+ capabilities = compiledCapabilities;
7272+ };
7373+7474+ # A capability self-identifies via `name`. Accept either a plain attrset (when
7575+ # `pkgs` is already in scope) or a function of `moduleArgs` (to share it across
7676+ # flakes). `name` is stripped from the body because the attr key carries it.
7777+ resolveCapabilities =
7878+ moduleArgs: capabilityModules:
7979+ lib.foldl' (
8080+ resolved: capabilityModule:
8181+ let
8282+ capability =
8383+ if lib.isFunction capabilityModule then capabilityModule moduleArgs else capabilityModule;
8484+ name = capability.name or (throw "Tartarus: a capability is missing its `name`");
8585+ in
8686+ if resolved ? ${name} then
8787+ throw "Tartarus: duplicate capability name '${name}'"
8888+ else
8989+ resolved // { ${name} = builtins.removeAttrs capability [ "name" ]; }
9090+ ) { } capabilityModules;
9191+9292+ # The default shell: the always-present baseline PATH inside every jailed call,
9393+ # before any capability grant is layered on. Kept deliberately minimal so the
9494+ # shell reflects the capability-OS model — each tool brings its own packages via
9595+ # `grants.packages`. Agents that want a richer baseline declare their own `shell`.
9696+ defaultShellPackages = pkgs: [
9797+ pkgs.bash
9898+ pkgs.coreutils
9999+ ];
100100+101101+ # An agent's `shell` may be omitted (use the minimal default), given as a plain
102102+ # list of packages (wrapped into a shell here), or given as a devShell
103103+ # derivation directly (reused from the flake or declared inline). Its PATH is
104104+ # baked into the bundle manifest; the shell output remains useful for humans.
105105+ resolveShell =
106106+ pkgs: shell:
107107+ if shell == null then
108108+ pkgs.mkShellNoCC { packages = defaultShellPackages pkgs; }
109109+ else if lib.isList shell then
110110+ pkgs.mkShellNoCC { packages = shell; }
111111+ else
112112+ shell;
113113+114114+ # The packages whose closure must be bound for the baseline shell PATH to work
115115+ # inside the jail. For the list and default forms we know the packages exactly;
116116+ # for a devShell-derivation `shell` we fall back to its inputs (so a custom
117117+ # devShell must declare its runtime PATH deps as packages — by design).
118118+ shellPackagesOf =
119119+ pkgs: shell:
120120+ if shell == null then
121121+ defaultShellPackages pkgs
122122+ else if lib.isList shell then
123123+ shell
124124+ else
125125+ (shell.buildInputs or [ ]) ++ (shell.nativeBuildInputs or [ ]);
126126+127127+ mkAgent =
128128+ moduleArgs:
129129+ {
130130+ capabilities,
131131+ systemPrompt ? null,
132132+ shell ? null,
133133+ grantEnvName ? "tartarus-nix-grants",
134134+ # The agent's model: one coherent unit holding the backend a model id is
135135+ # only meaningful within (`provider` type, `baseUrl`, `name`) plus its
136136+ # inference knobs (`maxTokens`, `sampling`). Optional — an agent that omits
137137+ # it inherits the harness defaults (PLAN.md §9). API keys and request
138138+ # headers are never declared here: they stay in the environment.
139139+ model ? null,
140140+ }:
141141+ let
142142+ resolved = resolveCapabilities moduleArgs capabilities;
143143+ pkgs = moduleArgs.pkgs;
144144+ # The baseline PATH packages: the declared shell plus bashInteractive (the
145145+ # shell `nix develop` used to run). The baked `shellPath` and the bound
146146+ # `shellClosure` share these roots, so PATH never advertises a binary the
147147+ # jail does not bind. cacert carries no bin; it rides the closure only, for
148148+ # the CA bundle so TLS works inside the jail for network grants.
149149+ shellBinPackages = shellPackagesOf pkgs shell ++ [ pkgs.bashInteractive ];
150150+ shellRoots = map packageBinRoot shellBinPackages ++ [ pkgs.cacert ];
151151+ shellClosureDrv = pkgs.closureInfo { rootPaths = shellRoots; };
152152+ grantClosureDrvs = map (
153153+ capability:
154154+ pkgs.closureInfo {
155155+ rootPaths = map packageBinRoot (capability.grants.packages or [ ]);
156156+ }
157157+ ) (lib.attrValues resolved);
158158+159159+ # The compiled, fully-resolved manifest: tool/capability contract plus the
160160+ # baked baseline PATH, the shell closure pointer, the CA bundle, and the
161161+ # optional persona/model. Serialized verbatim into the bundle below.
162162+ compiledManifest =
163163+ compileManifest pkgs resolved
164164+ // {
165165+ caBundle = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
166166+ shellClosure = "${shellClosureDrv}/store-paths";
167167+ shellPath = lib.concatStringsSep ":" (lib.unique (map packageBin shellBinPackages));
168168+ }
169169+ // lib.optionalAttrs (systemPrompt != null) { inherit systemPrompt; }
170170+ // lib.optionalAttrs (model != null) { inherit model; };
171171+ in
172172+ {
173173+ capabilities = resolved;
174174+175175+ # The agent owns its shell: a devShell kept for `nix develop` ergonomics.
176176+ # The harness no longer resolves it — the baseline PATH is baked into the
177177+ # manifest's `shellPath` — but it stays a convenient entry point.
178178+ shell = resolveShell pkgs shell;
179179+180180+ manifest = compiledManifest;
181181+182182+ # The shippable agent: one derivation whose runtime closure is the whole
183183+ # agent. Writing the manifest JSON into $out makes the output reference
184184+ # every store path it names (package bins, each grant's `closure`
185185+ # store-paths file, the shell closure, the CA bundle, the baked PATH
186186+ # entries), so `nix copy <bundle>` pulls the complete closure. The symlinks
187187+ # force realization and aid debugging. The harness reads
188188+ # <bundle>/manifest.json with no nix calls (tartarus/bundle.py).
189189+ bundle =
190190+ pkgs.runCommand "${grantEnvName}-bundle"
191191+ {
192192+ manifestJson = builtins.toJSON compiledManifest;
193193+ passAsFile = [ "manifestJson" ];
194194+ closures = [ shellClosureDrv ] ++ grantClosureDrvs;
195195+ }
196196+ ''
197197+ mkdir -p "$out/closures"
198198+ cp "$manifestJsonPath" "$out/manifest.json"
199199+ ln -s ${shellClosureDrv} "$out/closures/shell"
200200+ n=0
201201+ for closure in $closures; do
202202+ ln -s "$closure" "$out/closures/grant-$n"
203203+ n=$((n + 1))
204204+ done
205205+ '';
206206+ };
207207+in
208208+{
209209+ inherit mkAgent resolveCapabilities;
210210+211211+ mkAgents =
212212+ moduleArgs: agents:
213213+ lib.mapAttrs (
214214+ agentName: agentConfig:
215215+ mkAgent moduleArgs (
216216+ agentConfig
217217+ // {
218218+ grantEnvName = agentConfig.grantEnvName or "tartarus-nix-${agentName}-grants";
219219+ }
220220+ )
221221+ ) agents;
222222+}