# opencode Permissions Reference: allow, ask, deny, and auto-approve

Every key in opencode's permission block, the pattern rules, last-match-wins, per-agent overrides, auto mode, managed config, and what a plugin can intercept. Verified against opencode 1.18.4 and opencode's own docs.

<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> A complete, valid <code>permission</code> block for <code>~/.config/opencode/opencode.json</code> — reads and safe git are quiet, edits and the rest of bash ask, the destructive shapes are denied and stay denied under <code>--auto</code>:</p>
  <pre data-track="OpenCodePerms: 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",
  "permission": {
    "read": "allow",
    "edit": "ask",
    "webfetch": "ask",
    "bash": {
      "*": "ask",
      "git status *": "allow",
      "git diff *": "allow",
      "git log *": "allow",
      "npm test *": "allow",
      "rm -rf *": "deny",
      "git push --force *": "deny"
    }
  }
}</code></pre>
  <p style="margin:0;font-size:13px;color:var(--acp-text-dim);">Auto-approve: <a href="#auto-approve" style="font-weight:600;">the four ways, ranked</a> &nbsp;·&nbsp; <a href="/controls/opencode" data-track="OpenCodePerms: Controls Page">the control model, explained →</a> &nbsp;·&nbsp; <a href="/integrations/opencode" data-track="OpenCodePerms: Install Guide">wire in workspace rules →</a></p>
</div>

