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:
- Call
existingPathKindon the path.envresolves to, to learn whether something is there and what kind it is. - Call
mountCallto 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.