5.2. Security model
Fig. 5.2 Capability matrix and the authorization pipeline.
5.2.1. Execution modes
Role filtering happens before these modes are evaluated. Planning, Explorer
and Reviewer receive registry-derived read-only tool views, no memory-write or
checkpoint capability, and SAFE command execution. Registry metadata is
not an authorization substitute: every selected command and path still passes
through the capability, workspace, CommandRunner and Bubblewrap checks
described below.
SAFE—spear-chat(no flag, or--safe)The default. Read-only work. Nothing mutates, nothing reaches the network. A write is refused outright; it is not proposed.
ASK—--ask(aliases--confirm,--no-bypass)Mutation and network are possible, each behind an explicit confirmation.
AUTO—--auto(aliases-y,--yolo,--bypass-permissions)Mutation and network without confirmation.
--no-networktakes the network away from any mode, the web tools included.
The flags pass straight through spear-chat to rag_chat.py, and the
startup banner names the active mode plus the flags that reach the other two —
that line is the only place most users will ever read them.
5.2.2. The control plane in front of the coding core
The coding core (Implementation mode) never touches the host itself: every read, write, deletion and command it asks for is a call on SpearHost, and SpearHost applies the same rules this page describes to all of them, whichever tool asked.
Workspace containment. Every path — and every command’s working directory
— is resolved, symbolic links included, and must land inside the workspace.
A link cannot carry a write outside it, and delete_file removes a link, not
what it points at.
Source and write scope. A request that names its target confines writes to
that target and what belongs to it: a sibling of the same family (another
board, another driver) is refused unless the request widened to the whole
family, and the coding core has no argument that lets a write past it. The
same judgement is applied to what a shell command writes — a redirection, the
destination of cp, mv, install, ln or rsync, the of=
of dd, the operands of rm, touch, mkdir, tee,
truncate or sed -i, and any path an inline interpreter program names —
with relative operands read from the directory the command runs in
(request_scope.py). A terminal command cannot make a change the file
tools would refuse.
Project scope. A command that searches outside the project (find or
a recursive grep rooted elsewhere, locate), or names a path of the
home directory outside it, is refused unless the operator named that path:
another checkout is another project, not a source of this one’s build.
Generated and protected trees. Build outputs (/generated/,
/build/tmp/, files whose head says DO NOT MODIFY, DO NOT EDIT,
auto-generated or @generated) and snapshot or third-party copies
(.back, .pristine, .0 trees, vendored u-boot, atf,
qemu) are never written; the refusal points at the source to change
instead. The rule belongs to the target, not to the tool
(target_policy.py): write_file, patch, delete_file and every
shell write listed above ask the same question, about the path as written and
about its realpath, so a link or a .. does not change the answer.
Read-only and advisory turns. A turn that only asks to be told something, or is told not to change anything, keeps its reading tools and runs its commands without workspace write — so the prohibition cannot be routed around through the terminal.
Network. A command reaches the network only if the session’s mode grants it (below), through the sandbox’s own network stack.
Repeated refusals. A refusal is deterministic, so a turn that asks for the
same refused operation five times is stopped and the stop audited
(repeated_refusal_stopped, Tool execution harness).
External capabilities. spear-capability and spear-knowledge are
host commands: SpearHost recognises them in a terminal call and answers
them itself, and no shell ever runs them. Each must stand alone on one line —
not in a pipeline, a list, a redirection or a substitution. A capability runs
through the gateway (capability_gateway.py) only if its provider is
admitted for the workspace and task class and the arguments fit its declared
schema; a WRITE capability also needs a deployment that does not refuse
it, a turn allowed to change things, and the session’s confirmation (refused
in SAFE, asked in ASK, accepted in AUTO). Providers are MCP
servers started by SPEAR on the host with only the environment their
registration names; they are outside the Bubblewrap sandbox, which is why
what a registration may do is the deployment’s decision.
Audit and evidence. Every call is audited, every mutation checkpointed for
/undo, and every call recorded as canonical evidence — what it changed,
what it ran, how that ended — from which the turn’s verdict is computed
(Evidence and verdicts).
5.2.3. Capabilities
class Capability(StrEnum):
FILESYSTEM_READ = "filesystem:read"
HOST_READ = "host:read"
WORKSPACE_WRITE = "workspace:write"
SHELL_COMPLEX = "shell:complex"
NETWORK = "network"
GPU = "gpu"
SSH = "ssh"
REMOTE_WRITE = "remote:write"
CONTAINER_RUNTIME = "container-runtime"
SECRETS = "secrets"
The default policy:
Capability |
SAFE |
ASK |
AUTO |
|---|---|---|---|
|
yes |
yes |
yes |
|
yes |
confirmed |
yes |
|
no |
yes |
yes |
|
yes |
yes |
yes |
|
no |
confirmed |
yes |
5.2.3.1. Why reading outside the workspace is a grant, not a hole
host:read lets a read-only command name an absolute path that no declared
root contains. Refusing them made whole questions unanswerable — “read the
notes in /srv/notes” died on path argument may escape the
workspace, a refusal the model could not act on — while buying no
containment, because looking at a file is not changing it.
The grant is read-only by construction: the vetted paths are exposed
read-only (--ro-bind-try, so a file gone in between reports ENOENT)
and bound before the workspace mounts, so a declared tree
nested under one of them keeps the write access the workspace gives it. A
command that writes there meets EROFS, which is the truthful error.
Three things stay refused, and each is checked against both the literal spelling and the resolved one, so a symlink is not a way round:
credential stores —
.ssh,.gnupg,.aws,.docker,.kube,.netrc,.pgpass,.git-credentials,id_*,shadow,gshadow,sudoers. The model is served over the network: a file read here is a file sent there.the sandbox’s own mount points (
/proc,/dev,/usr,/etc, the sandbox$HOMEand/tmp,/workspace) and any ancestor of one — binding/homewould land on top of the sandbox’s$HOME.relative ``..`` traversal — the classifier cannot know which directory a shell stage resolved against, so the same string may denote two files. The refusal now says to name the path absolutely instead.
host:read is listed as sensitive, so ASK confirms each command that
leaves the declared trees, and every such read is recorded in the audit trail
even though it is read-only.
5.2.3.2. Why SAFE may run a pipeline
SAFE grants shell:complex because what makes SAFE safe is the
mount, not the classifier: without workspace:write every root is
bind-mounted read-only, so a pipeline physically cannot write. Refusing
find … | head bought nothing — find … -type f was allowed, adding
| head was not — and an assistant that cannot pipe cannot search a tree.
Writing is still refused, twice over: a redirection or a writing binary
declares workspace:write, which SAFE does not grant, and the read-only
mount would refuse it regardless. tee is listed among the mutating
binaries for exactly that reason — it writes a file with no redirection
operator, so the > heuristic never sees it — while a redirection to
/dev/null no longer demands write access, since it writes nothing.
5.2.3.3. Why AUTO has the network, and how to take it away
AUTO used to withhold it while ASK granted it, on the argument that
network access is the one capability whose consequences leave the machine and
cannot be reviewed afterwards from the workspace diff. The argument is sound
and the placement was not: AUTO is ASK without the prompt, so it cannot
grant less than ASK. The result was that -y — reached for precisely
to stop being blocked — was the one mode that still refused curl, while the
banner advertised it as the permissive one.
--no-network is the way to an offline session, and it now means the whole
session: CapabilityPolicy.without_network() drops the capability from every
mode, and rag_chat also withholds search_internet and fetch_url.
Those two reach the internet from the chat process, outside the capability
policy entirely, so a flag that only emptied the execution modes would have
read as offline without being it.
Reading a page needs no capability at all: fetch_url is a native tool, so
it works in SAFE. Its boundary is written in web_fetch.py instead —
http/https only, public addresses re-checked at every redirect, a byte cap
enforced while streaming. Saving one to disk is a mutation and goes through
the ordinary write authorization, so SAFE refuses it.
5.2.3.4. Declared but not implemented
gpu, ssh, container-runtime and secrets are named in the enum
but have no implementation. build_argv() raises before execution:
unsupported = profile.capabilities & self._UNIMPLEMENTED_PROFILE_CAPABILITIES
if unsupported:
raise ValueError(f"capability/profile not implemented: {names}")
Naming them without implementing them is the point: a future profile that requests one fails loudly at the boundary instead of silently receiving a sandbox that does not actually grant it.
5.2.4. Command classification
CommandPolicy.classify() maps a command to one of the classes below
(classify_script() judges a coding-core terminal command, which always
runs as a bash script, as that script); CommandPolicy.authorize() then
checks the class and its required capabilities against the mode:
READ_ONLYInspection only. Available in every mode.
WORKSPACE_MUTATINGWrites inside the workspace. Needs
workspace:write.SHELL_COMPLEXNeeds shell syntax — pipes, redirections, substitutions. Needs
shell:complex, and is executed as an explicitbash -lc <script>argv inside the sandbox (/bin/shwhere there is no bash), behind aset -o pipefailprelude so a failed stage is not masked by the last one. The harness never usesshell=True.DANGEROUSRefused.
Two rules about redirections are worth stating, because getting them wrong is expensive in both directions:
<and>introduce a file, not a command. Treating the token after them as a command stage made/dev/nulltrip the “argv[0] contains a slash” rule, so every command carrying2>/dev/null— the most common idiom in shell — was refused as a dangerous shell stage,ls 2>/dev/nullincluded. An assistant that cannot suppress stderr cannot search a tree, and a real session was spent discovering that.The redirection target is still checked, as a path. An absolute target, or one escaping the workspace, stays dangerous;
/dev/null,/dev/stdoutand/dev/stderrare the documented exceptions, since they discard rather than write.
5.2.5. The workspace boundary
Workspace.resolve() is the single gate for every filesystem tool path.
5.2.5.1. Roots
The boundary is a set of roots, not a single directory.
Primary root — the launch directory. Relative paths resolve there and nowhere else, so one string never denotes two files depending on the root list.
Extra roots — the corpora registered in projects.json, and the build
tree that encloses the launch directory when there is one (recognised by its
env.sh and scripts/build.sh). They are declared so a file in another
tree can be edited, or a component built from its umbrella, without
relaunching; --single-root drops them and restores the
launch-directory-only boundary. Because widening
the write boundary must never be silent, the startup banner names the count.
Each root is bind-mounted in the sandbox at its own host path, so an
absolute path means the same file inside and outside (Sandbox); where
that path is unsafe to mirror, or with SPEAR_SANDBOX_IDENTITY_MOUNT=0, the
primary falls back to /workspace and the others to /workspaces/<name>.
Every root has the same access as the primary (read only in SAFE,
read-write otherwise). Roots are canonicalised,
deduplicated, and never nested — a nested root would be mounted twice and make
a path’s label ambiguous. A registry entry whose tree has disappeared is
dropped rather than fatal.
One addressing scheme. A mount path — the host path itself, or the
/workspace and /workspaces/<name>/… names — is accepted by the shell
and by the file tools, which act on the host: the mount path is translated
back to its host root before containment is checked. Two schemes
that silently disagree would be a guaranteed source of wrong paths. Host
absolute paths into the launch directory remain gated by
--allow-absolute-paths; declaring extra roots does not widen what the
launch directory accepts.
Audit labels qualify a secondary root — so3:usr/src/ping.c rather than a
bare relative path that would read like a file of the launch directory.
The command classifier follows the same rule: an argument or redirection target
under a mount is not an escape, while /etc/passwd, /workspace-evil and
anything traversing out of a mount (/workspace/../etc) stay dangerous.
candidate, from_mount = self._from_mount_path(Path(value).expanduser())
resolved = (candidate if candidate.is_absolute() else self.root / candidate).resolve(strict=False)
containing = self._containing_root(resolved) # None if under no root
- Symlink escape.
Path.resolve()follows every existing symlink component, including the parent of a file that does not yet exist. Checking containment on the resolved path is what stops a write toworkspace/link-to-etc/passwd— from every root, not only the primary one.
5.2.6. Audit
AuditLogger.record_mutation() appends a metadata-only record for mutating
attempts. What is deliberately not recorded: file contents, command
output, environment values, bus addresses, cgroup paths, unit names.
Two redactions happen before anything is written:
_SECRET_ASSIGNMENT = re.compile(r"(?i)\b(api[_-]?key|token|password|secret)\s*=\s*[^\s]+")
and leading KEY=value environment assignments are stripped from the argv
before the executable summary is derived, so FOO=secret make is summarised
as make, not as something containing secret.
5.2.7. The fail-closed rule in practice
There is no degraded mode
Every one of these returns a failed ToolResult and spawns nothing:
bwrapabsent or not executable;bwraprefused by the kernel at preflight;prlimitabsent while resource limits are active;systemd-runorsystemctlabsent while cgroup limits are active (unless an outer container runtime owns the cgroup, Resource control);the systemd user bus unavailable;
a required cgroup controller not delegated;
slirp4netnsabsent, or unable to attach through pinned namespaces;a previous network containment failure (poisoning).
None of them falls back to a less confined execution.
The tests assert this negatively — popen.assert_not_called() — because
“the command did not run” is the property that matters, and it is easy to
satisfy accidentally with a test that only checks the returned status.