Skip to content
Chock
The actions (19 of 30)

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

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 },
        },
    },
}
  • .auto is the default and reads the rules. A project that permits something under net is given a router, and a project that permits nothing there is not, so it pays for none of it.
  • .none refuses a router whatever the rules say.
  • .filtered gives 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.