The opencode control model, explained
opencode is among the most-starred open-source coding agents (the repo now lives at anomalyco/opencode; the old sst path redirects), and its control model is one of the more thoughtfully shaped: a real rule language, a three-way native gate, an escape hatch that doesn’t waive your denies, and an in-process plugin system that can stand on the permission system itself. This page is the reference: how each mechanism behaves, the interactions that aren’t obvious from the docs, and where the model ends.
This page covers opencode’s own controls. For wiring ACP into opencode, see the install guide.
The permission system
Rules live in the permission block of opencode.json (~/.config/opencode/ globally, or per-project):
{
"permission": {
"bash": "ask",
"edit": "ask",
"webfetch": "allow",
"bash": { "git status*": "allow", "rm *": "deny" }
}
}
Three verdicts — allow runs without a prompt, deny blocks, ask fires the native gate — with two granularities: per-tool (read, edit, bash, webfetch, task, skill, and friends) and command patterns for bash. Resolution is last-match-wins, so ordering is your specificity mechanism: a broad "bash": "ask" followed by "git status*": "allow" quiets the safe case while keeping the prompt for everything else. Per-agent overrides exist too (agent.<name>.permission.*, or frontmatter in agent Markdown), and agent rules take precedence — the built-in plan agent denies edits and asks on bash.
Know the defaults before trusting them: most tools default to allow. What ships restrictive is narrow and well-chosen — read denies *.env files by default (allowing *.env.example), and two guard pseudo-tools default to ask: external_directory (touching paths outside the project) and doom_loop (the same call repeated three times with identical input — a genuinely clever native tripwire for stuck agents). Everything else is quiet until you write rules.
The pattern language is glob-over-command-string. That’s expressive enough for real policy and carries the standard caveat of every string-match permission system: command strings have many spellings. rm * doesn’t match command rm, an interpreter wrapper, or a deletion spelled through find -delete. Treat patterns as intent-capture for the common spellings, not as a boundary — the same lesson every harness’s denylist has taught.
The native gate: once / always / reject
A tool resolving to ask prompts the human with three answers, and the middle one is the interesting design choice: always approves the pattern for the rest of the session, so the same call shape stops asking. This is approval-fatigue management built into the gate — the third identical yes becomes a session rule instead of a fourth prompt — and scoping it to the session (rather than persisting forever) is the right default.
Worth knowing about always: within its session it’s a silent promotion of an ask into an allow, recorded nowhere reviewable. The prompt UX is genuinely good; what it quietly accumulated over a long session is the part you’ll want an independent record of.
The --auto flag
opencode’s full-auto flag is better-behaved than most of its peers’: --auto skips the ask prompts, but explicit deny rules remain enforced. Compare Cline’s CLI (auto-approve everything, on by default) or the yolo modes that waive whole rule systems: opencode’s escape hatch removes the human, not the policy.
The subtlety: anything you gated only with ask — because the native prompt was your control — silently becomes an allow under --auto. If a rule matters unattended, it has to be a deny (or resolved by a policy layer), because the ask tier evaporates exactly when nobody’s watching. This is the empty-chair test applied to one flag.
The plugin surface
opencode ships a first-class JS/TS plugin system — modules whose exported hooks run in-process. The control-relevant points:
permission.ask— fires when a tool’s permission resolves toask; a plugin can resolve the prompt programmatically (pre-approve, deny, or leave it to the human). This is the rare case of a harness exposing its permission system itself as an extension point, not just the tool calls.tool.execute.before— fires before execution; a throw blocks the call. Deny-only.tool.execute.after— observational; fires after the result exists.- Plus a general event bus (
permission.asked/permission.replied,command.executed,file.edited, session events) rich enough to observe token usage, costs, and tool results in-process.
Three field-tested caveats. First, the permission hook only fires for tools that resolve to ask — a tool at allow never touches it. A plugin that needs to see every call must pair it with tool.execute.before as a backstop, accepting that the backstop can only deny, not ask. Second: plugin coverage is only as complete as the permission block routing tools into it — config and plugin have to be set up together, which is why our installer writes both. Third: plugins auto-load from the project’s .opencode/plugins/ directory with no registration step — convenient, and it means a cloned repository can ship code that runs inside your agent with your permissions. Treat project plugin directories with the same suspicion as a repo’s install scripts.
What’s not there
- No sandbox. No OS-level isolation ships with opencode; the boundary is the permission system.
- No native audit beyond the session log. The run’s own record, on the run’s own machine. The always promotions, in particular, accumulate invisibly.
- Per-machine config.
opencode.jsonis wherever each install put it; two machines’ effective policies drift silently.
Where the native model ends
opencode’s native model is genuinely strong on the attended side: a real rule language, the best-designed escape hatch of the major harnesses, and a permission system a plugin can stand on. The structural limits are the ecosystem’s usual four: the record is self-authored, the policy is per-machine, the ask tier evaporates unattended, and the rules don’t travel to your other harnesses.
That seam is what our plugin fills, using opencode’s own extension points: workspace policy resolves permission.ask (allow pre-approves the prompt away, deny blocks, ask falls through to the native once/always/reject gate — the good UX stays), tool.execute.before backstops the rest, and every decision lands in a ledger off the machine. Same policy on opencode, Claude Code, dsh, and Hermes; one place to answer what ran, everywhere, and why.