Why OpenCode permission rules don't fire, and how to check the ones that loaded
A permission rule is a decision about a tool action: allow it, ask first, or deny it. In OpenCode the rule names an action and a resource, and the decision is made before the tool runs. A deny that does not block is rarely the model ignoring the rule. Usually the rule never loaded, a later rule won, or the action or resource does not match.
Run opencode --version first; 1.x is V1, 2.x is V2, and the keys, action names, and defaults differ.
Checked against OpenCode's documentation on 27 October 2026.
Status, 27 October 2026: V2.0.25 removed the built-in prompt for paths outside the project. The default is now allow, and the report about the surprise change is open (#54524, updated 25 October 2026); maintainers describe it as intentional. V1 still documents external_directory as asking. The explicit rule below restores the prompt.
The field names changed in V2#
First check whether the rule is written for the line you are running.
| Item | V1 | V2 |
|---|---|---|
| Config key | permission | permissions |
| Shape | object keyed by tool name | ordered array of rules |
| Shell tool | bash | shell |
| Subagents | task | subagent |
| Agent config | agent | agents |
V1:
{
"permission": {
"bash": {
"*": "ask",
"git status": "allow",
"git status *": "allow",
"git push *": "deny"
}
}
}The same policy in V2 is an array. Every rule has three strings: action, resource, and effect.
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git push *", "effect": "deny" }
]
}Paste the first block into V2 and the base policy applies: V2 does not read the key. The reverse is also true, and this is a common report after a V1-to-V2 upgrade.
V1 still accepts the deprecated tools map. A 2026 report found a bare true can act like an allow and override a narrower rule (#16331). If a V1 deny is ignored, remove the tools entry.
Order is the rule#
In V2 the last matching rule wins, not the most specific or longest one. Put the broad rule first and the exceptions after it:
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git push *", "effect": "deny" }
]Put git push * before the catch-all and the catch-all wins.
V1 uses the same last-match idea, but order comes from object keys. OpenCode preserves key order, but a generator or hand edit can reorder it. Duplicate keys are worse: the parser keeps only the last one, so a second "edit" block silently replaces the first. A 2026 report with two edit keys shows both traps (#24335). A fix in April 2026 preserved key order, but reports continued. Do not rely on object order for a security rule; one key per tool, broad rule first.
V2 policies hard-deny after rules and approvals; they only turn allow or ask into deny. In V2, agent rules are appended after global rules, so an agent's allow can override a global deny; a custom subagent uses its own permissions, not a subset of its parent's. Do not assume the parent's deny covers a child.
The rule that never matches#
A deny that never fires is often a pattern that never matches the resource.
- A bare command is not a prefix.
"git": "deny"does not matchgit status; the pattern is the whole command string, so usegit *. In both lines, a trailing*also matches the command without arguments; a baregit statuskey does not cover the arguments. - The wildcard is the whole value.
*matches zero or more characters, including/;?matches one.packages/*.mdxcan cross directory levels because*includes slashes. - Compound commands are several checks. V2's shell scanner may produce more than one command from a line such as
cd repo && git push. Any deny denies the operation; otherwise any ask asks. A rule written for the simple command can miss the wrapped form. - Path forms differ. V1 matches
readandeditagainst a path relative to the worktree, butexternal_directoryagainst an absolute boundary ending in/*. The same pattern can work for one action and fail the other (V1 source). V2 givesreada location-relative or canonical absolute path,editthe target path, andexternal_directorya canonical boundary.~and$HOMEexpand for those actions; shell resources stay raw. - The tool name is not the action name. V1's
taskis V2'ssubagent(a helper agent the main session launches); an MCP tool (from an external Model Context Protocol server) is<server>_<tool>.doom_loopandlspare V1-only, so migrated rules never fire. - A later rule matched. Re-read the order section. A catch-all at the end beats every specific rule before it.
Defaults, auto mode, and saved approvals#
V2 starts every agent with a base policy that allows everything, then asks for .env reads. No built-in rule asks for external_directory since 2.0.25, so paths outside the project are allowed without a prompt. To restore the prompt:
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "external_directory", "resource": "*", "effect": "ask" }
]
}V1 documents external-directory and doom-loop prompts as asking by default; config can still override.
Two other reasons a prompt stops: auto mode and a saved approval. Auto mode: opencode --auto approves every request not explicitly denied, and a V2 TUI can be set to session.permissions: "autoaccept" in cli.json. V1 has the same flag and a palette toggle. If commands run without prompts, check the mode first.
A saved approval: "Always" saves an allow rule. In V2 those approvals are durable and project-scoped, and they never override a configured deny; they can still make a later ask stop asking. In V1 the approved patterns are appended after the configured rules and last-match-wins applies, so a saved "always" can outrank an earlier deny; it lasts for the current session. If a deny worked yesterday and stopped today, suspect an approval.
Prove which rules loaded#
Do not ask the model what its permissions are; it cannot see the evaluator. Check the loaded rules, then run a harmless test.
V2#
opencode debug agents prints each agent's effective permission list in order, after the built-in base policy and your config have been merged:
opencode debug agentsOn 2.1.4 this is the direct view of the rules. If your rule is absent, the problem is config loading; if it is present but after a broader rule, the problem is order. opencode debug config only lists config sources in V2.
V1#
opencode debug config prints the resolved configuration, and opencode debug agent <name> prints one agent's configuration:
opencode debug config
opencode debug agent buildV1 also logs an evaluated line for each permission check, with the permission, pattern, and action. That line is the closest thing to a receipt:
grep -r evaluated ~/.local/share/opencode/log/Both lines: run a canary#
Add a rule you cannot mistake for normal work, then ask for the action. In V2:
{
"permissions": [
{ "action": "shell", "resource": "printf *", "effect": "deny" }
]
}In V1, add "printf *": "deny" under permission.bash.
A denied printf hello proves the rule loaded and matched. If it runs, the rule did not load, did not match the command string, or a later rule won. Remove it afterwards.
Where to go next#
- Why OpenCode ignores your AGENTS.md — instructions are context, not enforcement.
- Where did my OpenCode session go? — if the rule disappeared with the session.
- Why Codex keeps asking for approval — the same sandbox-and-policy split on the other agent.
- OpenCode guides — the rest of the set.