Skip to content
Chock
The threat model (24 of 30)

The threat model

Chock assumes a hostile model and not a careless one. SECURITY.md says it in one line: “Chock runs a language model that writes and runs code. The model is not trusted.” The sandbox is not built to catch a mistake. It is built for a model that means to escape, whatever put that intent there: a prompt hidden in a file the model read, a dependency that talks back, or the model’s own behaviour with no outside cause at all. The document does not try to tell those apart, and neither does the sandbox: it treats every tool call as if it might be adversarial and holds the same layers either way.

A hostile repository is in scope only through the model. Chock does not defend a maintainer against a pull request. It defends the rest of the machine, and the user’s real project, against what a model does after it reads one. The repository itself is never trusted with more than the workspace copy it is given, and the real project is never the thing a tool call writes to. See “Where the boundary is” in SECURITY.md and “The workspace” in sandbox.md.

A person approving an action is trusted, and the log that records the approval is the evidence, not the model’s account of what happened. See “A model’s own statement about what it did is not evidence” in SECURITY.md.

One attack, walked end to end

This is a real fault, found and fixed in this project, not a hypothetical.

applyDenyMounts, in palisade’s namespace.zig, is what turns a project’s deny_read entry into a mount that hides a path from a tool call. A project writes deny_read: [".env"] in chock.zon because it does not want an agent to read that file even inside the throwaway workspace, and Chock answers by binding an empty notice file over it, so a read returns a fixed message instead of the file’s contents.

The function used to do this in two steps, each one by name:

  1. Call existingPathKind on the path .env resolves to, to learn whether something is there and what kind it is.
  2. Call mountCall to bind the notice file onto that same path, again by name.

The window is between those two calls, and mount is what makes it dangerous. mount follows a symbolic link at its target the same way open does. If .env is a symlink whose target lies outside the workspace, either because the project shipped it that way or because a tool call earlier in the same session created it, the bind lands on whatever that link points to, not on the name the project wrote. A directory the project never asked to touch gets a notice file mounted into it, and mounting is a write. The two calls being sequential does not by itself create the hole. What creates it is that each call resolves the name freshly, so nothing guarantees that the second resolution lands on what the first one inspected.

Checking twice does not close this. A caller might try statx before the mount and lstat after, comparing the two, and conclude the mount is safe because both readings agree. That comparison only detects a change that already happened: it cannot stop the mount from being issued in the first place, because mount itself still resolves the name a third time when it runs, independent of either check. Two name based checks around a name based mount are three name resolutions of the same mutable path, not one, and nothing pins any of them to the same inode.

The fix pins the inode before the mount, and never resolves the name again. pinDenyTarget opens the target once with O_PATH | O_CLOEXEC | O_NOFOLLOW. O_PATH gets a descriptor without reading through the file. O_NOFOLLOW is supposed to refuse a symlink, and mostly does, but not at the last path component when O_PATH is also set:

open with O_PATH | O_NOFOLLOW on a symlink leaf succeeds. It hands back a descriptor on the link itself, not on whatever the link points to, and it does not fail. A caller that treats a clean return from that open as proof the target is an ordinary file is wrong, because a symlink leaf gives a clean return too. The refusal has to come from a second call: statx on the descriptor, with AT_EMPTY_PATH, reading the mode of what the descriptor itself names rather than looking the path up again. Only that call’s S_IFLNK check catches the symlink, and it catches it without ever resolving the original path a second time, because it asks the descriptor about itself.

Once the descriptor is confirmed to be a regular file (or freshly created, through O_CREAT | O_EXCL on the parent directory, when nothing was there yet), applyDenyMounts mounts through /proc/self/fd/N rather than through the original path string. /proc/self/fd/N names the exact inode the descriptor holds, so whatever the path .env resolves to by the time mount actually runs, the bind lands on the file that was checked and nothing else.

The source has no second name. The target still does. applyDenyMounts passes the target path as a name, both to the mount call that makes the bind and again to markReadOnly, which remounts it read only afterward. A target renamed between those two calls does not reopen the hole this section describes, because a target name only decides where the fixed, already checked source lands. markReadOnly’s own lookup either finds the mount applyDenyMounts just made or finds nothing there at all, and the second case answers EINVAL, which this code reads as a fault and aborts on rather than pressing ahead. So the failure is a safe one: the worst a second name can do here is stop the mount, never redirect it.

The lesson generalises past this one function: a security check on a path name and an operation on that same path name are two different lookups unless something pins them together. Comparing the results of two lookups after the fact is not the same as making a third lookup impossible.

Boundary and hardening are not the same claim

A boundary is enforced by the kernel and a defeated boundary is a security bug. Hardening raises the cost of an attack that has another way through, and a relaxed hardening setting is a known, named trade a project made on purpose, not a broken sandbox.

