Skip to content

plan: Plan-mode guard denies writes to the configured plan directory it names (still present on beta-18721, macOS arm64) #46358

Open

Description

@luke9705

Summary

In Plan mode, write and edit are refused for files inside the configured plan directory. The error names /Users/<user>/.opencode/plan as the Plan directory, then rejects /Users/<user>/.opencode/plan/003-example.md as being outside it.

This is the same failure as #44694, which was closed as not reproducible on beta-18050. It still reproduces on beta-18721, on a different OS and architecture (macOS arm64 vs the original Linux x86_64).

This report adds a discriminating test the original lacked: in the same Plan-mode session, a write to a project-relative aidocs/ path succeeds while a write to the absolute plan directory fails. Both paths have matching allow rules in the agent's resolved permissions. That isolates the fault to the guard's plan-path resolution and rules out both the permission layer and tool-selection/prompting.

Environment

  • opencode version: 0.0.0-beta-18721
  • OS: Darwin 25.6.0 (darwin arm64), macOS 26.6.2
  • Terminal: TERM_PROGRAM=ghostty, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Install/channel: beta
  • Active plugins: advisor.ts, memory.ts, sartiq-models.ts, tavily-fallback.ts (all under ~/.config/opencode/plugins/)

Reproduction

  1. Configure the plan agent with a plan-directory carve-out in ~/.config/opencode/opencode.json:

    "plan": {
      "permissions": [
        { "action": "edit", "resource": "*", "effect": "deny" },
        { "action": "edit", "resource": "~/.opencode/plan/*", "effect": "allow" },
        { "action": "edit", "resource": "aidocs/*", "effect": "allow" },
        { "action": "edit", "resource": "**/aidocs/*", "effect": "allow" }
      ]
    }
  2. Confirm the rule resolves. opencode2 api get /api/agent reports, for the Plan agent:

    {"action": "edit", "resource": "*", "effect": "deny"}
    {"action": "edit", "resource": "/Users/<user>/.opencode/plan/*", "effect": "allow"}
    {"action": "edit", "resource": "aidocs/*", "effect": "allow"}
    {"action": "edit", "resource": "**/aidocs/*", "effect": "allow"}

    The ~ is expanded to an absolute path and the plan-directory rule is the last matching edit rule, so it wins.

  3. Enter Plan mode. The injected reminder states: You may optionally create or update plan documents in: /Users/<user>/.opencode/plan

  4. write a new file to ~/.opencode/plan/003-example.md — denied.

  5. edit an existing file, e.g. ~/.opencode/plan/001-example.md — denied.

  6. In the same session, write to ~/aidocs/guard-probe.md — succeeds.

Expected Behavior

Steps 4 and 5 succeed. The path matches an allow rule that resolves last, and the Plan-mode contract explicitly grants plan-directory writes.

Actual Behavior

Cannot use write to modify files outside the Plan directory: /Users/<user>/.opencode/plan
Cannot use edit to modify files outside the Plan directory: /Users/<user>/.opencode/plan

The error names the same directory the target file is in. Step 6, with a project-relative path, succeeds in the same session.

Additional Context

The aidocs/ contrast is the key new datum. Same Plan-mode session, same agent, two paths that both have allow rules:

Target Result
~/.opencode/plan/003-example.md denied
~/aidocs/guard-probe.md succeeded

Because one succeeds, this is not a blanket Plan-mode write block, not the permission layer, and not a prompting/tool-selection problem (the same tool, in the same session, writes successfully to a different path). The guard discriminates by path, and does not recognise ~/.opencode/plan as the plan directory it names in its own error.

That the succeeding path is project-relative while the failing one is absolute is consistent with the original reporter's hypothesis in #44694: an unnormalized or wrongly-based comparison, e.g. the plan directory being resolved relative to the session/project location rather than to the advertised absolute path.

