cfgaudit

Rules / CFG105

CFG105 a committed OpenCode permission block removes one of the two remaining prompts

Severity: warn OWASP: LLM03:2026 – Excessive Agency

Why almost nothing in a permission block is a finding

OpenCode's defaults are permissive by construction. The ruleset in packages/core/src/plugin/agent.ts opens with:

const defaults: PermissionV2.Ruleset = [
  { action: "*", resource: "*", effect: "allow" },
  ...readonlyExternalDirectory,          // external_directory: * → ask
  { action: "question",   resource: "*", effect: "deny" },
  { action: "plan_enter", resource: "*", effect: "deny" },
  { action: "plan_exit",  resource: "*", effect: "deny" },
  { action: "read", resource: "*",             effect: "allow" },
  { action: "read", resource: "*.env",         effect: "ask" },
  { action: "read", resource: "*.env.*",       effect: "ask" },
  { action: "read", resource: "*.env.example", effect: "allow" },
]

So "bash": "allow", "edit": "allow" and "webfetch": "allow" restate the default. A rule reporting every allow would report how the tool is meant to be used — the mistake #525 originally planned for and the measurement talked us out of.

What the defaults still hold back is exactly two things: touching a path outside the working directory, and reading a .env file. Both are ask.

How a committed block removes them

Config rules are pushed after the defaults (agent.permissions.push(...permissions) in packages/core/src/config/plugin/agent.ts), and resolution takes the last matching rule:

rulesets.flat().findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource))
  ?? { action, resource: "*", effect: "ask" }

Two consequences the rule is built on:

  1. "read": "allow" re-allows .env. It becomes {action: "read", resource: "*", effect: "allow"}, which matches *.env and lands after the default that asks. The author wrote "let it read files"; the effect includes "and .env without asking".
  2. Order inside the block decides. {"*": "allow", "external_directory": "ask"} is not a finding and the reverse order is. cfgaudit decodes the block with a token walk rather than into a map for exactly this reason, and upstream keeps the order on purpose: "Runtime config parsing uses Effect's propertyOrder: "original" parse option so user key order is preserved for permission precedence."

A bare "permission": "allow" normalizes to {"*": "allow"} and therefore hits both.

What is reported

Resolves to allow Reported
external_directory for everything, the whole home directory, or the filesystem root yes
external_directory for a named directory (~/projects/personal/**) no — documented usage
reading *.env or *.env.* yes
reading *.env.example no — allowed by default
anything else (bash, edit, glob, skill, …) no — restates the default

Agent blocks (agent.<name>.permission) are resolved separately and named separately, since agent rules take precedence over the global block.

The message names the rule in the file that decided the outcome, not the mechanism, and the fix it suggests is verified to silence the finding.

Two places where the docs and the code disagree

Measured

38 committed opencode.json files with a permission block, run against the finished rule: it fires on 14 of them, 26 findings — 19 for .env reads and 7 for external_directory.

That rate is higher than this project usually accepts, and the reason it ships anyway is that every finding is a real change of behaviour rather than a pattern that merely looks broad: the file genuinely removes a prompt that OpenCode puts there. The .env half is the part to revisit first if that turns out to be wrong in practice.

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation