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.