Other findings:

  • Not a symlink or canonicalization issue on the user side: ~/.opencode is a real directory, and os.path.realpath('/Users/<user>/.opencode/plan') returns the path unchanged.
  • Not a permission-layer failure: the same absolute path accepts a write from the build agent in the same workspace.
  • The guard sits above the permission system, so no configuration change works around it. Config.AgentEncoded exposes only color, description, disabled, hidden, mode, model, permissions, request, steps, system, and the root config has no plan-directory field — so the plan directory is not user-configurable.
  • Reproducible consistently, across service restarts.
  • Practical impact: the plan agent cannot write plan documents to the global plan directory at all. Workarounds are to write them from build, or to use a project-relative aidocs/ path.

Re: #44694

That issue was closed NOT_PLANNED with a request for a session transcript. I do not have a shareable transcript, but the aidocs/ contrast above should serve the same diagnostic purpose: it demonstrates the tool call is issued and succeeds against one path while being refused for another in the same session, which distinguishes a guard bug from a tool-selection problem.

Worth noting the maintainer tested beta-18050 and could not reproduce, while it reproduces reliably here on beta-18721. Something environmental may gate it. Details that may be relevant: the plan directory contains pre-existing files; ~/.opencode also contains memory/, node_modules/, package.json, and a .gitignore; and the session's working directory is the user's home directory (/Users/<user>), which is also the detected project root. If the guard bases the plan directory on the project location, a home-directory session may be the trigger.

