Skip to content
Chock
Nix, from inside a session (4 of 23)

This page is the documentation for unstable. Read the current version.

Nix, from inside a session

nix_eval answers what an expression says, and nix_build makes one attribute of a flake. Getting a program the session has not got is provide_tool, which is in tools.md.

Asking Nix what something is

The agent sends one Nix expression and reads the value back.

$ nix_eval {"expression":"(import <nixpkgs> {}).hello.version"}
! the expression reads something a pure evaluation may not ...
$ nix_eval {"expression":"builtins.attrNames { a = 1; b = 2; }"}
  [ "a" "b" ]

Chock evaluates with fix, which is a second implementation of the Nix language, and only a build uses the system’s own Nix. So a value here can differ from the one nix eval gives. One difference to know about: a flake’s own source, self, keeps the .git entry that Nix drops, so a derivation that runs git on self fails in the build with “not a git repository”, and lib.cleanSource self avoids it.

Nothing is built

The expression is evaluated inside Chock, by its own evaluator, so no nix process starts and no store is opened. A derivation answers what it is and where its derivation file would be, because that path is computed from the derivation itself and is not looked up anywhere.

A build is a different act, and this tool does none. An expression that needs the result of a build, such as an import of a derivation, is refused, and the refusal names the derivation it wanted, so the agent can ask about the derivation instead of sending the same expression again.

What it can read

The evaluation is pure. The environment is empty, NIX_PATH and channels answer nothing, and a path outside the workspace is refused. The workspace is the throwaway copy of the project the agent already works in, so an expression reads the same tree every other tool call reads.

What bounds it

An evaluation runs in Chock’s own process and not in the sandbox, so it is bounded where it runs. A recursion deeper than the call depth stops with an error, the collector holds the memory an evaluation keeps, and the rendered answer stops at 16384 bytes. Nothing bounds how long an evaluation runs, so an expression that loops runs until the session is stopped with Ctrl-C.

The store caps

A nix block in chock.zon bounds what an evaluation may put in a store.

.{
    .nix = .{
        .max_object_bytes = "16MiB",
        .max_session_bytes = "256MiB",
    },
}

max_object_bytes bounds one object, and max_session_bytes bounds what every build of one session puts in your store between them. The operator’s own config.zon names the same block, the project wins over it, and an organisation’s policy bundle is the last word over both. org.md has the whole fold.

Building one attribute

nix_eval says what a derivation is. nix_build makes it. The agent names an attribute path, a person or your policy answers, and the host builds it.

$ nix_build {"attribute":["packages","x86_64-linux","default"]}
? nix.build.packages.x86_64-linux.default
  /nix/store/...-myproject was built. It produced /nix/store/...-myproject.
$ run_command {"argv":["myproject","--version"]}
  0.1.0

The attribute path is a list with one name per entry, never one dotted string. A Nix attribute name may itself hold a dot, so one string would leave the tool guessing where a level ends. flake is optional: left out, the build is of the workspace the agent is already working in.

A flake that is not your project is refused. The agent may write any reference, and fetching one means fetching its whole input graph. What that graph reaches is written in a lock file inside the flake, which nobody can read until the flake has already been fetched, so there is no moment at which your rules could answer for those hosts first. The agent is told so in one sentence and builds an attribute of your project instead.

Two rules, because a build asks two questions

What is being built is asked under nix.build followed by the attribute path. A build of your own project asks that and nothing else:

.{
    .policy = .{
        .rules = .{
            .{ .action = "nix.build.packages.*", .decision = .allow },
        },
    },
}

A call that names a flake asks a second question, for the reference itself, and both have to be allowed before anything is built:

.{ .action = "nix.build.flake.github.NixOS.*", .decision = .allow },

A reference that is not your project is refused whatever those rules say, so the second rule narrows a subdirectory of your own tree and never widens to a foreign flake. The two questions keep two names, because an attribute path and a flake reference are both dotted paths, and one joined string cannot say where the first ends.

The derivation hash is in neither name, because it changes on every edit of the Nix. The rows answer the two questions a person keeps: may this agent build this attribute, and from where. A revision or a #fragment on the reference is dropped before the name is built, because what a row grants is the repository and never the content.

What stops a build of anything else

The attribute is evaluated inside Chock first, and that evaluation is what registers the derivation. A build of a store path this session did not itself produce is refused, by name, before nix is run at all. That check matters because a hand written derivation can name any builder, so building a path nobody evaluated here would be running a program of the model’s choosing on your machine with a content hash in front of it.

What that evaluation authorised is what your machine builds. The derivation it computed is written into your own store first, through your Nix daemon, and the store is asked where it put it: an answer that is not the path Chock computed is refused there and then. Chock then hands nix that derivation path and never the attribute, so the attribute is read once. Your Nix and Chock’s evaluator cannot read one expression differently between the two moments, and an unpinned reference cannot move between them.

It says what goes into the build, and never what comes out. The builder runs under your Nix, and a substituter may answer for an output instead of building it.

What a build may fetch while it runs

A fixed output derivation builds with the network open to it, because Nix checks its output hash afterwards. That check is worth something for integrity and nothing for egress: a URL that carries a secret in its query string, with the hash of an innocuous file, passes the check, and the request has already gone out.

So Chock reads the whole derivation closure first, finds every fixed output derivation in it, and asks about each host under nix.net:

.{ .action = "nix.net.org.nixos.cache.*", .decision = .allow },

The labels are reversed for the reason actions.md gives. A build gets its own namespace because it is not your agent opening a socket. net.connect stays the sandbox’s own connections and net.fetch stays the fetch tool, so a host you allow for a build is not a host your agent may reach.

A build fetches in two phases. An input fetched so an expression can evaluate is nix.net.eval, and a derivation fetching while it builds is nix.net.build. A rule naming the phase decides for that phase alone, and a rule naming nix.net covers both:

.{ .action = "nix.net.com.github.443", .decision = .allow },
.{ .action = "nix.net.eval.*", .decision = .allow },
.{ .action = "nix.net.build.*", .decision = .ask },
.{ .action = "nix.net.*", .decision = .deny },

The first names one host in either phase. The second lets inputs resolve. The third asks for everything a derivation fetches. The last turns off every network request a build can make, including the two below.

The phase word sits where a reversed host’s last label sits, so a host under a top level domain called build or eval shares a name with an ordinary host in that phase. build is delegated today. Chock accepts this rather than preventing it.

This is the rule and not the router. A build runs on your machine, outside the sandbox, so .policy.net.router says nothing about it: the rules are what answer.

A host nobody allowed refuses the build before nix is told to build anything, and the refusal names the host and the derivation, so the agent can ask you for that host rather than try the same attribute again.

You are asked once for a build and not once per host, because a nixpkgs closure reaches a hundred of them. Chock reads your rules for every host first: a host a rule allows is decided there and never appears in the question, and a host a rule denies refuses the build with nobody asked. Only the hosts no rule covers are left, and those go into one question, under nix.net.hosts, that says how many there are and names the first few. The detail key shows every one of them beside the exact rule you would write to stop being asked. A yes covers the hosts of that question for that build and nothing after it.

A URL whose scheme Chock does not read, and one with no host in it, are both refusals: Chock guesses no port, and a fetch nobody can name is a fetch nobody can rule on. The schemes it reads, with the port each names when the URL gives none, are https 443, http 80, ftp 21, git 9418 and ssh 22. A transport written in front of a URL, git+https:// and hg+https:// among them, is taken off and the URL behind it is read. So a rule for any of them looks like any other: nix.net.org.gmplib.ftp.21, nix.net.org.sourceware.9418.

Mirrors

A mirror:// URL names a site and not a host, and nixpkgs writes plenty of them. The derivation also names its own mirrors file, a store path that holds the mirrors of every site, so Chock reads that file and turns the site into the hosts it really names.

That file is itself a derivation output, so it is often not in your store yet. Chock realises it first, with local and remote builds both turned off, so Nix either takes it from a substituter or refuses: no builder runs for it, and a derivation that named a path of its own cannot be built before a rule has answered. A mirrors file no substituter has is a refusal that says so.

One site is one question. The mirrors are taken in the file’s own order, one your rules already allow is taken with nothing asked, and otherwise you are asked about the first of them. A site the file does not name, and a derivation with no mirrors file, stay refusals.

A site is named with the hash of its own mirror list, so a rule says which mirrors you agreed to and not merely which site:

.{ .action = "nix.net.build.mirrors.gnu.*", .decision = .allow },

