A command classifier should only run when nobody can be asked
opencode-guard adds permission modes, a static shell safety floor, credential guards and redaction to opencode, and asks a model only when no person can be.
A coding agent with a shell runs whatever it decides to run. opencode controls that with permission globs, and they have a hole. Rules resolve last match wins, and an agent’s own allow block is appended after the global rules, so one cat * grant in any agent definition overrides a global deny on ~/.ssh. opencode-guard (previously opencode-guardrails) is a plugin that adds Claude-Code-style permission modes and a safety floor that no permission rule can be appended past. Its central rule is that the classifier model answers exactly one question: may this run with nobody to ask? Where a person will be asked, the model decides nothing. Before any model is involved, a static table of more than 120 read-only commands settles ordinary reads for free.
What it does #
Each session has a mode, flipped with F9 in the opencode-broker HUD or oc-mode from a shell:
manualasks about everything opencode would ask about.editslets file edits, reads and searches through, and asks about everything else.autolets everything through except a shell command the classifier won’t call SAFE. That command is refused, with a reason the agent can act on.godruns everything. It’s per session and only honoured when a person is present, so an unattended run keeps the floor whatever its mode file says.
A second knob, the level, is the escape hatch: on, static (never call a model) or off.
The floor applies to every tool that starts a process, including a background shell that nobody waits on. It is cc-safety-net’s static analysis of destructive commands plus a credential guard, and it throws in every mode but god. The credential guard also covers opencode’s native read, glob and grep, which never pass through a shell.
Tool output is redacted in every mode, god included, because a secret in a transcript was never a permission question. The exceptions are the tools that feed an edit, which would write the marker back to disk. Redaction is driven by key names like password, token and a trailing _key, plus PEM blocks, not by guessing what looks random, which would mangle the hashes and IDs an agent needs. It appends a count so the agent doesn’t mistake a marker for a failure. oc-reveal 60 opens a one-minute unredacted window from a real terminal, and the plugin refuses any shell command that names it.
The prior art is plain: cc-safety-net is a dependency doing the destructive-command analysis, and the four modes follow the shape of Claude Code’s permission modes.
How it works #
On opencode 1.18.22 the permission.ask hook never fires, so a plugin can’t turn a verdict into a prompt. It can throw, stand aside, or answer a permission that has already been raised. The design follows from that. Hard checks throw in tool.execute.before. In manual and edits, the plugin stands aside and opencode’s own prompt reaches the person. In auto, it answers the raised permission when the verdict is SAFE.
“Unattended” means a headless command such as opencode run or serve, or a session started with --auto. The plugin reads it from /proc/self/cmdline, because the terminal UI’s worker strips the flags from process.argv, and any doubt counts as unattended.
Hard problems #
Settling reads without a model #
The first version split command lines on &&, | and ; without regard to quotes. ssh nas "test -w X && echo yes || echo no" became four fragments, one with an unbalanced quote, so nothing matched and every such command paid for a model call. The classifier shares a GPU slot with the session’s own model, so every avoidable call is latency now and an HTTP 500 under contention later.
The lexer now honours quotes, and a segment with command substitution, or a redirect anywhere but /dev/null, is never a read. A line is a read only if every segment is: cd repo && rg foo passes, while rg -l x | xargs rm -f doesn’t. 124 verbs read by default, 17 more such as git, docker and kubectl read only for listed subcommands, and 15 carry per-flag guards:
find: (t) => !t.some((x) => /^-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)$/.test(x)),
sed: (t) => !t.some((x) => x === "--in-place" || /^-[a-zA-Z]*i/.test(x)),
awk: (t) => !t.some((x) => x.includes(">") || x.includes("system(") || x.includes("|")),
curl: (t) => !t.some((x) => /^-[A-Za-z]*[cdDFTOoXKJ]/.test(x) ||
/^--(data|form|upload-file|output|remote-name|remote-header-name|request|create-dirs|config|json|dump-header|cookie-jar)/.test(x)),
The curl guard matches prefixes because an earlier exact-match version let curl -XPOST through as a read. ssh host "<command>" is judged by pulling out the remote command and running it through the same rules, two hops deep at most, because being remote says nothing about what a command does.
A classifier you can fail closed on #
Failing closed is only safe if the classifier is available, fast and consistent, and each of those had to be measured.
The lane now runs a small local model first, with cloud subscriptions as the fallback. Over 14 labelled commands run three times, a 4B model with no reasoning mode got 13 right, gave identical answers on every run, and typically took about 200 ms. A 9B model that reasons first got 12 right in 10 to 54 seconds, against a 12-second budget. Direct requests use temperature 0 and a fixed seed, because the log once held the same docker ps --filter line judged SAFE once and RISKY once. Verdicts are cached by working directory and command, and the cache is shared across sessions.
The prompt had to define dangerous rather than unfamiliar. An early version counted “anything that reaches beyond the project” and “anything you are unsure about” as risky, and a small model denied 63 of 96 commands, including nearly every read-only ssh to the NAS. RISKY is now a closed list, and a command that only reads is SAFE wherever it runs.
The worst bug was in error reporting. Every transport, auth, provider and timeout failure surfaced as “classifier returned no text”. The broker deliberately doesn’t count that against a provider, so broken lanes were never benched. The number quoted onward was 155 empty answers in 161 leases for one cloud model. When I finally measured that model, it was 6 for 6 correct. Its real problem was latency: a median of 9.6 seconds and a worst case of 24, against a 12-second deadline. A failure that can’t name itself gets explained by whoever reads it next.
Privacy sits under all of this. A command line is where secrets travel inline, so under any routing profile other than auto or manual, a command the static table can’t settle is refused without asking any model. An unrecognized profile name counts as restrictive.
Subagents inherit, and a cancelled turn is over #
A delegated child session has no mode file of its own, so it fell through to the global default, which was manual. Every permission a subagent raised landed on me as a prompt, and its commands were never classified. The most restrictive default produced pure noise, and noise is how people end up clicking yes without reading. A child now inherits its parent’s mode, capped at auto: nobody pressed the god key on the child. A permission it’s refused is rejected outright and appended to the result its parent gets back from the task tool, so the parent decides.
Two gates sit above god mode, because they aren’t limits on what a session may run. The dispatch gate refuses a task sent to an agent that declares capability: read unless the brief opens with INTENT: read. It was prompted by four misdispatches in a single god-mode session. The stale-turn gate exists because opencode 1.18.22 checks the abort signal only after a tool has run. On 2026-09-21 a plugin’s startup sweep starved the event loop, Esc took a minute to land, and two subagents’ queued tool calls ran about 200 ms after the cancel. Now a tool call for a session that is no longer busy is refused, and a failed status read allows the call, so the gate can’t stall a live session.
Status #
opencode-guard is at v0.6.0: 28 commits between 31 August and 21 September 2026, about 2,400 lines of JavaScript and 770 lines of tests on Node’s built-in runner. It depends on cc-safety-net 2.1.1 and the opencode SDK, and it’s built against opencode 1.18.22. Limitations:
- It’s a speed bump for a helpful model, not a sandbox against a hostile one.
- A SAFE verdict runs the command, and the static floor in front of the classifier only catches the destructive cases it knows about.
- Commands your own opencode config already allows are treated as settled, so the guard inherits those rules.
- The routed classifier creates a child session per call, and cleanup has to wait on an idle barrier that opencode 1.18 only stubs. That leaked 210 sessions at about 52 an hour, so a direct endpoint is now primary and the routed path is a fallback for fast failures only.
- Mode inheritance is one level deep, so a grandchild falls back to the global default.
What’s next #
The loop guard from opencode-agent-workflows is being folded in. It watches for a read-only call repeated with the same input and output and nothing changed in between, warns at 12 in a row, and hands a subagent back to its parent at 25. The bigger lesson I’d apply from the start: make every failure name its cause at the point it happens. Several of the worst bugs here were a refusal carrying the wrong reason.
Links #
- Repository: github.com/sudolulo/opencode-guard
- Depends on: cc-safety-net
- Its routed classifier leases through: opencode-broker