Skip to content

write permission rules are silently ignored — write.ts asks under edit #40150

Closed

Description

@iceteaSA

Description

A write: block in opencode.json or in agent frontmatter parses without error, loads into the ruleset, and is never consulted by anything.

write is not a declared permission key. packages/core/src/v1/config/permission.ts:18-34 declares:

read · edit · glob · grep · list · bash · task · external_directory
todowrite · question · webfetch · websearch · lsp · doom_loop · skill

The struct has a Schema.Record(Schema.String, Rule) rest element, so any unknown key parses cleanly and is carried into the ruleset by fromConfig. Nothing then asks under it.

The write tool asks under edit:

// packages/opencode/src/tool/write.ts:55-57
yield* ctx.ask({
  permission: "edit",
  patterns: [path.relative(instance.worktree, filepath)],

There is no permission: "write" anywhere in src.

The practical effect is that a config like this does not do what it reads as:

{
  "permission": {
    "edit": { "*": "allow" },
    "write": { "*.env": "deny" }
  }
}

The write block is inert; writes are governed entirely by the edit rules. An operator who tightens write and leaves edit open has not tightened anything.

This also means unknown keys generally are silent no-ops — a typo like edt: or bahs: parses, loads, and does nothing, with no warning at any level.

Steps to reproduce

  1. Configure "permission": { "edit": { "*": "allow" }, "write": { "*": "deny" } }.
  2. Have the agent write a file.
  3. The write is allowed. The write deny never participates.
  4. Nothing is logged about the unused key.

The evaluated log line at permission/index.ts:74 confirms it directly: writes appear as permission=edit, and permission=write never appears at all. An independent deployment checked 125,800 evaluated lines and found zero permission=write entries.

OpenCode version

dev @ 1882c33

Expected

One of:

  1. Reject unknown permission keys at parse time. Preferred — it makes this class of mistake loud, and it catches typos as a side effect. It is a breaking change for any config carrying an undeclared key, so it wants a release note.
  2. Make write.ts ask under permission: "write" and document the key. Existing write: blocks become live as written, which is what their authors intended, but anyone relying on edit rules covering writes would see a behaviour change.

Either way write should appear in docs/permissions.mdx — as a supported key, or in the list of keys that do not exist.

Notes

Found while working on #30551 / #40149 (absolute permission patterns not matching outside the worktree). Filed separately because the fixes are unrelated: that one changes which path is matched, this one changes which key is consulted.

Two deployments were carrying write: blocks written in good faith that had never done anything.

Activity

  1. Santoshkumarpuppala commented on Sep 3, 2026

    @Santoshkumarpuppala

    Confirmed on dev — tool/write.ts:55 asks with permission: "edit", and the only permission: "write" anywhere is a test fixture. The diagnosis holds. Three things that bear on the fix rather than the finding.

    Option 1 would reject a documented feature. The config key is not a lookup key — Permission.evaluate does Wildcard.match(permission, rule.permission) (packages/opencode/src/permission/index.ts:32, and again at :77 and :210), and every MCP or custom tool asks under its own name. That is documented: agents.mdx:453 says permission keys "are matched as wildcard patterns against the underlying tool name… for example "mymcp_*": "deny"". So the Schema.Record(Schema.String, Rule) rest element at packages/core/src/v1/config/permission.ts:35 is load-bearing, and closing it at parse time would break every config gating an MCP tool. "mymcp_search": "ask" has no glob metacharacter, so it is not distinguishable from edt: by shape.

    That does not make the typo problem unreal — edt: still silently does nothing. It means the remedy is a diagnostic for keys matching no registered tool name, not a parse-time allowlist.

    Option 2 has a second call site. write is remapped in two places: the ask at tool/write.ts:55, and permission/index.ts:205 — const edits = ["edit", "write", "apply_patch"] — which feeds the path deciding which tools are hidden from the model. Changing only write.ts makes "write": {"*": "deny"} start prompting while tool-hiding stays keyed on edit. Half-live is worse than inert, because the config then appears to work.

    list is the same defect and survives the preferred fix. It is declared at packages/core/src/v1/config/permission.ts:23, documented at agents.mdx:438 as gating a tool called list, and still has a prompt renderer. There is no list tool: packages/opencode/src/tool/ contains apply_patch, edit, glob, grep, lsp, read, shell, skill, task, todo, webfetch, websearch, write and friends — no list.ts. I control-tested that enumeration against glob, grep, lsp, skill and websearch, all of which resolve, so it finds rare positives. So "list": {"*": "deny"} is inert and declared, and a strict-key gate waves it through — which is the argument that the property making a key inert is reachability, not declaredness.

    One note on the 125,800-line check, which is good evidence here: the evaluated log is emitted inside the ask path, while Permission.disabled (:204-219) consumes the same ruleset and logs nothing. For write both remap to edit so the conclusion stands, but a key can be dead in one consumer and live in the other — which is why that method finds write and not list.

    Same shape from the other side, for whatever it is worth: in an MCP enforcement point I maintain, the integrity check pins an enumerated allowlist of six tool fields, so anything the spec adds later ships unpinned and silently uncovered. The schema vouches for coverage that reachability does not provide, which is list exactly.

  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed write-up!

    What you're seeing is how it's designed to work: the permissions docs say that edit covers all file changes (edit, write and patch). So the write tool asking under edit is expected, and there is no separate write key. Other keys are accepted on purpose, probably so rules can apply to permissions outside the core list, such as MCP or custom tool names.

    You're right that it's easy to get caught by this, though: a write: block, or a typo like edt: or bahs:, does nothing and gives no warning. I've filed this as a feature request. Options include a warning for keys that never match anything, a specific hint that write is covered by edit, or a real write permission. Maintainers will consider it.

    For now, to restrict writes, put the rules under edit:

    {
      "permission": {
        "edit": { "*": "allow", "*.env": "deny" }
      }
    }

    Related: #30551 and #40149 (absolute permission patterns outside the worktree), which you already linked and which are a separate problem.

  3. neriousy commented on Nov 13, 2026

    @neriousy
    Member

    Closing, since this is intended in V2 and the config no longer ignores write: rules. The write tool asks under edit on purpose, and the docs say edit covers edit, write and patch (see the Permissions page and the V1 migration guide). Legacy write, patch and apply_patch keys in permission, tools and agent config are migrated to edit, so write: { "*.env": "deny" } is enforced as an edit deny. Warnings for unknown permission keys are tracked in #46757.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions