The actions
Every question Chock asks carries an action name. A rule in chock.zon names
one of them, or a class of them, and answers it. How a rule is read is in
policy.md. This page is the reference for the names themselves.
What Chock ships
Chock ships default rules for the ordinary tool calls, so a project with no
chock.zon at all still runs them without a prompt.
| Namespace | What it names | Shipped |
|---|---|---|
call.<tool> |
one ordinary tool call | allow, for the tools below |
exec.devshell.* |
a program in the dev shell closure | allow |
exec.nix.store.* |
any other store path | ask |
exec.workspace.* |
a path inside the project | allow |
exec.path.* |
a bare name looked up on PATH |
allow |
exec.unparsed |
a path Chock would not resolve | none, so ask |
net.connect.* |
the sandbox opening a socket | none, so ask |
net.fetch.* |
the fetch_url tool |
none, so ask |
web.search |
the web_search tool |
ask |
skill.read.<layer> |
reading a skill of that layer | allow for yours, ask for the rest |
nix.build.* |
one attribute, built on your machine | none, so ask |
nix.net.* |
a host a Nix build reaches | none, so ask |
nix.net.build.opaque |
a build that names no URL at all | allow |
git.<subcommand> |
one git subcommand | allow where it changes only the workspace |
lsp.* |
starting a language server | allow |
device.* |
a USB or serial device | none, so ask |
mcp.* |
an MCP server and its tools | none, so ask |
plugin.* |
a plugin and its tools | none, so ask |
A shipped default is read only when nothing in the project’s own rules matches
the key. A project rule that matches wins outright, whatever it names and
however wide it is. Writing
.{ .action = "call.write_file", .decision = .ask } in your own chock.zon
puts that call back behind a prompt.
call.*, the ordinary tool calls
One action per tool, named after the tool. These ship as allow: read_file,
read_image, list_directory, glob, grep, write_file, edit_file,
read_guidance, read_memory, write_memory, provide_tool and nix_eval.
run_command has no call. action of its own, because a command is named by
the program it runs. spawn_agent, update_plan, restrict_self,
fetch_url, ask_user, set_title and request_action are decided at a key
that fits them better, so a call. rule for one of those would read as a
control and do nothing.
exec.*, running a program
run_command names a program by the class it belongs to, and the store is two
of those classes. exec.devshell.* is a program inside the Nix dev shell
closure this session mounted at its start, and it is allowed. exec.nix.store.*
is every other store path, and it is the one shipped rule that is not an
allow.
A store path is immutable, so it names one program for ever, but the set of store paths is not: the agent can have an expression evaluated and the result built, and a path it made that way is not the toolchain it was given.
exec.unparsed is the class for a path Chock refuses to resolve lexically,
most often one holding a ... It holds no shipped rule, so it asks.
net.connect.*, net.fetch.* and nix.net.*
Each of the three names one source. net.connect is the sandbox opening a
socket, net.fetch is the fetch_url tool, and nix.net is a Nix build,
which runs on your machine rather than in the sandbox. A host allowed for one
is not allowed for the others.
None of the three holds a shipped default, so a project that named nothing
about a host meets ask there and never allow. What an ask on
net.connect.* can reach is in
threat-model.md, and the phases a build fetches
in are in nix.md.
net.fetch is fetch_url’s action, and the host is part of the name, with the
labels reversed: docs.ziglang.org becomes net.fetch.org.ziglang.docs.
Reversal is what makes a class rule safe: net.fetch.org.ziglang.* means any
host under ziglang.org, and a host somebody else registered cannot match it.
.{ .action = "net.fetch.org.ziglang.*", .decision = .allow }
.{ .action = "net.fetch.*", .decision = .deny }
A redirect is followed only while every host along the way is allowed too, and
a site’s own robots.txt is honoured. There is no way to send a header, a
credential, or a body: fetch_url reads, and it never writes to a remote
service.
Only allow reads a host with no question. A host that no rule names answers
ask, and for the host the agent itself named that ask reaches you while the
agent waits: you answer once and the fetch goes on.
A redirect hop is not asked. A fetch follows redirects, and each hop is a new host. A hop a rule does not name is refused, because a page that could ask at every hop would let whoever wrote it chain redirects and turn the prompt into a way to tire you out. So you approve reading the host you were told about, and never wherever it forwards you.
A rule in chock.zon still reads a host with no question at all, which is what
you want for a host the agent visits often. The refusal on your screen names
the rule to add, and the block it goes in.
Whether a session has a network at all
net.connect and net.fetch rules say which hosts a session may reach.
.policy.net.router decides whether it is given a network namespace, a kernel
ruleset and a resolver at all:
.{
.policy = .{
.net = .{ .router = .auto },
.rules = .{
.{ .action = "net.connect.com.github", .decision = .allow },
},
},
}
.autois the default and reads the rules. A project that permits something undernetis given a router. A project that permits nothing there is not, and pays for none of it..nonerefuses a router whatever the rules say..filteredgives one even when no rule permits a host yet, which suits a session where a person answers for each host as it comes up.
A router is a mechanism and never a permission. It grants no host by itself:
every connection is still decided at net.connect.*, and a router with nothing
permitted reaches nothing. A rule that only denies does not make .auto build
one.
.policy.net.background says what a command started in the background gets,
and takes the same three words. .auto, the default, is whatever the session
itself has.
A session with a network is given a trust store. Chock copies the host’s own
certificate bundle into the sandbox, points SSL_CERT_FILE at that copy, and
links the conventional path, /etc/ssl/certs/ca-certificates.crt, to it too. A
project that sets SSL_CERT_FILE itself keeps its own.
A background command never asks you anything. It runs after the tool call that
started it has returned, and a question needs the session to be waiting on it.
So a background command reaches what this policy allows outright, and a host
that would have asked you is refused instead of queued. Set
.background = .none for a project where a background command should reach
nothing at all.
web.search
One name, with no host under it, for the web_search tool. It ships as ask,
and the question reaches you at the tool call while the agent waits.
That timing is what lets it be ask at all. Two actions, nix.build and
model.select, are read before the work they govern, when nobody is there to
answer, so an ask on one of those means “never”. web.search is not one of
them.
The engine’s own host is not gated under net.fetch. You name the engine in
your own config.zon and a project does not get to pick it, so a project does
not have to permit it either. search.md has the block, the kinds,
and where the key lives.
A result the agent then wants to read is an ordinary net.fetch on a host your
rules probably do not name. Chock asks about that host, once, for the host the
agent named.
skill.read.<layer>
Three names, and only three: skill.read.operator, skill.read.project and
skill.read.packaged. Each says whether the agent may read a skill that came
from that layer.
The name is the layer and never the skill. A per skill action would put a name a package chose into your action namespace, which is the thing a policy namespace must never accept from a stranger. So a project with forty skills adds no rows, and one rule covers every skill of a layer.
skill.read.operator ships as allow, because that directory holds what you put
there. The other two ship as ask, because a repository’s or a package’s skill
is a stranger’s written instruction. A project that trusts its own writes one
rule:
.{
.policy = .{
.rules = .{
.{ .action = "skill.read.project", .decision = .allow },
},
},
}
The question reaches you at the call, the same way web.search does, and a
refusal refuses that read and nothing else. skills.md has
the format, the layers and what a skill can never do.
secret.use.*
One name per secret a project’s secrets block names, spelled
secret.use.<name>. It says whether a tool call may be given that secret, so a
project can make the one that matters a question and leave the rest a standing
permission.
The name after secret.use. is the secret’s own, and a name holds letters,
digits and underscore for that reason: a dot in it would name a class of
actions nobody wrote.
Nothing ships as a default here. A secret nobody granted cannot be asked for at
all, so an unnamed one needs no row. For a tool call the question reaches you at
the call, the same way web.search does, and a refusal refuses the call rather
than running it with the secret missing.
For an MCP server the row is read at start, when nobody is there to answer, so
ask means the server is not given it. That is the same reading nix.build and
model.select take, and Chock says which rule read that way.
secrets.md has the block, how a secret binds, and what the log records.
The acts
| Action | What it does |
|---|---|
git.commit |
make a commit in a repository on the host |
git.push |
move a ref on a remote |
git.branch.delete |
delete a branch |
net.fetch |
read one page over http or https |
web.search |
put one query to the configured search engine |
nix.build |
realise a package, outside the sandbox |
file.write |
write a file on the host |
workspace.apply |
carry the session’s commit into your repository |
model.select |
which provider instance and which model a session uses |
policy.widen |
let a session out of a promise it made to itself |
sandbox.jit |
run with the sandbox’s write and execute rule off |
workspace.integrate |
let an approved apply move the branch you have checked out |
workspace.bind.<name> |
expose a path the git worktree does not carry |
git.*
The three git actions have a shim in front of them. It reads the argument
vector of every run_command call whose first word is git, and it sorts each
subcommand into one that only reads and one that changes state. The shim
prevents a mistake and it does not prevent an attack. The sandbox layers are
the boundary. An agent that wants to avoid the shim has several ways and none
of them is difficult, so nothing in Chock is built as though the shim were a
control.
Every subcommand the shim classifies is asked about while the loop runs,
through the same arbiter an MCP tool call and a plugin tool call go through. A
read only subcommand asks nobody and runs the real git, so git status,
git log and git diff cost what they always did. A subcommand the shim does
not know asks under its own name, such as git.frobnicate. An option the shim
cannot read stops it reading the subcommand at all, and that asks as
git.unknown.
Chock ships allow for every git action name that changes only the session’s
own workspace, git.commit among them, so a project with no chock.zon gains
no new prompt. It ships none for git.push, git.clone, git.fetch,
git.pull or git.unknown, so each of those asks.
An approved git push runs. It is the one subcommand that reaches another host
and is carried out: the real git runs inside the sandbox, reaches the remote
through the network router, and gets its credential over a socket for that one
call. An https remote prompts a person for a password, an ssh remote arms
the agent proxy, and both are closed again when the call ends.
credentials.md has the rest.
Every other subcommand that reaches another host is asked about and still does
not run, even when a person says yes. git clone, git fetch and git pull
have no act that carries the effect out, so the agent is told what is missing
rather than told no. Nothing in a session asks file.write.
model.select and nix.build
model.select reads two rows: provider.<instance> is the instance by its
name in your config.zon, and provider.<instance>.<model> is one model at
that instance by the id that goes on the wire. The narrower of the two is the
answer. A session picks its model before the first turn, when nobody is waiting
to be asked, so only allow lets a model be used and every other answer is a
refusal.
nix.build is read once, also before the first turn, and it decides whether
the provide_tool tool exists at all. Only allow gives the session that
tool, and ask is a refusal there for the same reason.
sandbox.jit
sandbox.jit is read once too, and it is the one row that widens rather than
narrows. It turns off the sandbox rule that refuses a page which is writable
and executable at the same time, which a run time with a just in time compiler
needs: V8 asks for such a page over a 268 MB range, so Node, Deno and Bun
cannot work without it. Only allow turns the rule off, because the filter is
built before the first turn and there is nobody to ask.
It is a row here and not a key in chock.zon because it widens. An
organisation writes .{ .action = "sandbox.jit", .decision = .deny } in its
bundle and no project can raise it, because the answer is a minimum over both
layers. The same fold means a subagent cannot give up hardening its parent
kept.
What is given up is documented hardening and it is not a boundary. sandbox.md lists the three ways past the rule, and the three places a session that gave it up says so.
workspace.integrate
workspace.integrate is the second row of that shape, and it is read the other
way round from sandbox.jit. A project says in its chock.zon how an approved
apply should land, with .apply = .{ .mode = .rebase } and the four modes
approvals.md lists. This row says whether an approved
apply may move a branch of yours at all. There is no mode that means “move
nothing”, so this row and a n at the prompt are the two ways to say it.
.{ .action = "workspace.integrate", .decision = .deny }
A row nobody wrote answers allow here, unlike every action above, because
this is a question about a capability and not about an act. That is the same
reading provider.<instance> gets. One deny in an organisation’s bundle
closes the road for every project under it, and the same fold means a subagent
moves no branch its parent could not.
This row decides whether, and .apply.mode decides where. deny is the one
decision that takes the capability away, so it is the one decision that parks
the work at the ref. ask, agent_review and agent_then_human each say that
integration is permitted once somebody says yes, and none of them names a
landing, so they keep the mode the project configured. The one question an
apply puts is the workspace.apply approval, which is a different row.
The answer is read once, when the session starts, before an apply is described, because the description is what a person reads and it has to say what the apply does. So the mode is settled before the prompt exists, and a yes at that prompt carries the work in the mode the prompt named.
A rule that names nothing reaches this row. A catch all
.{ .decision = .ask } and a workspace.* rule both match
workspace.integrate, while a rule that names workspace.apply alone does
not.
workspace.bind.*
A path the git worktree does not carry reaches the agent only when it is named
twice: once in a workspace block of chock.zon, and once here. The name a
bind is asked under is the name as the block spells it, so
.{ .name = "scripts/release" } asks under
workspace.bind.scripts/release.
.{ .action = "workspace.bind.*", .decision = .deny }
That row refuses the whole mechanism, and an organisation that writes it in its bundle refuses it for every project under it. A row naming one path refuses that path alone.
For a bind that copies nothing back, read_only and temp_copy, the answer
decides whether the bind is made at all. For write and copy, the answer is
folded with the block’s own write field and the narrower of the two wins. A
write bind is read only until that fold answers allow. A copy bind is
written back only when the workspace.apply prompt that names it is permitted.
The block itself is in configuration.md.
device.*
A project reaches a USB or serial device only when it is named twice: once in a
devices block of chock.zon, and once in a policy rule for the action that
block names. Chock ships no default for device.*, so a device named in the
devices block and nowhere in policy.rules still answers ask, and ask
refuses here: there is nobody at the keyboard while a session is already
running.
.{
.devices = .{
.{ .action = "device.usb.1d50.6018" },
},
.policy = .{
.rules = .{
.{ .action = "device.usb.1d50.6018", .decision = .allow },
},
},
}
The action name is the device’s identity and never its path. /dev/ttyUSB0
changes with plug order and after a reboot, so a rule written against it stops
being true the moment the board is unplugged and plugged back in. A USB device
is named device.usb.<vendor>.<product>, in lower case hex. A serial adapter
with a serial of its own is named device.tty.serial.<serial>, because a whole
run of USB-to-serial chips from one factory can share a vendor and product id.
A serial adapter with none is named device.tty.<vendor>.<product>, the same
shape as a USB device.
Naming a device in the devices block only says the project wants it. The
policy rule beside it is what lets a session reach the node. See
running.md for when a device that arrives is picked up, and
sandbox.md for what the grant does and does not bound.
lsp.*
lsp.<program> is the action for starting a project’s language server, named
after the last part of the program’s path: a command of
/nix/store/aaa/bin/zls asks about lsp.zls. Chock ships lsp.* as allow,
so a project that already has one is unchanged, and an organisation that wants
none writes one rule:
.{ .action = "lsp.*", .decision = .deny }
A label is not an identity. A project writes its own chock.zon, so a project
that wanted to could point the name zls at another binary. lsp.* is the
rule to trust, and lsp.zls is a convenience. What bounds the damage is not
the name: the server runs inside the same sandbox a tool call gets, and reaches
nothing a tool call cannot. A program whose name cannot be one label of a rule
does not start at all, and says so.
mcp.* and plugin.*
A tool a third party supplies is decided twice: once at session start, and then
on every call. Only a deny is spent at the start, and it keeps the tool out
of the session entirely, so a denied tool costs no context and asks nobody.
Every other answer leaves the tool offered and puts the same key to the broker
one call at a time, so a promise the session makes with restrict_self binds
the very next call.
.{ .action = "mcp.*", .decision = .deny }, // no MCP tool at all
.{ .action = "mcp.time.tool.*", .decision = .allow }, // every tool of that server
.{ .action = "mcp.time.tool.get_current_time", .decision = .ask }, // that one tool, per call
.{ .action = "plugin.hello.tool.greet", .decision = .agent_review }, // that one plugin tool
The tool segment is there so that a tool a third party names network cannot
become a rule about mcp.<server>.network, which is a separate key about
whether that server’s process reaches a host at all.
A plugin tool is also priced against each capability it declares, under that
capability’s own ordinary action name such as fs.write. A capability has to
be allow outright, because the capabilities of every offered tool decide the
set of host functions the plugin is built with, once, before any guest code
runs. plugins.md has the plugin side of it.