The Deny Rule That Silently Died After an OpenCode V2 Upgrade
Upgrading the harness killed an old permission config with no error. How I found the dead deny rule and proved it with a denied action attempt.
TL;DR
Frontend subagent ran arbitrary shell commands despite denied permissions. Vocabulary changes in OpenCode V2 caused the issue. Config files required manual updates.
The first morning after upgrading the coding harness to OpenCode V2 in the KotaPortal project, I saw something that should have been impossible: a frontend subagent was running arbitrary shell commands, even though its YAML frontmatter denied every shell command except a six-line whitelist I had written myself. No error. No warning. The config file looked exactly as I had left it the night before.
My first guess was a session bug, or that the agent had quietly switched to primary mode. It was neither. The frontmatter schema had changed under me.
A Vocabulary Change, Not a Bug
The V2 documentation states that frontmatter accepts the same fields as an `agents` config entry [2], and the permission field is now a list of rules with `action`, `resource`, and `effect` [1]. The vocabulary was renamed: V1 used `permission`, `bash`, and `task`; V2 uses `permissions`, `shell`, and `subagent` [1]. The old names are no longer part of the accepted field set.
It was not only permissions. Legacy keys such as `temperature` and `memory` disappeared without a sound, and a declared skills preload for my research subagent never loaded; skill loading now goes through the skill tool from the prompt instructions. None of those dead fields produced any error.
The old block, which V2 no longer reads, looked like this:
# V1 — no longer read by V2
---
mode: subagent
permission:
bash:
"*": deny
"npm run lint": allow
task: deny
---
The form that applies now:
# V2 — the list form that applies
---
mode: subagent
permissions:
- action: shell
resource: "*"
effect: deny
- action: shell
resource: "npm run lint*"
effect: allow
- action: subagent
resource: "*"
effect: deny
---
The V2 documentation explains why the first block stays silent.
The V2 documentation explains why the first block stays silent. Rules are combined in order, the last match wins, and agent rules are appended last [1]. When the field and action names are not recognized, nothing matches, and OpenCode falls back to `ask` [1]. In my own observation, the old permission map was not read at all, so no deny rule ever got the chance to block anything.
The comparison is what bothered me most. At the global config level, schema churn is handled carefully: the V1 documentation notes that the legacy `tools` boolean config has been deprecated since v1.1.1 but is "still supported for backwards compatibility" [3]. The V1 agents documentation already described tool access as "configured via permissions" [4], so the vocabulary had changed shape before. At the agent-frontmatter level, my audit found no comparable compatibility layer, and the V2 documentation does not promise one [2]. That silent behavior is a first-hand observation from my own migration, not a vendor-stated contract, so I treat it as something to verify rather than assume. The V2 line itself is a real major release: the installer notes say older versions install OpenCode 1, and the releases carry versioned Docker tags [5].
Proving the Deny Still Bites
My verification method is now a single step: attempt the denied action. Ask the subagent to run a command that should be denied. If the command runs unpunished, the rule is dead, no matter how tidy the YAML looks.
A green test suite never does this. Suites test the code that implements features, not the config that refuses actions; no assertion asks whether a command is supposed to fail. The V2 documentation shows why one live attempt is decisive enough: an operation touching several resources is denied as soon as one `deny` matches [1], and a subagent runs with its own permissions, not the parent's combined grants [2]. If the child has no living deny rule, tight permissions on the parent stop nothing.
The Two Steps After Every Upgrade
Every harness upgrade in that repo now ends the same way. I re-read every agent frontmatter file against the schema that is currently in force, then run one denial attempt per deny rule. The config file looks equally tidy before and after. Only one externally visible thing separates a working policy from a dead one: an action that is actually refused.
Sources:
Sources
[1] https://opencode.ai/v2/docs/permissions [2] https://opencode.ai/v2/docs/agents [3] https://opencode.ai/docs/permissions [4] https://opencode.ai/docs/agents [5] https://opencode.ai/v2/docs