Skip to content

[Bug] Global/project "edit": "allow" silently overrides built-in plan agent's read-only deny (edit/write/patch modify files in Plan mode) #55127

Closed

Description

@jensklose

Summary

The built-in plan agent is designed to be read-only (denies edit/write/patch outside ~/.opencode/plan/). However, a user's global or project config containing "permission": { "edit": { "*": "allow" } } silently overrides the plan agent's deny because Permission.evaluate uses findLast (last match wins). No warning is emitted, and the agent behaves identically to Build mode for file edits.

Root Cause

In packages/core/src/permission.ts:

export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
  return rulesets.flat().findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? { action, resource, effect: "ask" }
}

The plan plugin (packages/core/src/plugin/plan.ts) pushes its deny early:

item.permissions.push({ action: "edit", resource: "*", effect: "deny" })
item.permissions.push({ action: "edit", resource: path.join(directory, "*"), effect: "allow" })

Global/project config entries are appended after the plugin's push. With findLast, the later allow wins.

Reproduction

  1. In global ~/.config/opencode/opencode.jsonc or project opencode.json, set:
    { "permission": { "edit": { "*": "allow" } } }
  2. Start an opencode session with the plan agent.
  3. Ask the agent to edit a file outside ~/.opencode/plan/.
  4. Observe: the edit succeeds with no prompt/denial.

Expected Behavior

Plan mode should be enforced at the permission layer. Options:

Related

Environment

  • opencode version: 2.0.27 (Homebrew opencode-v2)
  • OS: macOS (also reproducible on Linux/Windows)
  • Install: standalone binary / npm
  • No plugins required

Additional Context

The plan agent's execute.after hook only rewrites the error message after a Permission.BlockedError; it does not block. Enforcement relies entirely on the permission rule, which is neutralized by config ordering.

Activity

  1. opencode-agent commented on Oct 13, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report, @jensklose! I was able to reproduce this on v2.0.27, and it still happens on the latest dev (c1615b1).

    Setup: the global config contained only "permission": { "edit": { "*": "allow" } }. I switched to the Plan agent with Shift+Tab and had the (simulated) model edit src/value.ts with the patch tool. The edit was applied with no prompt, and the file changed on disk. Without that config line, the same call is denied with Cannot use patch to modify files outside the Plan directory.

    The recording and screenshot below show this, and the script below reproduces it. Your analysis matches the code: the config's top-level permissions are appended to every agent after the Plan agent's own rules, and the last matching rule wins.

    Until this is addressed, setting edit to deny on the plan agent in your config should keep Plan from editing files.

    Related: #39715 asks for a warning when global config overrides Plan's permissions. #53955 / #54165 cover the separate gap where Plan has no shell rule.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#55127: a global `permission.edit: { "*": "allow" }`
    // overrides the built-in Plan agent's edit deny, so the `patch` edit tool modifies project files in Plan mode.
    import { Effect } from "effect"
    import { readFileSync } from "node:fs"
    import { join } from "node:path"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        // Written to the isolated global config (OPENCODE_CONFIG_DIR/opencode.jsonc).
        // `permissions: []` drops Drive's default allow-all rule so only the user's rule applies;
        // remove the `permission` line and the patch is denied ("Cannot use patch to modify files outside the Plan directory").
        config: { permissions: [], permission: { edit: { "*": "allow" } } } as any,
        project: { git: true, files: { "src/value.ts": "export const value = 1\n" } },
      },
      ({ ui, llm, artifacts }) =>
        Effect.gen(function* () {
          yield* ui.screenshot("01-build")
          // Switch the primary agent from Build to Plan
          yield* ui.waitFor("Ask anything")
          yield* Effect.sleep(1500)
          yield* ui.press("tab", { shift: true })
          yield* Effect.sleep(800)
          yield* ui.waitFor("Plan ·")
          yield* ui.screenshot("02-plan-selected")
          yield* llm.queue(
            Llm.toolCall({
              index: 0,
              id: "call_patch",
              // The simulated (OpenAI-style) model is offered `patch` rather than `edit`/`write`
              name: "patch",
              input: {
                patchText:
                  "*** Begin Patch\n*** Update File: src/value.ts\n@@\n-export const value = 1\n+export const value = 2 // MODIFIED IN PLAN MODE\n*** End Patch",
              },
            }),
            Llm.finish("tool-calls"),
          )
          yield* llm.queue(Llm.text("I changed src/value.ts while in Plan mode."))
          yield* ui.submit("Change value to 2 in src/value.ts")
          yield* ui.waitFor("while in Plan mode")
          yield* Effect.sleep(1000)
          yield* ui.screenshot("03-after-write")
          const after = readFileSync(join(artifacts, "files", "src/value.ts"), "utf8")
          console.log(`src/value.ts after the Plan-mode patch: ${JSON.stringify(after)}`)
          console.log(after.includes("MODIFIED IN PLAN MODE") ? "BUG REPRODUCED: Plan agent modified the file" : "OK: file unchanged")
          // Show the file contents on screen via the read tool
          yield* llm.queue(
            Llm.toolCall({ index: 0, id: "call_read", name: "read", input: { path: "src/value.ts" } }),
            Llm.finish("tool-calls"),
          )
          yield* llm.queue(Llm.text("Done reading."))
          yield* ui.submit("Show src/value.ts")
          yield* ui.waitFor("Done reading.")
          yield* Effect.sleep(1000)
          yield* ui.screenshot("04-file-contents")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. Finn763 commented on Oct 19, 2026

    @Finn763
    Contributor

    I'd like to work on this — the root cause is as described: the plan plugin pushes its deny before config rules are appended, and Permission.evaluate takes the last match. I'll reorder so the built-in plan deny is evaluated after user config (and cover it with a test).

  3. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up, @jensklose. We looked at this and are treating it as expected behavior. It's a documentation problem, not a permission-engine bug.

    Here's what happens. Permission rules are evaluated in order, and the last matching rule wins. The Plan agent's built-in rules are applied first. Your top-level permission config is applied after them, to every agent. So "edit": { "*": "allow" } at the top level means "allow edits everywhere, for every agent", Plan included. The same applies to "*": "allow". This is also how it worked in v1.

    The part we got wrong was the docs. The Plan section said edits and shell commands default to ask, and it didn't explain how global rules interact with Plan. #56922 rewrites that section to describe what Plan actually does:

    • Edits outside ~/.opencode/plan/ are denied without a prompt.
    • The question tool is allowed.
    • Shell commands aren't restricted by Plan. They follow your permission config.

    That PR also updates the docs to use the current shell tool name instead of bash.

    To allow edits globally but keep Plan read-only, set Plan's permissions directly. Per-agent rules are applied after the global ones, so they win:

    {
      "$schema": "https://opencode.ai/config.json",
      "permission": {
        "edit": "allow"
      },
      "agent": {
        "plan": {
          "permission": {
            "edit": {
              "*": "deny",
              "~/.opencode/plan/*": "allow"
            },
            "shell": "ask"
          }
        }
      }
    }

    With this config, Plan can't edit files outside its plan directory, can still write plans there, and asks before running shell commands.

    Closing this one since the docs fix has merged. Thanks again for digging into the root cause.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions