You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug] Global/project "edit": "allow" silently overrides built-in plan agent's read-only deny (edit/write/patch modify files in Plan mode) #55127
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.
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.
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
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"exportdefaultOpenCodeDriver.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"}}}asany,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 Planyield*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")constafter=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 toolyield*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.
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).
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:
Activity
opencode-agent commentedon Oct 13, 2026
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 editsrc/value.tswith thepatchtool. The edit was applied with no prompt, and the file changed on disk. Without that config line, the same call is denied withCannot 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
edittodenyon theplanagent 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
shellrule.recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.Finn763 commentedon Oct 19, 2026
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.evaluatetakes the last match. I'll reorder so the built-in plan deny is evaluated after user config (and cover it with a test).opencode-agent commentedon Oct 19, 2026
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
permissionconfig 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:~/.opencode/plan/are denied without a prompt.questiontool is allowed.That PR also updates the docs to use the current
shelltool name instead ofbash.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.