# OpenCode Permissions and Hooks Reference

How opencode's permission block resolves allow/ask/deny, how the acp-opencode plugin's two hooks map onto it, and what changes in local on-device mode.

<div style="margin:8px 0 28px;padding:20px 22px;border:1px solid var(--line-2);border-radius:12px;background:var(--color-accent-light,#f0effe);">
  <p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:var(--acp-text);"><strong>Just want the answer?</strong> Add the plugin and a <code>permission</code> block to <code>opencode.json</code> — the block is what routes tool calls into the plugin's primary hook:</p>
  <pre data-track="OpenCodeHooks: Hero Config Copy" style="margin:0 0 12px;background:var(--color-surface,#faf9ff);border:1px solid var(--line-2);border-radius:8px;padding:12px 14px;overflow-x:auto;"><code>{
  "$schema": "https://opencode.ai/config.json",
  "plugin": ["acp-opencode"],
  "permission": {
    "bash": "ask",
    "edit": "ask",
    "webfetch": "ask"
  }
}</code></pre>
  <p style="margin:0;font-size:13px;color:var(--acp-text-dim);"><a href="/integrations/opencode" data-track="OpenCodeHooks: Install Guide" style="font-weight:600;">Full opencode install guide →</a> &nbsp;·&nbsp; <a href="/controls/opencode" data-track="OpenCodeHooks: Controls Page">the controls reference →</a> &nbsp;·&nbsp; free up to 5 agents</p>
</div>

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](/integrations/opencode) 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](/controls/opencode) — the full permission-system reference: the rule language, the native gate, the plugin surface
- [/integrations/opencode](/integrations/opencode) — the install guide, local metering, and troubleshooting
- [Claude Code hooks reference](/blog/claude-code-hooks-reference) — the shell-hook shape, for comparison
- [Codex hooks reference](/blog/codex-cli-hooks-reference) — a narrower, evolving hook surface
- [DeepSeek Harness (dsh) hooks reference](/blog/deepseek-harness-hooks-reference) — the other in-process typed-plugin model
- [OpenCode cost tracking](/blog/opencode-cost-tracking) — the local SQLite path and the proxy path for priced spend

## Frequently asked questions



<!-- Sources: https://opencode.ai/docs/plugins/ (opencode plugin system docs); https://opencode.ai (opencode project); agenticcontrolplane.com/controls/opencode and agenticcontrolplane.com/integrations/opencode (this site's own reference and install pages); https://github.com/agentic-control-plane/opencode-acp-plugin (plugin source and README, cross-checked against the above) -->