The wildcard trusts gnu’s mirrors across revisions of the list. Naming the hash instead pins the rule to one list, and a nixpkgs that changes gnu’s mirrors asks again. The hash covers that site alone, so changing another site’s mirrors leaves this rule alone.

The answer is then pinned into the environment nix runs with, as NIX_MIRRORS_<site>, so the builder uses the mirror you allowed instead of walking its own list. NIX_HASHED_MIRRORS is pinned beside it, because a nixpkgs fetcher tries a hashed mirror for every fetch and would otherwise reach a host that appears in no URL of the derivation. Only a rule can turn that one on, and it is off for every other build.

A build that names no URL

A fixed output derivation that says nowhere it fetches from is a different question, nix.net.build.opaque. Some fetchers read their URLs out of a lock file at build time, zig.fetchDeps, npm deps and fetchCargoVendor among them, and those hold no URL anywhere in the derivation. There is no host, so there is nothing a rule could name.

Chock ships that one as allow, because that is how every vendored dependency fetch works: refusing them refuses nearly every Rust, Node and Zig package. The question is one per build and never one per derivation, and the derivation names go in the words you read, never in the action, so one answer covers a session.

What you give up is stated plainly: the output hash proves the bytes are the ones the derivation expected, and proves nothing about where the request went. If you want the question back, write it in your own chock.zon:

.{ .action = "nix.net.build.opaque", .decision = .ask },

.deny refuses such a build outright. Every derivation that does name a URL is unaffected either way and still goes to nix.net per host.

The rule is all of it, and there is no backstop under it. Nix has no flag that keeps a fixed output builder off the network. --offline turns your substituters off and a fixed output derivation still fetches with it set, so Chock does not pass it and your binary cache keeps working. A host Chock’s reader did not find is a host nobody was asked about.

A build needs your Nix daemon, because that is what takes the derivation in. A machine with no daemon builds nothing here and says so.

Where your flake inputs come from

Chock’s evaluator has no fetcher. It cannot open a connection, whatever an expression asks for, so a flake input reaches it one way: it is in your store before the session starts.

chock run reads your project’s flake.lock at startup, names every host the inputs would be fetched from, and puts each one to the nix.net.eval rules above. One question per host, whatever number of inputs share it. A github input with no host of its own is fetched from api.github.com, which redirects to codeload.github.com, so both are named. A lock file sits in your project directory beside chock.zon, and Chock reads a file there as something an attacker may have written, which is why the hosts in it are asked about at all. Only allow fetches at startup: a session is starting up, so there is nobody to prompt, and ask is off there the same way it is for a language server.

Everything it fetched goes in your store, and the evaluation takes each input from there, by the hash the lock pins. A node Chock cannot turn into a host is a refusal rather than a guess: an indirect input names a registry entry and not a host, and an ssh:// URL is not a scheme that can be named as a host and a port.

A startup ask is not a permanent no. A build is a turn the agent took, so there is somebody at the prompt. When the evaluation for a build finds an input missing, Chock puts that input’s hosts to you under the same nix.net.eval rules, fetches what you allow, and evaluates once more. One retry, never a loop: a second miss is the answer. That is why a project that has written no nix.net rule can still build, and why one that has written its rules pays nothing at build time, because its inputs arrived at startup and the build never asks.

A session whose inputs did not arrive still starts. A project with no flake at all is the ordinary case, and nothing else a session start does refuses the session because an optional thing was missing. A build that then wants an input you said no to says which input and which host, and tells the agent to work with what is there rather than to ask you again for the host you just refused.

Import from derivation stays refused, here as in nix_eval. A build the agent asked for is not the same act as an evaluation that quietly needs one. nix_eval itself still writes nothing at all: it answers through a store that takes no object.

Where it runs, and what it costs

The build runs on your machine, outside the sandbox, because the sandbox has no daemon, no network and no writable cache directory. What it produced is mounted by every tool call after that one and is on their PATH, which is the same road a provisioned program takes. A background task or a subagent that was already running does not get it, and nothing survives the session.

A program out of a build asks under exec.nix.store.* when the agent runs it, and not under exec.devshell.*. The dev shell is the toolchain your project declared. A build is something the agent asked for, and the two are not the same class. actions.md has both rows.

The turn waits for the build, with no deadline, exactly as provide_tool does.