OpenCode Permissions and Hooks Reference
Just want the answer? Add the plugin and a permission block to opencode.json — the block is what routes tool calls into the plugin's primary hook:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["acp-opencode"],
"permission": {
"bash": "ask",
"edit": "ask",
"webfetch": "ask"
}
}
Full opencode install guide → · the controls reference → · free up to 5 agents
opencode ships a real permission system — a rule language with allow/ask/deny verdicts and a genuinely well-designed native gate — and, unlike most CLI coding agents, a first-class in-process plugin system that can stand on that permission system directly. That combination is worth a reference of its own: how the permission block resolves a verdict, what the ACP plugin’s two hooks each see, what degrades if you skip the config, and what changes in a local, credential-free mode.
The permission block resolves first
Every tool call in opencode is checked against the permission block in opencode.json before anything else runs. Verdicts are allow, deny, or ask, resolved last-match-wins — a broad "bash": "ask" followed by a more specific "git status*": "allow" quiets the safe case while keeping the prompt for everything riskier. Most tools default to allow out of the box; the two things that default to ask are external_directory (touching paths outside the project) and doom_loop (the same call repeated three times with identical input).
This matters for wiring a plugin in, because opencode only exposes one native way for a plugin to intercept the approval decision itself: the permission.ask hook, and it only fires for a tool call whose permission already resolved to "ask". A tool sitting at allow in your config never reaches that hook at all.
The two hooks, and what each one covers
acp-opencode registers on four of opencode’s hooks; two of them do the interception work:
| Hook | Behavior |
|---|---|
permission.ask |
POSTs to /govern/tool-use. An ACP allow sets the permission status to allow — the native prompt is skipped, pre-approved. A deny blocks the call outright. An ask leaves the status untouched, so opencode’s own once / always / reject gate fires exactly as it would with no plugin installed. |
tool.execute.before |
The deny-only backstop for tool calls that never reach permission.ask — anything sitting at allow in your permission config. An ACP deny here throws [ACP] Denied by policy: <reason>, which is how opencode blocks a call from this hook. It also caches the call’s arguments and start time by callID for the audit row. |
The other two — tool.execute.after (a fire-and-forget POST to /govern/tool-output for server-side output audit, capped at 200 KB) and chat.headers (stamps X-GS-Session / X-GS-Client on every model request) — don’t make allow/deny decisions; they extend the audit trail and tie a session’s tool calls to its model spend for the cost view.
One deduplication detail worth knowing: when both tool.execute.before and permission.ask fire for the same callID — which happens whenever a tool sits at ask — the plugin fetches the /govern/tool-use decision once and reuses it for both hooks, rather than double-checking the same call.
Why the permission block isn’t optional
Skip the permission block, or leave a tool at its default allow, and permission.ask simply never fires for that tool — there’s no native prompt for the plugin to intercept in the first place. What’s left is the tool.execute.before backstop: still a real check, but deny-only. An ACP allow decision has nothing to skip (the call was already going to run unprompted), and an ACP ask decision has nowhere to go, because tool.execute.before can’t summon opencode’s native gate — only permission.ask can. Practically: without the block, you get full audit coverage and policy denies, but not pre-approval and not the interactive-ask experience. The installer fills gaps in the permission block for bash / edit / webfetch on install — it never overrides a choice you made deliberately — precisely so this degradation doesn’t happen by default.
The native gate, and what --auto does to it
A tool resolving to ask — whether from your config or because the plugin left it untouched — prompts the human with three answers, and the middle one is opencode’s own design: always approves that call shape for the rest of the session, so it stops asking. It’s a real usability feature, but it’s also a silent promotion from ask to allow for the session that isn’t recorded anywhere reviewable outside opencode itself; the ACP audit row for the call that triggered it is the more durable record.
opencode’s full-auto flag, --auto, is better-behaved than most CLI harnesses’ yolo equivalents: it skips the ask prompts, but explicit deny rules — from your permission block or from the plugin — are still enforced. The thing to plan for: anything gated only with ask becomes an effective allow under --auto, because the human who’d have answered the prompt isn’t there. If a rule needs to hold unattended, it has to resolve to deny, not ask — the same logic applies to policy rules routed through the plugin as to opencode’s own config.
Local mode: decisions with no workspace at all
The plugin can run with zero credentials. When no ACP_BEARER_TOKEN and no ~/.acp/credentials are configured, and ~/.acp/decide.mjs is present on the machine — written there by installing another harness with install.sh --local — the plugin automatically switches to evaluating permission.ask and tool.execute.before against that on-device engine instead of calling the gateway. Setting export ACP_LOCAL=1 (or the plugin option { "local": true }) forces local mode on regardless of what’s configured. A workspace credential always wins toward cloud mode if one is present.
The two hooks behave asymmetrically in local mode, and the asymmetry is deliberate: permission.ask is attended — a human can see the native prompt — so an unavailable or broken local engine fails open, with one loud [ACP·local] warning, rather than hanging the session. tool.execute.before is effectively unattended — it has no ask primitive to fall back to — so a local ask verdict, and an engine failure at that hook, both fail closed and throw. That fail-open/fail-closed split by hook mirrors the same attended/unattended posture the Claude Code, Codex, and dsh integrations use. What local mode doesn’t do: there’s no server-side output scanning or redaction (decide.mjs only makes pre-call decisions), no dashboard — ~/.acp/audit.jsonl is the only record — and no tuned risk classifier, only the deliberately simple, reviewable one shipped in decide.mjs.
Separately, the one-command installer’s default path treats opencode as needing a workspace: run curl -sf https://agenticcontrolplane.com/install.sh | bash --local and opencode is detected and skipped, with the installer saying so, because its automatic setup provisions a workspace credential rather than the local-only condition above. Local mode is real and shipped in the plugin — it’s just not what the installer’s --local flag reaches for opencode specifically; ACP_LOCAL=1 or the plugin option is how you reach it deliberately.
Fail-open, and opting into fail-closed
For the cloud path, network errors, timeouts over 2 seconds, non-2xx responses, and malformed bodies all fail open by default: the tool call proceeds, with one loud warning per session. Security-sensitive setups can flip that with export ACP_FAIL_MODE=closed — the same environment variable every ACP harness integration reads, so one setting covers Claude Code, Cursor, Codex, and opencode together. A policy deny always blocks regardless of fail mode, and an unprovisioned machine with no credentials is always a no-op either way.
Related links
- /controls/opencode — the full permission-system reference: the rule language, the native gate, the plugin surface
- /integrations/opencode — the install guide, local metering, and troubleshooting
- Claude Code hooks reference — the shell-hook shape, for comparison
- Codex hooks reference — a narrower, evolving hook surface
- DeepSeek Harness (dsh) hooks reference — the other in-process typed-plugin model
- OpenCode cost tracking — the local SQLite path and the proxy path for priced spend
Frequently asked questions
What's the primary hook the ACP plugin uses in opencode?
permission.ask. It fires whenever a tool call’s permission resolves to “ask” in opencode.json, and the plugin’s response can set the status to allow (skips the native prompt), deny (blocks the call), or leave it untouched so opencode’s own once/always/reject gate fires.
What happens if I don't add the permission block to opencode.json?
Coverage degrades to a deny-only backstop. permission.ask only fires for tools resolving to “ask”, so without the block ACP allow can’t pre-approve a prompt away and ACP ask can’t reach the native gate — you still get tool.execute.before’s deny-only check and the output audit, but nothing else.
Does the plugin work without a workspace credential?
It can, in a local mode: with no ACP_BEARER_TOKEN or ~/.acp/credentials set, and ~/.acp/decide.mjs present on the machine (written by installing another harness with –local), the plugin automatically evaluates decisions on-device instead of calling the gateway. Setting ACP_LOCAL=1 forces it on regardless. A workspace credential always wins toward cloud mode.
What happens to an ask verdict at tool.execute.before?
It denies. That hook has no native way to summon an interactive prompt — opencode’s only approval surface is permission.ask, and only for tools whose permission resolves to ask. So a deny-only backstop turns any ask it receives into a deny rather than pausing for a human.