Activity

  1. holny commented on Sep 2, 2026

    @holny
    Contributor

    Question before anyone picks this up: on current v2 the Plan plugin hardcodes ~/.opencode/plan as the plan directory (packages/core/src/plugin/plan.ts:31) — there's no configurable plan path in that plugin. How is the plan directory set in your setup (custom agent config?), and what does the effective allow rule look like when the absolute-path write is denied? Just want to reproduce the right configuration first.

  2. Xarianne commented on Sep 29, 2026

    @Xarianne

    Reproduces here with no custom config of any kind, it's the plugin's hardcoded ~/.opencode/plan as suspected.

    Env: opencode v2.0.18, Linux x86-64 (Fedora bootc;). Session cwd = $HOME, which is also the project root; not a git repo. No opencode.json(c) anywhere, no agent/ dir, no plan-related config.

    Resolved permissions for the plan agent (from GET /api/agent):

    { action: edit, resource: , effect: deny },
    { action: edit, resource: ~/.opencode/plan/
    , effect: allow },
    { action: external_directory, resource: ~/.opencode/plan/*, effect: allow }

    Yet write to ~/.opencode/plan/.md fails with Cannot use write to modify files outside the Plan directory: ~/.opencode/plan, while writing the identical content to the identical path via the shell tool succeeds. Same as #49168.

    Since it reproduces with default config, the guard's plan-path resolution looks like the fault.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report and the aidocs/ comparison. I reproduced this on 2.2.0 (the reported beta isn't published on npm, so I used the latest release), and it still happens on the latest dev (06d3c06).

    Steps:

    1. Make the session's project root the home directory, as when starting in ~.
    2. Use the Plan agent with default permissions.
    3. Have the model add ~/.opencode/plan/003-example.md using the absolute path.

    The tool fails with Cannot use patch to modify files outside the Plan directory: <home>/.opencode/plan and no file is created. My test used patch, which OpenCode offers instead of write/edit for OpenAI-style models; those use the same edit permission check. When the home directory is outside the project, the same call succeeds.

    The recording and screenshot below show this, and the script below reproduces it. The TUI shows the target as the relative path .opencode/plan/003-example.md. That fits your hypothesis: files inside the project are checked as relative paths, so the Plan agent's absolute allow rule for ~/.opencode/plan/* never matches, and its deny-all edit rule wins. The 'outside the Plan directory' text is a rewrite of that permission denial.

    Until it's fixed, starting OpenCode from a directory that doesn't contain ~/.opencode/plan (e.g. a project subdirectory) avoids it.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces anomalyco/opencode#46358: the Plan agent cannot write into its own
    // plan directory (~/.opencode/plan) when the session's project directory is $HOME.
    //
    // opencode-drive normally uses separate dirs for $HOME (OPENCODE_TEST_HOME) and the
    // project. A small wrapper sets OPENCODE_TEST_HOME to the project directory, so the
    // project root is $HOME, as in the report.
    import { chmodSync, writeFileSync } from "node:fs"
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const dev = process.env.OC_DEV
    const wrapper = "/tmp/opencode/oc-home-is-project.sh"
    const target = dev
      ? `exec bun --conditions=browser --preload=${dev}/packages/tui/node_modules/@opentui/solid/scripts/preload.js ${dev}/packages/cli/src/index.ts "$@"`
      : `exec opencode2 "$@"`
    writeFileSync(
      wrapper,
      `#!/bin/sh\n# project dir = files/, OPENCODE_CONFIG_DIR = files/.opencode\n${process.env.CONTROL ? "" : 'export OPENCODE_TEST_HOME="$(dirname "$OPENCODE_CONFIG_DIR")"\n'}${target}\n`,
    )
    chmodSync(wrapper, 0o755)
    
    export default OpenCodeDriver.use(
      {
        opencode: { command: [wrapper] },
        // Drop opencode-drive's default allow-all rule so the default permissions apply, as for a real user.
        config: { default_agent: "plan", permissions: [] },
        project: { git: true, files: { "README.md": "# home\n" } },
        tui: { recording: true },
      },
      ({ ui, llm, artifacts }) =>
        Effect.gen(function* () {
          yield* Effect.sleep("3 seconds")
          yield* ui.screenshot("plan-agent-selected")
          // Set CONTROL=1 to keep $HOME outside the project: the same write then succeeds.
          const planDir = process.env.CONTROL ? `${artifacts}/.opencode/plan` : `${artifacts}/files/.opencode/plan`
          // Plan dir as the plugin computes it: <home>/.opencode/plan, home = project here.
          const seen: string[] = []
          let second = ""
          yield* llm.serve((request, index) => {
            const msgs = (request as any).body?.messages ?? (request as any).messages ?? []
            seen.push(`#${index}: ` + JSON.stringify(msgs.slice(-2)).slice(-600))
            const body = JSON.stringify(msgs)
            if (body.includes("Reply with only the title")) return Stream.make(Llm.text("Plan probe"))
            if (!body.includes("call_write_plan"))
              return Stream.make(
                Llm.toolCall({
                  index: 0,
                  id: "call_write_plan",
                  // The simulated model is OpenAI-style, so OpenCode offers `patch` instead of write/edit.
                  name: "patch",
                  input: {
                    patchText: `*** Begin Patch\n*** Add File: ${planDir}/003-example.md\n+# Plan\n*** End Patch`,
                  },
                }),
                Llm.finish("tool-calls"),
              )
            second = body
            seen.push(((request as any).body?.tools ?? (request as any).tools ?? []).map((t: any) => t.function?.name ?? t.name).join(","))
            return Stream.make(Llm.text("Done."))
          })
          yield* ui.submit("Save the plan to the plan directory.")
          yield* ui.waitFor("Done.", { timeout: 20000 })
          yield* Effect.sleep("1 second")
          yield* ui.screenshot("after-write")
          const created = yield* Effect.promise(() => Bun.file(`${planDir}/003-example.md`).exists())
          yield* Effect.log(`tools offered: ${seen.at(-1)}`)
          yield* Effect.log(`patch result returned to the model: ${second.match(/"role":"tool".{0,400}/)?.[0]}`)
          yield* Effect.log(`plan file created: ${created} (${planDir}/003-example.md)`)
        }),
    )

    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.

  4. Lomare commented on Oct 29, 2026

    @Lomare

    Two additions from #59838 (closed as a duplicate of this):

    The error message is misleading in exactly the case where it fires. When the session cwd is an ancestor of the plan dir (e.g. ~), the write to ~/.opencode/plan/ is hard-denied with "Cannot use write to modify files outside the Plan directory" — even though the file is inside the plan directory. Root cause: the plan plugin's allow rule is absolute (<home>/.opencode/plan/*) but the permission check receives a cwd-relative resource when the target is inside the session cwd. I don't think #53365 (matching absolute rules against the full path) changes the message in plugin/plan.ts — so until the rule normalization lands, the message will keep misdirecting people.

    Workaround until it's fixed (add to the plan agent's permissions):

    { "action": "edit", "resource": ".opencode/plan/*", "effect": "allow" }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions