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, and this page is the reference for the names themselves.
What Chock ships
Chock ships default rules for the ordinary tool calls, so even a project with
no chock.zon at all 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.serve.<port> |
a port of a background command, reachable from this machine | 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 |
style.parse |
parsing a file a write would leave behind, for the project’s style rules | none, so ask |
device.* |
a USB or serial device | none, so ask |
mcp.* |
an MCP server, its tools, and the host a remote one is reached at | 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. So writing
.{ .action = "call.write_file", .decision = .ask } in your own chock.zon
puts that call back behind a prompt.
call.*, the ordinary tool calls
Each tool gets one action, named after the tool itself. 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. 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 says 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. The
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 when the agent itself named the host, 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 no rule names 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 wear you down. 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.
net.serve.<port>
A web project wants the server the agent just started to be reachable from the
browser on this machine. net.serve.3000 is that port, and a rule that names
it and answers allow publishes it:
.{
.policy = .{
.rules = .{
.{ .action = "net.serve.3000", .decision = .allow },
},
},
}
http://127.0.0.1:3000 then reaches port 3000 inside the sandbox of a
background run_command, which is where a dev server runs: a foreground call
holds the turn until it ends, so a server started there would hold the session.
The listener is on 127.0.0.1 and nowhere else, so a published port is for the
person at this machine, and a sandbox is not a way to put a service on a
network.
The port has to be a number a person wrote. net.serve.* publishes nothing: a
listener is opened on the host for each published port, and a pattern names no
port to open. A deny further in, or an org ceiling, still refuses one of
these the way it refuses anything else.
A port can also be asked for while a session runs. serve_port is a tool the
agent calls once it has started a server and knows the port, and the answer to
net.serve.<port> is what decides, so ask there reaches a person with that
exact number in the question. The tool is offered only where some rule could
permit a port: a project that names none is not offered it at all, the same way
provide_tool appears only where nix.build is answered.
.{
.policy = .{
.rules = .{
.{ .action = "net.serve.3000", .decision = .allow },
.{ .action = "net.serve.*", .decision = .ask },
},
},
}
Three things are reported rather than guessed at: a port under 1024, which needs privilege this session does not have, a port something on this machine is already listening on, and more ports than one session may publish. Each says which port and why.
A published port is Linux only. The path inward is a network namespace and a helper that dials loopback in it, and macOS has neither, so a session there says that no port is open and runs on without one.
What carries a connection in is a helper of its own inside the sandbox, with one power the router is refused: it may dial, and only loopback in that namespace. sandbox.md says why.
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, and a project that permits nothing there is not, so it 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, 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 there waiting
for the answer. So a background command reaches what this policy allows
outright, and a host that would have asked you is refused rather than queued.
Set .background = .none for a project where a background command should
reach nothing at all.
web.search
The web_search tool gets one name, with no host under it. 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, and only because
the agent named it.
skill.read.<layer>
There are 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, never the skill. A per skill action would put a name a package chose into your action namespace, which is the one 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 yourself. 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.*
Each secret a project’s secrets block names gets one action, 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 it 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. On 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 tells you which rules are 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, and an option the
shim cannot read stops it reading the subcommand at all, so that one 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 just 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 away hardening its parent
kept.
What is given up is documented hardening, 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 rather than 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 asks 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, 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 it says so.
style.parse
One action covers a project’s whole style block: whether any of its rules may
run. A rule parses the content a write would leave behind, and a parser is a
program whichever engine answers, so this is a policy decision and not a
configuration key.
.{ .action = "style.parse", .decision = .allow }
Chock ships no default, so a project that writes rules writes this rule too.
Anything other than allow leaves every write unchecked and says so in one
line at session start, which is the opposite of what a rule is for: a rule that
quietly does not run is worse than no rule.
There is no action a rule, because a rule cannot do anything a parse cannot. A rule reads the content the call already carries, answers a number, and either refuses the write or does not. See style.md.
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 server the project named by url instead of command is one somebody else
runs, and the host Chock reaches it at is its own key:
.{ .action = "mcp.notion.host.com.notion.*", .decision = .allow }, // that domain, for that server
The host is written last label first, as it is in net.fetch.<host>, so a rule
about a domain is a rule about every name under it. Only allow reaches a
host, because a server starts before anybody can be asked. The key holds the
server name, so a rule cannot authorise a host for a server it was not written
for. remote-mcp.md has the rest of it.
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.