Skip to content

Phantom external_directory prompt when a compound command uses a relative cd #57515

Open

Description

@Nemesis19

Description

When a shell command changes directory twice and the second change is relative, opencode asks for access to a directory outside the project that the command never touches.

cd /abs/project/admin-ui && grep -n x app/pages/index.vue; cd ../backend && git status

../backend from project/admin-ui is project/backend, inside the project. With external_directory: * → ask the prompt asks for /abs/backend/* instead — a sibling of the project root.

The scanned cd arguments are all resolved against one fixed cwd, so the directory reached by the first cd is ignored. packages/opencode/src/tool/shell.ts in v1.19.3 does exactly that (argPath(arg, cwd, …) inside collect(root, cwd, …), with cwd never advancing); v2.1.1 resolves the extracted directories against the shell job's cwd the same way. v1 rarely hit it because its shell description told the model to avoid cd chains ("AVOID using cd <directory> && <command>. Use the workdir parameter instead"), an instruction v2 dropped from the tool description.

Plugins

No response

OpenCode version

2.1.1

Steps to reproduce

Use ~-less absolute paths, or the same relative form, from the project root.

  1. Two layer directories inside one project:
mkdir -p /tmp/oc-repro/project/layer-a /tmp/oc-repro/project/layer-b
cd /tmp/oc-repro/project && git init
  1. opencode.json in the project root:
{
	"$schema": "https://opencode.ai/config.json",
	"permissions": [
		{ "action": "external_directory", "resource": "*", "effect": "ask" }
	]
}
  1. Run this command (in the TUI, or opencode run "…" — non-interactive runs auto-reject and log the request):
cd /tmp/oc-repro/project/layer-a && ls; echo done; cd ../layer-b && ls

Actual (v2.1.1, macOS, zsh):

! permission requested: external_directory (/private/tmp/oc-repro/layer-b/*); auto-rejecting

Expected: no request. Replacing the second cd with an absolute path produces none.

Fix suggested

Track cd/pushd state while walking the command list and resolve each argument against the current directory, or don't assert external_directory for relative arguments containing ... Restoring the "avoid cd, use workdir" line in the tool description is only a mitigation.

Screenshot

No response

Operating System

No response

Terminal

No response

Activity

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can reproduce this on v2.1.1, and it still happens on the latest dev (a13c48b).

    Steps:

    1. A project with layer-a/ and layer-b/, configured with external_directory set to * with effect ask.
    2. The model runs cd layer-a && ls; echo done; cd ../layer-b && ls from the project root.
    3. OpenCode asks for access to <parent of project>/layer-b/*, a folder next to the project that doesn't exist. The command only ever enters <project>/layer-b.

    The recording and screenshot below show the prompt, and the script below reproduces it. As you described, the cd/pushd arguments found in the command are all resolved against the tool's starting directory, without accounting for earlier cds (ShellTool.prepare in packages/core/src/tool/plugin/shell.ts and ShellParse.scan in packages/core/src/shell/parse.ts). Until it's fixed, using an absolute path for the second cd (or the tool's workdir) avoids the prompt.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#57515: a relative `cd` after an earlier `cd`
    // is resolved against the job's starting cwd, so the external_directory
    // permission asks for a sibling of the project that the command never touches.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const COMMAND = "cd layer-a && ls; echo done; cd ../layer-b && ls"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: {
          git: true,
          files: { "layer-a/a.txt": "a\n", "layer-b/b.txt": "b\n" },
        },
        config: {
          permissions: [{ action: "external_directory", resource: "*", effect: "ask" }],
        },
      } as any,
      ({ ui, llm, artifacts }) =>
        Effect.gen(function* () {
          yield* llm.queue(
            Llm.toolCall({ index: 0, id: "call_shell", name: "shell", input: { command: COMMAND, description: "List layers" } }),
            Llm.finish("tool-calls"),
          )
          yield* ui.submit(`Run: ${COMMAND}`)
          // The permission prompt names a directory outside the project.
          yield* ui.waitFor("Permission required", { timeout: 60000 } as any)
          yield* Effect.sleep(1000)
          yield* ui.screenshot("result")
          const frame: any = yield* ui.capture()
          const text = frame.lines.map((l: any) => typeof l === "string" ? l : (l.text ?? (l.cells ?? l.spans ?? []).map((c: any) => c.text ?? c.char ?? "").join(""))).join("\n")
          yield* Effect.log("\n" + text)
          // Reject so the session settles.
          yield* ui.arrow("right")
          yield* ui.arrow("right")
          yield* ui.enter()
          yield* Effect.sleep(2000)
          // Show where the project actually lives: layer-b is inside it, not next to it.
          const { execSync } = yield* Effect.promise(() => import("node:child_process"))
          yield* Effect.log(`artifacts: ${artifacts}\n` + execSync(`find ${artifacts} -maxdepth 3 -name 'layer-*' -not -path '*/node_modules/*'`).toString())
        }),
    )

    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. kerthans commented on Oct 21, 2026

    @kerthans

    I'd like to take this if no one's on it. Plan: track cwd while scanning in ShellParse.scan (both the legacy and portable scanners), advancing it after cd/pushd across && at the same nesting level, and keeping subshells, pipelines and || resolved against the starting cwd as today. For ; I'd only advance if the target directory exists, since a failed cd doesn't stop the next command. Does that match how you want external_directory to behave? #56390 also touches parse.ts, so I'd keep the diff small and rebase on whichever lands first; #57111 and #52224 shouldn't overlap.

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