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
Phantom external_directory prompt when a compound command uses a relative cd #57515
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.
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
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.
Thanks for the detailed report. I can reproduce this on v2.1.1, and it still happens on the latest dev (a13c48b).
Steps:
A project with layer-a/ and layer-b/, configured with external_directory set to * with effect ask.
The model runs cd layer-a && ls; echo done; cd ../layer-b && ls from the project root.
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
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"constCOMMAND="cd layer-a && ls; echo done; cd ../layer-b && ls"exportdefaultOpenCodeDriver.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"}],},}asany,({ 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}asany)yield*Effect.sleep(1000)yield*ui.screenshot("result")constframe: any=yield*ui.capture()consttext=frame.lines.map((l: any)=>typeofl==="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.
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.
Activity
opencode-agent commentedon Oct 20, 2026
Thanks for the detailed report. I can reproduce this on v2.1.1, and it still happens on the latest
dev(a13c48b).Steps:
layer-a/andlayer-b/, configured withexternal_directoryset to*with effectask.cd layer-a && ls; echo done; cd ../layer-b && lsfrom the project root.<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/pushdarguments found in the command are all resolved against the tool's starting directory, without accounting for earliercds (ShellTool.prepareinpackages/core/src/tool/plugin/shell.tsandShellParse.scaninpackages/core/src/shell/parse.ts). Until it's fixed, using an absolute path for the secondcd(or the tool'sworkdir) avoids the prompt.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.kerthans commentedon Oct 21, 2026
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 aftercd/pushdacross&&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 failedcddoesn't stop the next command. Does that match how you wantexternal_directoryto behave? #56390 also touchesparse.ts, so I'd keep the diff small and rebase on whichever lands first; #57111 and #52224 shouldn't overlap.