The write-and-execute rule in the seccomp filter is hardening, and sandbox.md and SECURITY.md’s own scope list say so directly: a page that is both writable and executable is not, by itself, an escape. chock doctor reports a write^execute row in state OFF, with a note that this project’s policy allowed it, when a project turns the rule off for a JIT runtime such as V8, and the red team oracle in test/redteam/scope.zig is written to agree: a session that relaxed this rule and did nothing else must never be scored the same as a session that broke out. Landlock, the namespaces, the 31 seccomp calls that kill the process outright, and the two argument-inspecting rules that kill on execveat’s AT_EMPTY_PATH and socket’s AF_VSOCK are boundaries. Their absence, or a kernel that degrades one of them, is what chock doctor reports as a blocking row, and a real defeat of any of them is a security bug under SECURITY.md’s own definition.

Reading a relaxed boundary and a relaxed piece of hardening as the same kind of fact is the mistake this section exists to prevent. A project that allows sandbox.jit has made one specific, logged, reviewable trade. A project whose Landlock ruleset failed to load has no sandbox at all.

What is not covered

A threat model that only lists what holds is marketing. These are gaps that were found by trying, and not a hedge.

A language server cannot start in a project that permits a host. The language server config is built by lib/chock-core/tools.zig’s prepare, which carries the session’s network mode through and attaches no router, because only a tool call has one. So the config is filtered with no seam, and palisade’s Linux driver refuses it before the fork rather than running a server with a network nothing filters. The refusal is the right response to a state that should not exist: a language server needs no socket and should be given Network.none, which is not what the code does today.

The session log is tamper evident and not confidential. It is created readable by anybody with an account on the machine, and the directory holding it is too, while the approval socket beside it is not. So a hash chain tells you whether a record was changed and tells you nothing about who read it. On a shared build host, treat a session log as readable by every user of that host.

macOS has no system call filter, and this is permanent rather than a missing feature. (deny syscall-unix (syscall-number 26)) compiles in a Seatbelt profile, applies with no error, and ptrace still returns 0. Only the blanket (deny syscall-unix) has any effect, and that also blocks execve, which every tool call needs to start a program at all. So the layer that caught an io_uring bypass on Linux, the seccomp section of sandbox.md, has nothing that plays its role on macOS.

macOS has no bind mount, so there is no capped scratch area. Every path Chock would place under a mount point on Linux instead appears at its own real host path on macOS, because there is no pivot_root and no ordinary user can mount a filesystem to cap it. A file a tool call writes through TMPDIR or CHOCK_SCRATCHPAD survives the call. lib/chock-core/scratchpad.zig names both.

sysctl-read is granted by default on macOS, and a program can read the host process table through it. KERN_PROC_ALL names every process on the machine, in one call, and the answer runs to roughly 192 kilobytes on an ordinary desktop. Darwin has no PID namespace to hide that table behind, and taking sysctl-read away breaks ordinary software that expects to enumerate processes, so it stays granted. The program still cannot act on any process it sees this way. It can only see that the process exists, under what name.

An MCP or plugin tool is decided twice: once at session start, and then on every call. chock_core.mcp.Session.admit and chock_core.plugin.Session.admit read the policy table once, before Loop.run takes the session log’s lock, against mcp.<server>.tool.<tool> and plugin.<plugin>.tool.<tool>. Only a deny is spent there, 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 decided one call at a time: chock_core.mcp.Session.dispatch and chock_core.plugin.Session.dispatch put the same key to chock_core.arbiter.Asker before anything reaches the third party process, so a row of ask, agent_review or agent_then_human reaches Broker.request, which answers each the way its row says. Because the question is asked again on each call, Broker.request folds this session’s own restrict_self promises in with chock_policy.ratchet.narrow, and a promise made half way through a session binds the very next call.

A session with nobody to ask runs no such tool at all. The asker is filled in by src/run.zig, and the session log handle inside it arrives from chock_core.Loop.GiveLocked once the loop holds the lock. Until both are there, chock_core.arbiter.Asker.decide answers not_asked, which does not permit. So a wiring nobody finished leaves a session with no working third party tools, and never a tool that runs ungated.

Two limits stay. A plugin tool’s declared capabilities decide the import set the whole plugin is instantiated with, once, before any guest code runs, so a capability the table does not allow outright refuses the tool at load time: an import cannot be taken back once supplied. Each call still asks about the tool’s own action and about every capability it declared, so a narrowing mid session still bites. And the set of tools a session holds is fixed at the start, so a server that gains a tool mid session is proposing a widening with nowhere to land.

A session is given a network only when its policy permits one. src/run.zig reads policy.wantsRouter(), which is true when any rule permits something under net, and a project that permits nothing there gets Network.none: no interface that can deliver a packet, and no seam to ask on. Nothing shipped makes wantsRouter true, so a project with no chock.zon has no network in a tool call at all.

A project that does permit a host gets a router, and then every name is judged before DNS runs. lib/chock-broker/network.zig refuses a name no rule reaches without asking anybody: a host outside the permitted set is not a question a person can answer yes to, it is a refusal at the resolver. So a single net rule opens the hosts that rule names and no others.

A background run_command call gets the same router, not Network.none. What makes it safe is narrower than that: a background slot has nobody to ask, so only a table allow passes, and a host that would have raised a question is refused instead of queued.

If any point above stops being true, this document is wrong until it is corrected. Each one was checked against the code, not carried forward from an earlier note.