Safety guard
Go code at the dispatch point, not an instruction in a prompt. It reads the actual arguments of every tool call before anything reaches a shell, refuses what has no safe form, pauses what leaves the machine, and rewrites what can be made safe.
Where it sits
Every tool call passes through Guard.Check before execution, and through Guard.Remediate after that. Neither is reachable by the model: they are compiled into the core, and a call that fails Check never becomes a subprocess.
Two tiers, and the difference matters
Denials are typed. This is the design position, not an implementation detail:
A denial reaches the model as [tier] blocked: reason — hint, so it learns what happened and what to do instead rather than retrying blindly.
Catastrophic rules
Nine patterns, matched against the command string. From internal/guard/guard.go.
| Matches | Reason | Hint |
|---|---|---|
:(){ | fork bomb | never allowed |
dd … of=/dev/ | dd writing to a device | never allowed |
dd … of=$VAR | dd writing to a shell-variable target (unverifiable — may be a device) | never allowed — use an explicit path |
mkfs | filesystem format | never allowed |
shutdown reboot poweroff halt | system power operation | never allowed |
git push --force | force push rewrites remote history | never allowed — use a normal push |
DROP DATABASE|TABLE | DROP on a database object | never allowed |
> /dev/sd|nvme|hd | raw write to a disk device | never allowed |
chmod 777 / | chmod 777 on root | never allowed |
dd … of=$VAR rule is the one worth studying. The target is a variable, so nobody can prove where it points — it may be a file, it may be your disk. The guard refuses what it cannot verify rather than hoping. That principle is why it holds up.
Sensitive rules
| Matches | Reason | Hint |
|---|---|---|
git push | push publishes to a remote | external side effect — needs user confirmation |
npm|pip|cargo|gem publish | package publish | external side effect — needs user confirmation |
curl|wget … | sh | piping remote script into a shell | download first, review, then run |
ssh scp rsync | remote connection | remote operations need user confirmation |
The curl | sh pattern also matches laundered forms — curl … | env sh, | sudo sh, | xargs sh — because matching only bare sh is a rule that looks right and does nothing. The ssh rule does not require an @: ssh 192.168.1.5 id runs a command on another host with no user in the target.
Path rules
The guard reads paths as well as commands. These apply to read, edit, write and grep, and to any path appearing in a bash command.
| Matches | Reason | Hint |
|---|---|---|
.env | environment file may contain secrets | use config example files instead |
.ssh/ id_rsa id_ed25519 | SSH material | never touch SSH keys |
*.pem *.key *.p12 *.pfx | key/certificate file | never touch key material |
credential secret token | possible secrets file | verify the path is not a secrets store |
The rm boundary
rm is not a pattern match, because rm -rf build/ inside your project is ordinary work and rm -rf / is not. rmViolation judges each target against the real workspace boundary:
- Plain
rmwithout-ror-fis left alone entirely. - An absolute path inside the workspace is an ordinary delete.
- Root, home, bare globs, traversal, and anything resolving outside the workspace are catastrophic.
~,$HOMEand${HOME}are expanded so they can be judged. Any other variable is refused as unverifiable.
Remediation
Not everything dangerous deserves a refusal. Where a safe form exists, the guard rewrites the call and discloses the rewrite. From internal/guard/hardrules.go:
cp -a A B && <verify> && rm -rf A — a copy that is checked before the original is deleted, so a move that dies halfway cannot lose files.file.bak.20260824-141802 — before it is touched. If the backup fails, the edit is blocked.The mv rewrite is deliberately conservative: it matches a single source and destination with no flags, globs, pipes or redirects. Anything fancier passes through untouched, on the reasoning that a half-understood rewrite is worse than none.
The workspace jail
File tools resolve every path against the workspace root, and lexical containment is not enough — a symlink inside the workspace can point outside it. internal/tools/fs.go resolves symlinks on the deepest existing ancestor and re-checks containment against the resolved path, which closes that escape:
path "…" resolves outside the workspace via a symlink
Recalled memory cannot give orders
Memory is a place text arrives from the outside. Anything recalled re-enters the conversation wrapped in a marked envelope, as text the agent read rather than instructions you gave:
<system-reminder>
## Recalled memory (auto, system-owned)
- [evt_004411] prefers tabs
</system-reminder>
Any closing tag inside a stored document is escaped before it goes in, so a note indexed months ago cannot end the envelope early and have the rest of itself obeyed. Prompt injection through your own memory is a real path; this closes it. See Skills for the envelope's other job.