opencode's permission system is a real rule language: three verdicts, per-tool keys, glob patterns over the command or path, a defined precedence, and a config layer an admin can lock. This is the reference for it — every key, every value, what each pattern matches on, what wins when rules conflict, every way to auto-approve and what each one leaves gated, and exactly what an in-process plugin can and cannot intercept. Everything here is checked against opencode 1.18.4's own `--help` and [opencode's permissions docs](https://opencode.ai/docs/permissions/); where the docs and the binary are the only sources, that is said.

## Where the block lives

The `permission` key belongs in `opencode.json` (JSON or JSONC — comments are fine). opencode reads it from several places and **merges** them; a later layer overrides an earlier one only for the keys it sets. The load order from [opencode's config docs](https://opencode.ai/docs/config/), lowest to highest:

| Layer | Path | Meant for |
|---|---|---|
| Remote | `.well-known/opencode` | organizational defaults |
| Global | `~/.config/opencode/opencode.json` | your preferences |
| Custom path | `OPENCODE_CONFIG=<file>` | one-off overrides |
| Project | `opencode.json` at the repo root | project settings |
| Directories | `.opencode/` (agents, commands, plugins) | project extensions |
| Inline | `OPENCODE_CONFIG_CONTENT='{…}'` | runtime overrides |
| Managed | `/Library/Application Support/opencode/` · `/etc/opencode/` · `%ProgramData%\opencode` | admin-controlled |
| macOS MDM | `/Library/Managed Preferences/ai.opencode.managed.plist` | highest, not user-overridable |

Two things follow. A per-project `opencode.json` can loosen a rule you set globally, because it loads later. And a `deny` in the managed layer cannot be loosened by anything below it — opencode's docs say managed settings "override everything." That is the layer for a rule the whole team must keep; see [locking a rule](#locking-a-rule) below.

There is also `OPENCODE_PERMISSION`, listed in [opencode's CLI docs](https://opencode.ai/docs/cli/) as "Inlined json permissions config" — a whole permission block as an environment variable, useful for a CI job that must not depend on the checkout's config file.

## Three verdicts, three shapes

Every rule resolves to one of three strings:

- **`allow`** — run without approval.
- **`ask`** — prompt for approval.
- **`deny`** — block the action.

The block itself takes three shapes, from coarsest to finest:

```json
{ "permission": "allow" }
```

One verdict for everything. Then the per-tool object, with `"*"` as the catch-all:

```json
{
  "permission": {
    "*": "ask",
    "bash": "allow",
    "edit": "deny"
  }
}
```

And the granular form, where a tool's value is itself an object of pattern → verdict:

```json
{
  "permission": {
    "bash": {
      "*": "ask",
      "git *": "allow",
      "npm *": "allow",
      "rm *": "deny"
    },
    "edit": {
      "*": "deny",
      "packages/web/src/content/docs/*.mdx": "allow"
    }
  }
}
```

The shapes mix freely: `"read": "allow"` next to a full `"bash": { … }` object is normal. opencode's agent docs list the keys that accept the object form as `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `external_directory`, `lsp`, and `skill`; the rest take the string.

One deprecation to know if you inherit an older config: the boolean `tools` block (`"tools": { "bash": false }`) is deprecated as of v1.1.1 and folded into `permission` — `true` maps to `{"*": "allow"}`, `false` to `{"*": "deny"}`. It still works; write new rules in `permission`.

## Every key

Keys are tool names plus two safety guards. What each one's pattern matches against, per opencode's permissions docs:

| Key | The pattern matches on |
|---|---|
| `read` | the file path |
| `edit` | the file path — one key covers `edit`, `write`, and `patch` |
| `glob` | the glob pattern the agent asked for |
| `grep` | the regex the agent asked for |
| `bash` | the parsed command, e.g. `git status --porcelain` |
| `task` | the subagent type |
| `skill` | the skill name |
| `lsp` | LSP queries — "currently non-granular" |
| `question` | the agent asking you a question mid-run |
| `webfetch` | the URL |
| `websearch` | the query |
| `external_directory` | a path outside the directory opencode started in |
| `doom_loop` | the same tool call repeated 3 times with identical input |

`external_directory` is a guard, not a tool: it fires for any path-taking tool — `read`, `edit`, `glob`, `grep`, many `bash` commands — when the path is outside the project. An allowed external directory inherits the workspace defaults, so `read` works there because `read` defaults to allow; to permit reads but block edits in it, pair an `external_directory` allow with an `edit` deny on the same path (the [worked example](#external-directories-reads-yes-edits-no) below).

`doom_loop` is the native tripwire for a stuck agent: three identical calls in a row and opencode asks you before the fourth.

## The defaults

Before writing anything, know what you're starting from. Most permissions default to `allow`. The exceptions, as opencode documents them:

- `doom_loop` → `ask`
- `external_directory` → `ask`
- `read` → allow, except `.env` files:

```json
{
  "permission": {
    "read": {
      "*": "allow",
      "*.env": "deny",
      "*.env.*": "deny",
      "*.env.example": "allow"
    }
  }
}
```

That is the whole restrictive surface out of the box. `bash`, `edit`, and `webfetch` are `allow` until you say otherwise — which is why [the ACP installer](/integrations/opencode) sets those three to `ask` (filling gaps only; a value you already wrote is left alone).

## Patterns and precedence

**Wildcards.** `*` matches zero or more of any character. `?` matches exactly one character. Everything else matches literally. A leading `~` or `$HOME` expands to your home directory, so `~/projects/*` and `$HOME/projects/*` are the same rule.

**Arguments need the star.** `"grep"` matches the bare command and nothing else; `"grep *"` matches `grep pattern file.txt`. Same for `"git status"` versus `"git status *"`. If a rule you wrote seems ignored, this is the first thing to check.

**Last match wins.** Within one tool's object, rules are evaluated in order and the last matching rule takes precedence. That is why the examples put `"*"` first: the catch-all sets the floor, the specific rules after it carve out exceptions. Reverse the order and the catch-all silently overrides everything you wrote above it.

**Agent rules beat global rules.** A `permission` block under `agent.<name>` merges with the top-level block and wins on conflict. The built-in `plan` agent uses this — it asks on all file edits and all bash by default — and you can do the same for your own:

```json
{
  "permission": {
    "bash": { "*": "ask", "git *": "allow", "git push *": "deny" }
  },
  "agent": {
    "build": {
      "permission": {
        "bash": { "*": "ask", "git *": "allow", "git commit *": "ask", "git push *": "deny" }
      }
    }
  }
}
```

Agents defined as Markdown take the same block as YAML frontmatter:

```markdown
---
description: Code review without edits
mode: subagent
permission:
  edit: deny
  bash: ask
  webfetch: deny
---
Only analyze code and suggest changes.
```

**Config layers beat each other in load order** (the table above): project over global, inline over project, managed over all.

**What a pattern is not.** A `bash` pattern is a glob over the command text opencode parsed. A different spelling of the same action is a different string: `rm -rf *` catches that spelling, not a deletion routed through another tool or a script. Write the destructive shapes as `deny` to capture intent and catch the common spellings; treat the list as a tripwire, not a boundary. That is the same lesson [every harness's denylist teaches](/blog/what-survives-yolo-mode).

## The gate: once, always, reject

A call resolving to `ask` stops and offers three answers:

- **once** — approve this request only.
- **always** — approve matching requests for the rest of the current session.
- **reject** — deny it.

The pattern that *always* covers is supplied by the tool, not typed by you; for bash it is typically a safe prefix such as `git status*`. Two properties of *always* matter operationally. It is session-scoped — restart opencode and it is gone. And it is recorded nowhere you can review: for the rest of that session an `ask` has become an `allow`, and the only trace is in your memory. A long session can accumulate a lot of these. If you want the promotion to outlive the session, write it as an `allow` pattern in the file instead, where it can be diffed.

<a id="auto-approve"></a>

## Auto-approve: four ways, and what each leaves gated

"Make opencode stop asking me" has four answers with different scope. Narrowest first.

**1. `allow` patterns in the block.** Per command shape, permanent, in a file you can diff and review. The right default: approve the shapes you would approve every time, leave the rest at `ask`. The hero block at the top of this page is this.

**2. `always` at the prompt.** Per shape, for this session, unrecorded. Good for the third identical `npm test` in a long session.

**3. `--auto`.** Everything that is not an explicit `deny` is approved, for the run. opencode's own help text for it, verbatim from 1.18.4:

```
--auto  auto-approve permissions that are not explicitly denied (dangerous!)
```

It is available on the TUI (`opencode --auto`) and on headless runs (`opencode run --auto "Refactor this module"`), and it can be toggled mid-session from the TUI command palette — *Enable auto-approve permissions* / *Disable auto-approve permissions* — with a muted `auto` indicator beside the agent name while it is on. What makes it better-behaved than most yolo flags: **deny rules remain enforced.** What it removes is the human answer on the ask tier, so everything you were relying on the prompt for now runs — `edit`, `external_directory`, `doom_loop` included — unless you wrote a `deny`. A rule that has to hold unattended has to be a `deny`.

**4. Workspace policy, with the ACP plugin.** [The plugin](/integrations/opencode) hooks `permission.ask`, which opencode fires for a tool whose permission resolved to `ask`, *before* the prompt renders. A workspace rule that says allow sets the permission to `allow` — no prompt, the call runs — and the decision, the tool, the arguments, and the session land in the activity log. A rule that says ask leaves the native once/always/reject gate to fire exactly as it would without the plugin. So "git, npm test, and reads are fine; edits ask; anything touching production asks" is one rule that behaves the same on every machine with the plugin, instead of one `opencode.json` per laptop. Your `permission` block still matters: a tool sitting at `allow` in your config never reaches `permission.ask` at all.

Whichever you pick: an explicit `deny` in your config holds under *always* and under `--auto`, and with the plugin a workspace deny holds as well — it fires from `tool.execute.before`, which runs on every call regardless of what the config says.

## Worked examples

### A read-only reviewer agent

An agent that can look but not touch. `edit: deny` covers `write` and `patch` too; `webfetch: deny` keeps it off the network; bash is allowed for read-only git and denied for everything else.

```json
{
  "agent": {
    "review": {
      "description": "Code review without edits",
      "mode": "subagent",
      "permission": {
        "edit": "deny",
        "webfetch": "deny",
        "bash": {
          "*": "deny",
          "git status *": "allow",
          "git diff *": "allow",
          "git log *": "allow",
          "grep *": "allow"
        }
      }
    }
  }
}
```

### Unattended: `opencode run --auto` with a floor

A CI or cron run has no one to answer a prompt, so `--auto` is the honest flag. Everything you need to hold has to be a `deny`, and the block can travel with the job as an environment variable so it does not depend on the checkout:

```bash
export OPENCODE_PERMISSION='{
  "edit": { "*": "allow", "*.env*": "deny", ".github/workflows/*": "deny" },
  "bash": {
    "*": "allow",
    "rm -rf *": "deny",
    "git push --force *": "deny",
    "git push * --force": "deny",
    "curl * | sh": "deny"
  }
}'
opencode run --auto "Fix the failing unit tests and commit"
```

With the ACP plugin in that run, add `export ACP_FAIL_MODE=closed` so an unreachable gateway blocks rather than lets the unattended job proceed unchecked — the [install guide](/integrations/opencode#fail-open-by-default--and-opting-in-to-fail-closed) has the contract.

### Edits scoped to one package

Deny edits everywhere, then allow them under one path. Last-match-wins does the work:

```json
{
  "permission": {
    "edit": {
      "*": "deny",
      "packages/web/src/**": "allow"
    }
  }
}
```

### External directories: reads yes, edits no

`external_directory` allows the path; `edit` denies changes to it. Both rules are needed because an allowed external directory inherits the workspace defaults, and `read` defaults to allow:

```json
{
  "permission": {
    "external_directory": { "~/projects/personal/**": "allow" },
    "edit": { "~/projects/personal/**": "deny" }
  }
}
```

<a id="locking-a-rule"></a>

### Locking a rule for a team

A `deny` that must survive whatever a developer puts in their own config goes in the managed layer, which opencode loads last and documents as not user-overridable. On macOS:

```
/Library/Application Support/opencode/opencode.json
```

with the same `permission` block inside it (Linux: `/etc/opencode/`; Windows: `%ProgramData%\opencode`). opencode's config docs also show an MDM plist form for macOS fleets, where `permission` is a dict with wildcard and nested `bash` rules such as `rm -rf *: deny`. That is the one place in opencode's native model where a rule can be made to hold against the user, and it is per-machine: what it does not give you is a record of what the rule did, or the same rule on the developer's other harnesses.

## What a plugin sees — and does not

opencode runs plugins in-process, and two of its hooks stand on the permission system itself. From the plugin package's own types (`@opencode-ai/plugin` 1.18.4):

| Hook | Fires | Can do |
|---|---|---|
| `permission.ask(input: Permission, output: { status })` | when a tool's permission resolved to `ask` | set `output.status` to `allow` (skip the prompt), `deny`, or leave `ask` (native gate fires) |
| `tool.execute.before(input: { tool, sessionID, callID }, output: { args })` | before every tool call | mutate `args`; `throw` to block |
| `tool.execute.after(input, output: { title, output, metadata })` | after the result exists | observe |

Three consequences for anyone wiring policy through this surface, all verified against the [ACP plugin's source](https://github.com/agentic-control-plane/opencode-acp-plugin):

- **`permission.ask` only sees the ask tier.** A tool at `allow` never reaches it. That is why a policy layer needs `tool.execute.before` as the backstop — and that hook has no ask primitive, so an ask verdict arriving there can only deny.
- **The `Permission` object carries no arguments.** On 1.18.4 it has `id`, `type` (the tool name), an optional `pattern`, `sessionID`, `messageID`, an optional `callID`, `title`, `metadata`, and `time`. The command text is not on it. The plugin caches `output.args` from `tool.execute.before` by `callID` and attaches it when `permission.ask` fires for the same call, and it fetches one decision per `callID` and reuses it across both hooks.
- **The only block primitive in `tool.execute.before` is a throw.** The message is what the model sees, so it carries the reason. The plugin's exact string is `[ACP] Denied by policy: <reason>`, and a hit on the hardline floor — the short list no workspace setting, approval, *always*, or `--auto` can allow — reads:

```
[ACP] Denied by policy: hardline floor: recursive delete of the root filesystem — blocked unconditionally; this pattern cannot be allowed by policy or approval
```

The floor's other labels, from the classifier: recursive delete of a system directory or the home directory, `mkfs`, `dd` or a redirect onto a raw block device, a fork bomb, `kill -1`, shutdown/reboot/halt/poweroff, `init 0`/`6`, `systemctl poweroff`. Commands wrapped in `sh -c '…'` or `eval` are scanned as commands, not prose.

**Policy keys.** opencode spells its tools in lowercase, and workspace rules are written against canonical names. The gateway maps the native spelling before classification, so a rule on `Bash.rm` applies to opencode's `bash` calls without a separate opencode rule:

| opencode tool | Policy key |
|---|---|
| `bash` | `Bash` (sub-classified: `Bash.git`, `Bash.rm`, …) |
| `edit`, `patch` | `Edit` |
| `write` | `Write` |
| `read` | `Read` |
| `grep` | `Grep` |
| `webfetch` | `WebFetch` |
| `websearch` | `WebSearch` |
| `task` | `Agent` |

A rule written against the native spelling keeps working; the original name is always tried last.

**What it does not see.** Three honest limits. `opencode --pure` — the binary's help: "run without external plugins" — starts a process with no config-registered plugin in it, so nothing from ACP is in that run; it is a flag on the command line, visible in any process list, not a config change. `ACP_OPENCODE=off` disables the plugin without touching your config. And a machine with no workspace credential is a no-op: opencode behaves exactly as if the plugin were not installed. There is no on-device mode for opencode today — the installer's `--local` flag skips it and says so.

## Troubleshooting

**A rule I wrote is ignored.** Check the order. The last matching rule wins, so a `"*"` placed after your specific rules overrides them. Then check for the missing star: `"git status"` does not match `git status --short`.

**opencode still asks for something I allowed.** The prompt may be coming from a different key. A `bash` allow does not cover `external_directory`, which fires separately when a command touches a path outside the project; `doom_loop` fires on repetition regardless of what the tool is allowed to do.

**A project loosened my global rule.** Expected: project config loads after global and wins for the keys it sets. Put the rule in the managed layer if it must hold.

**`--auto` ran something I meant to block.** It was an `ask`, not a `deny`. Auto mode approves everything that is not explicitly denied.

**The ACP plugin never pre-approves anything.** The tool is at `allow` in your config, so `permission.ask` never fires for it. Set it to `ask` (the installer does this for `bash`, `edit`, `webfetch`) and the plugin can resolve the prompt before it renders.

**Every call is being blocked.** That is a `deny` rule, not an outage. The plugin fails open by default when the gateway is unreachable; if you set `ACP_FAIL_MODE=closed` and the gateway is down, the block message says so: `[ACP] gateway unreachable — blocked (fail-closed)`.

## Frequently asked questions



## Where to read more

- [The opencode control model, explained](/controls/opencode) — the gate, the escape hatch, the plugin surface, and where the native model ends
- [Install guide](/integrations/opencode) — the plugin, the permission block the installer writes, fail mode, troubleshooting
- [opencode hooks reference](/blog/opencode-hooks-reference) — the four hooks the plugin registers and what each one covers
- [opencode cost tracking](/blog/opencode-cost-tracking) — local metering with no account, and priced spend through the proxy
- [Native controls, compared](/controls) — opencode's permission model beside Claude Code, Codex CLI, Cursor, and the rest
- [opencode permissions docs](https://opencode.ai/docs/permissions/) · [config docs](https://opencode.ai/docs/config/) · [agents docs](https://opencode.ai/docs/agents/) · [CLI docs](https://opencode.ai/docs/cli/) — the upstream sources this page is checked against

<!-- Sources: https://opencode.ai/docs/permissions/ · https://opencode.ai/docs/config/ · https://opencode.ai/docs/agents/ · https://opencode.ai/docs/cli/ · opencode 1.18.4 `opencode --help` and `opencode run --help` · @opencode-ai/plugin 1.18.4 index.d.ts and @opencode-ai/sdk types.gen.d.ts (Permission type) · github.com/agentic-control-plane/opencode-acp-plugin src/index.ts (deny strings, callID cache, fail mode) · gatewaystack-connect apps/tenant-gateway/src/govern/riskClassifier.ts (hardline labels) and hookGovernance.ts (deny reason string, tool-name aliases) -->
