Roadmap
What Chock does not do yet, and the faults it is known to have. This is a snapshot and not a promise. Chock is an early prototype.
This page holds no list of what works. Read the pages in README.md for what each part does, and treat anything absent from them as not built.
What is not built yet
-
One act is agent-requestable and the rest are not. This is the largest remaining gap in the approval model, so read it before the rest. An ordinary tool call is asked about:
gateToolCallinLoop.runbuilds the action nameTool.actionIntonames for the call and asksdeps.arbiter, the same seamrunRestrictSelfuses for a widening. The shipped defaults answerallowfor almost every one of those action names, so a project with nochock.zonof its own gains no new prompt.There are two deliberate exceptions. The first is
exec.unparsed, whichrun_commandanswers for a path it refuses to resolve lexically, most often one holding a... It holds no shipped default at all, the same asnet.connect.*andnet.fetch.*, so a project with nochock.zongains a new prompt the first time such a path runs. A default ofallowthere let such a path escape every exec rule a project wrote, naming none of its four classes. Seelib/chock-policy/defaults.zig, which holds the shipped decisions. The second isexec.nix.store.*, a store path this session did not start with, which ships asaskwhile the dev shell closure it split off,exec.devshell.*, ships asallow. actions.md has both.Seven tool names are skipped by this gate and decided elsewhere, at the key that actually works.
spawn_agentis bounded bychock.zon’s ownsubagentsblock,max_widthandmax_depth.restrict_selfneeds nobody’s permission to narrow, and asks aboutratchet.widen_actiononly to widen.fetch_urlis decided per host, once the URL is known, atnet.fetch.*andnet.connect.*.request_actionis decided at the requested act’s own name.update_plan,ask_userandset_titlegrant no capability and need no key at all.An MCP or plugin tool is not asked about by this gate either, and is gated at its own door instead.
chock_core.mcp.Session.dispatchandchock_core.plugin.Session.dispatchaskchock_core.arbiter.Askeron every call, through the same broker every other mid-session question goes through. Session start still reads the table once, and adenythere keeps the tool out of the session entirely, so a denied tool costs no context and nobody a question. An.ask,agent_revieworagent_then_humanrow is put to the broker, which answers each the way its row says, and a mid-sessionrestrict_selfnarrowing binds the very next call. A session with nobody to ask runs no such tool at all.The two key shapes are written out here, because a rule that matches nothing looks exactly like a rule that allows. An MCP tool is
mcp.<server>.tool.<tool>and a plugin tool isplugin.<plugin>.tool.<tool>. Thetoolsegment is there so that a tool a third party namesnetworkcannot become a rule aboutmcp.<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 asfs.write, and those are asked about per call too. So:.{ .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 toolWhat is missing is a way for the agent to ask about a wider act.
request_actionis inchock_core.tools.Tool, and it takesworkspace.applyand nothing else: an agent that believes it is finished can ask for its commit to be carried into the user’s repository, and the policy table, or a person, answers. Every other action name is refused by name, before anybody is asked. An agent cannot ask aboutnet.fetchas an act,nix.buildorfile.write, and the table can answer those rows while nothing in a session asks them.Four callers reach the broker. Two are the harness itself:
chock runasks forworkspace.applyafter the loop has ended, and the session arbiter asks forpolicy.widenwhile it runs. The third is the agent, throughrequest_action, for that one act. The fourth is the git shim, which asks about every subcommand it classifies, sogit.commit,git.pushandgit.branch.deleteare rows a session really does ask.git commitruns with no prompt on a project that wrote nochock.zon, because Chock shipsallowfor every git action name that changes only the session’s own workspace. A subcommand that reaches another host is asked about and still does not run, even approved: the act that leaves the sandbox has no caller yet. -
Delegating a web search to the provider is not built. All three engine kinds run harness side, which is what keeps the egress in the broker and the result cleaned. Both Anthropic and OpenAI accept an allowed-domains list on their own search tools, and Chock’s policy already says which hosts an agent may read as
net.fetch.<reversed host>, so the allowlist would come off the policy table rather than being configured a second time. Until that exists, a delegated search would hand the agent content from hosts nobody approved by a path the broker never sees, which is the one thing this design will not do.And it could not honour its own policy default. The tool list is built once in
start(), so whether a provider-side search tool is offered is decided before any work, when nobody is waiting to answer.web.searchshipsask, and anaskread at that moment means never, which is exactly why the harness-side tool is gated at the call instead. So delegation is reachable only for a project that setsweb.searchtoallowoutright, and that has to be a decision somebody makes rather than a surprise they meet. -
A handover cannot carry a background command or a background subagent, so it waits for one. Both live in the process that started them, and that process is what writes their record into the log, so a handover that took the session would drop the record and tell the person who asked that the work carried on. A background command cannot move by any means: its thread reads the command’s output in this process, and the session’s own teardown ends the command. A background subagent could in principle be adopted, because its process and its log both outlive the parent and its log’s lock says whether it still runs, but that costs the property every replay reads, that each
session.spawnhas anagent.completeafter it. So the ask is held open instead and the session hands over once the work is recorded. What is missing is a handover that does not make the person wait for a long build. -
Only a git project’s workspace moves to a second process. A project with no git of its own gets the overlay backing, and
overlay.createcannot be called twice on the same scratch directory: the Linux driver refuses a directory that exists, and Darwin’s clone refuses the same.overlay.adoptstands besideoverlay.create, one per driver, and rebuilds the four paths over a layout that is already on disk. What is missing is the rest of a live handover for that backing.Workspace.adopttakes abase_commita project with no git does not have, andsrc/run.zig’s owntakenOverreads only a worktreeworkspace.open. Sochock detachrefuses a running overlay session by name, and nameschock workspace adoptin the same sentence. -
The work of an overlay session is reachable, and it is never applied for you.
chock workspace adopt <session>rebuilds the overlay, reads the upper layer withOverlay.changedFiles, and copies what changed into.chock-adopted/<session>/filesinside the project. Nothing of yours is written over and nothing of yours is deleted: a destination that already holds something is refused by name, and every path the session deleted is written to.chock-adopted/<session>/deletedfor you to act on rather than removed. The act is recorded as aworkspace.adoptevent on the session’s own log, with the three counts. This is the overlay’s answer torefs/chock/<session>: inert, complete, and yours to take with onecp. -
A session that ended some other abnormal way still rebuilds from committed state. Those sessions keep their workspace too, and adopting one is a separate decision with its own refusal.
-
There is no seen-line guard on
edit_file. An edit is anchored two ways: the text it replaces must be unique in the file, and thefile_hashthatread_fileprinted must still match, so a file that changed after the model read it is refused before anything is written. The hash is optional, so an edit that sends none is held by the uniqueness rule alone. What is missing is the stronger rule, that the line being edited was one a tool actually showed the model. That needs a record of what each read returned, and the tool runner is a fresh process per call, so the record belongs at the loop’s tool seam. -
macOS runs a real session, and two limits there are permanent. Seatbelt holds the paths, the network including unix sockets, the signals and shared memory. A whole session ran on the Darwin box on 2026-08-25:
read_file,write_file,run_command, the knowledgebase and a background task all answered, andcat /etc/passwdcame back “Operation not permitted”. Two things macOS cannot do: there is no system call filter, because macOS has none that works, and there is no bind mount. Every path Chock would place under/run/chock/appears at its own host path there instead, so an absolute path a program writes into a file names a directory that is deleted when the session ends. There is also no capped temporary area, soTMPDIRandCHOCK_SCRATCHPADname one directory and a file written through either survives the call.chock doctorreports both. See sandbox.md. -
Two CI runs touch macOS, and only one of them proves the sandbox. Nix on macOS puts every builder under
sandbox-exec, and macOS refuses to put one profile inside another. So theaarch64-darwinpart ofnix flake checkproves that the code compiles for macOS, and it proves nothing at all about Seatbelt: each test that needs a profile of its own asksseatbelt.confinedAlready, finds one already there, and skips.On a real Mac, macOS 15.7.9 with Nix 2.34.8 and
sandbox = relaxed, on 2026-08-26, both ways on the one machine: from a login shell the suite ran 103 of 103 steps with 2168 of 2192 tests passed and 24 skipped, and the two Darwin escape suites ran 17 and 3 tests with nothing skipped. Insidenix buildthe same suite skipped 46, and the two escape suites skipped whole. Every figure in this paragraph but the escape suite counts is that one run and is not taken again here. The counts are read from the files themselves:test/sandbox/darwin_escape.zigholds 17 tests andtest/workspace/darwin_escape.zigholds 3, and the first of those was 13 untilfff60feadded four Mach tests.The
test-sandboxjob runs the same suite on a macOS runner, outside any Nix builder, which is also how a person runs Chock. That job first proves the runner can enter a profile, and it fails rather than skips when it cannot, because a check that was not made is never a pass. A greenaarch64-darwincheck and a greentest-sandboxrun must not be read the same way. See .github/workflows/ci.yml. -
A tree of agents runs, and the agents in it are
chock runonly at one level. Real trees are driven in tests: a chain four levels deep, two children of one parent alive at the same time, a reviewer counted among a parent’s children, a slice of a slice of a budget, and a parent that resumed and did not hand out the money it had already promised. Every agent in those trees is a real process with a log of its own, and a subagent is a child process, not a thread, becauseforkcarries only the calling thread. What those trees do not have is a model in them: each agent is a test program that reads the command linechock runwould read and writes the logchock runwould write. A tree of realchock runsessions, with a provider behind each one, has never been run. -
A provisioning request holds the turn and has no deadline.
nix buildon a cache miss is minutes, and the turn waits for it, because the answer has to reach the mount set the very next tool call is built from and not the model. A line is printed before the wait so the terminal is not silent, and Ctrl-C reaches thenixchild the same way it reaches a subagent, but a build that never ends holds the session. It is also not asked about: the policy answer is read once, at the start, and decides whether the tool exists at all. -
A project cannot ship guidance of its own. The shelf
read_guidancereads is compiled into Chock, which is why no tool call can write it. Letting a project add to it needs the read only path into the project thatchock.zonalready has. -
A stream that broke in the middle of a reply is not retried. A 429 or a 5xx is answered with the same request sent again after a wait, up to six attempts spanning about a minute, honouring
Retry-Afterwhen the provider sends one. A broken stream is a different fault: the turn half happened, and sending it again is a larger change than the wait was. -
An approved
git pushruns. The real git runs inside the sandbox, reaches the remote through the network router, and is given its credential over a socket for that one call: git runschock askpass, which carries the prompt to the broker and carries one answer back. Which credential is chosen by the remote’s own scheme, read on the host at approval time. Anhttpsremote prompts a person for a password, ansshremote arms the agent proxy, and a remote that cannot be read refuses the push rather than guessing. Both are closed again when the call ends, so the capability lasts as long as the act and no longer. Every other subcommand that reaches another host is asked about and still does not run.A password is prompted live and never stored. There is no credential store entry for one, no configuration field, and nothing written to disk. The value is held for one tool call, covered by the redaction funnel for exactly as long as it exists, and overwritten when the call ends. A private key is never copied: an ssh agent signs and never hands a key out, so the sandbox gets “can sign with this key” and not the key.
The agent cannot use the socket as a credential oracle. A helper reachable from any tool call would let the agent ask for a password for any host
secret.password.*permits, as often as it likes, with no git involved. Live prompting is what removes that: nothing can be had without a person seeing a prompt, so the worst an agent can do is make noise, which is visible and refusable. -
chock migratereads a Claude Code, Codex, OpenCode, Zed or oh-my-pi configuration. It is a one shot, offline command: no session, no model, no network, no sandbox.--from <harness>names a harness, and a name this build does not read is refused against the list inreadersinsrc/migrate.zig. A foreign deny renders at.deny. A foreign allow renders at.ask, never.allow, because an allow read against one tool’s threat model does not become an allow under this one. A hook, a plugin, a skill and a slash command carry into no field: a reader puts each inFound.refusedwith the reason instead. An environment variable’s value is never read, only its name, throughenvName. It never overwrites achock.zonthat is already there: it prints the rows it would have added and writes nothing instead.A stance that covers a whole session carries into no row. Codex’s
sandbox_modeandapproval_policy, and Claude Code’sdefaultMode, are each refused and named in the report. A row for an action this build does not define would read as a carried stance and match nothing, which is worse than refusing it, because the file then looks faithful.migrate.vetis the net under that. It runs on every read and moves a hint naming an action this build does not define into the refusals, so a reader added later cannot put an inert row in the file by forgetting. A test holds every name the guard admits to whatchock_policy.table.patternIsWellFormedaccepts, because a name the table refuses would stop the generated file loading at all.Most foreign permissions do not survive this. A Claude Code rule names a tool and an argument pattern, as in
Bash(cargo test:*); Zed’s are regular expressions over command text; oh-my-pi’s and OpenCode’s are their own tool names. None of those is an action name here, so each is refused and named rather than guessed at.webfetchandlspin OpenCode reach a real action, and so doesWebFetch(domain:<host>)in Claude Code: a host is exactly whatnet.fetchnames, the labels reverse, and a*.prefix becomes the trailing.*this table already reads as any host under a name. A host that cannot be read exactly is refused, because a wrongly reversed name would grant reach to a host nobody chose.--sessionsbrings a transcript from another harness across. It makes a new session log and writes two events into it,session.startthensession.imported, and nothing else. The foreign turns are never written asmessageevents: this log is a hash chain thatchock sessions sealsigns, and writing another tool’s history into it would sign a claim about work Chock never saw. The transcript is copied beside the session and the event names it with the SHA-256 of its bytes, so a copy swapped afterwards is visible. The agent reads it withread_filewhen it needs to, the same way anAGENTS.mdin a subdirectory arrives as a path and not as pasted text. A real transcript runs to megabytes, so there is no other workable shape. Only Claude Code has a pinned location today; every other harness is refused rather than guessed at.--memorybrings another harness’s notes into the knowledgebase, as data and never as instructions. Those files are written by an agent and often read like orders, so promoting them would turn a note an agent wrote to itself into something it must obey.Both read under the user’s home, which the config readers deliberately do not. A transcript and a notebook live nowhere else, unlike a project’s own configuration.
--permission <class>carries an allow under that class as.allowinstead of.ask. It takesnet.fetch, and nothing else today. It exists becauseaskis a refusal for a fetch: onlyallowreads a host, so a fetch rule written ataskis the same as no rule. Without the flag nothing is carried as an allow at all. With it, only a host the source named itself is carried, never a wider one, and both the report and the generated file say which rows came in that way and why.The generated file opens with a header comment naming the version that wrote it, the date, and every file a reader read with the SHA-256 of the bytes as read, so an auditor can run
sha256sumon the same paths and tell whether the project has moved since.Found.sourcesis where a reader states that list, so carrying it is not a habit one reader can keep and another forget. The same list is in the report too, under “carried”, so it is visible even when the header is trimmed from a copy somebody keeps.All five readers are built: Claude Code, Codex, OpenCode, Zed and oh-my-pi.
--sessionsreads a Claude Code transcript and refuses every other harness by name, because only that one has been read at the source.
Known open items
-
The DNS rebinding window is closed for IPv4 and open for an IPv6-only name.
fetch_urlresolves a permitted host, checks every address it answers with, and then holds the connection to a checked address, so the HTTP client cannot resolve the name a second time and reach an answer nobody checked. That pin needs the address written where the client wants a host name, and Zig 0.16 refuses a colon there, so an IPv6 address cannot be written into one. A name that answers with IPv6 addresses only is therefore read by the ordinary path, and the window is open for it. Whoever runs the zone decides which records it answers, so an attacker picks when this applies. It is allowed because refusing such a name leaves Chock unable to read anything on an IPv6-only or a NAT64 network.The address check still runs over every address, IPv6 included, before anything opens, and it reads the two IPv6 spellings of an IPv4 address that a NAT64 network makes real:
64:ff9b::/96is unwrapped and answered by the IPv4 rules, andfd00:ec2::254is refused by name. The rest offc00::/7stays reachable, because unique local addressing is the IPv6 form of the private IPv4 ranges a permitted host is most often on. Only the well known NAT64 prefix is read that way. RFC 6052 lets a network pick its own prefix at several lengths, and such a prefix cannot be told from an ordinary global address by its bytes alone, so reading one needs the network to say what it is. Until that is configuration, a site with its own prefix gets no unwrap. -
A reverse proxy talks to
chock serveover--host, not over the socket. The socket check is the peer’s user id, so a proxy under its own account, such as nginx aswww-data, cannot open it. That is deliberate. Access by group is not built, and the answer for a proxy is--host 127.0.0.1. Bind the proxy to that address, and keep the port closed to everything else. The socket stays the default because it is what makes a barechock servesafe with no proxy at all.