opencode Permissions Reference: allow, ask, deny, and auto-approve
Running opencode? See, control, and meter every tool call it makes — a check ahead of every dispatch, local token/cost tracking, and a full audit log, in one command:
curl -sf https://agenticcontrolplane.com/install.sh | bash
irm https://agenticcontrolplane.com/install.ps1 | iex
Full opencode install guide → · see your first checked call → · free up to 5 agents
Just want the answer? A complete, valid permission block for ~/.config/opencode/opencode.json — reads and safe git are quiet, edits and the rest of bash ask, the destructive shapes are denied and stay denied under --auto:
{
"$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"
}
}
}
Auto-approve: the four ways, ranked · the control model, explained → · wire in workspace rules →
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; 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, 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 below.
There is also OPENCODE_PERMISSION, listed in opencode’s CLI docs 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:
{ "permission": "allow" }
One verdict for everything. Then the per-tool object, with "*" as the catch-all:
{
"permission": {
"*": "ask",
"bash": "allow",
"edit": "deny"
}
}
And the granular form, where a tool’s value is itself an object of pattern → verdict:
{
"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 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→askexternal_directory→askread→ allow, except.envfiles:
{
"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 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:
{
"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:
---
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.
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.
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 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.
{
"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:
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 has the contract.
Edits scoped to one package
Deny edits everywhere, then allow them under one path. Last-match-wins does the work:
{
"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:
{
"permission": {
"external_directory": { "~/projects/personal/**": "allow" },
"edit": { "~/projects/personal/**": "deny" }
}
}
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:
permission.askonly sees the ask tier. A tool atallownever reaches it. That is why a policy layer needstool.execute.beforeas the backstop — and that hook has no ask primitive, so an ask verdict arriving there can only deny.- The
Permissionobject carries no arguments. On 1.18.4 it hasid,type(the tool name), an optionalpattern,sessionID,messageID, an optionalcallID,title,metadata, andtime. The command text is not on it. The plugin cachesoutput.argsfromtool.execute.beforebycallIDand attaches it whenpermission.askfires for the same call, and it fetches one decision percallIDand reuses it across both hooks. - The only block primitive in
tool.execute.beforeis 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--autocan 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
How do opencode permissions work?
A permission block in opencode.json maps each tool to allow, ask, or deny. bash, edit, read, and a few other keys also take an object of pattern → verdict ("git status *": "allow", "rm *": "deny"), resolved last-match-wins, so the catch-all "*" goes first and the exceptions after it. Anything resolving to ask prompts you with once / always / reject. Most tools default to allow; doom_loop and external_directory default to ask; read denies .env files.
How do I auto-approve in opencode?
Narrowest to widest: write allow patterns for the command shapes you would approve every time; answer always at the prompt to approve that shape for the rest of the session; or start with --auto (also opencode run --auto, or the TUI command palette’s Enable auto-approve permissions) to approve everything that is not an explicit deny. With the ACP plugin, a workspace rule that says allow pre-approves the prompt before it renders and writes an audit row. Denies hold in all four.
Does opencode --auto ignore deny rules?
No. opencode’s docs state that deny rules remain enforced and auto mode only affects what would otherwise ask. What --auto removes is the human answer on the ask tier, so a tool you were only gating with ask runs. If a rule has to hold unattended, write it as deny.
Where does opencode read the permission block from?
~/.config/opencode/opencode.json globally, opencode.json at the project root, .opencode/ directories, and several override layers (OPENCODE_CONFIG, OPENCODE_CONFIG_CONTENT, managed config under /etc/opencode or /Library/Application Support/opencode). Layers merge; later layers win only for keys they set, and managed config wins over everything.
Can I write an opencode permission rule a user can't undo?
Yes. Put it in the managed config location — /Library/Application Support/opencode/ on macOS, /etc/opencode/ on Linux, %ProgramData%\opencode on Windows, or the macOS managed-preferences plist. opencode’s config docs say managed settings override everything and are not user-overridable, so a deny there holds against per-user and per-project files.
What does [ACP] Denied by policy mean in opencode?
The ACP plugin blocked the call from opencode’s tool.execute.before hook by throwing; the text after the colon is the policy reason the workspace returned. That hook runs for every tool call, including tools sitting at allow in your config. A hardline-floor hit reads hardline floor: <what> — blocked unconditionally; this pattern cannot be allowed by policy or approval.
Where to read more
- The opencode control model, explained — the gate, the escape hatch, the plugin surface, and where the native model ends
- Install guide — the plugin, the permission block the installer writes, fail mode, troubleshooting
- opencode hooks reference — the four hooks the plugin registers and what each one covers
- opencode cost tracking — local metering with no account, and priced spend through the proxy
- Native controls, compared — opencode’s permission model beside Claude Code, Codex CLI, Cursor, and the rest
- opencode permissions docs · config docs · agents docs · CLI docs — the upstream sources this page is checked against