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
The shell tool's external_directory check covers cd/pushd targets but not commands that change their own working directory through a flag. With external_directory: deny (or ask), cd /outside && git status is denied, but git -C /outside status runs without a prompt.
Ask the agent to run cd /some/other/dir && git status — denied by external_directory (expected).
Ask the agent to run git -C /some/other/dir status — it runs without any prompt.
Same for make -C /some/other/dir -n.
Expected Behavior
Directory flags are authorized like cd targets, so git -C /outside … is denied (or prompts) under the same external_directory rule.
Actual Behavior
packages/core/src/tool/plugin/shell.ts authorizes invocation.cwd and ShellParse.scan(...).directories (the cd/pushd targets) via FileAccess.authorizeExternal. Flag-based directory changes are not reported by the scanner, so they are never authorized.
Suggestion: have ShellParse report these flag directories alongside cd targets (resolved against the cwd, including nested -C), so they go through the same authorizeExternal path. Values containing variables/substitutions can be skipped the same way cd targets are.
Reproducible every time. As a workaround we check these flags in a plugin (execute.before on shell, calling ctx.permission.assert with external_directory), but the boundary belongs in core.
Thanks for the detailed report. I can reproduce this on 2.1.4, and it still happens on the latest dev (8697644).
I used your config (shell allowed for *, external_directory denied for *) and a simulated model that makes three shell calls against a git repo outside the project:
git -C <outside> log --oneline → runs without a prompt and prints the outside repo's commit
make -C <outside> → runs the outside Makefile without a prompt
The recording and screenshots below show this, and the script below reproduces it. As you found, the shell scanner only reports directories for cd/pushd-style commands, so directories passed as flags never reach the external_directory check.
Related: #56288 (file-path arguments and redirects in shell also skip external_directory) and its open PR #56390.
recording.mp4
Reproduction script (drive.ts)
// Repro for anomalyco/opencode#59564: external_directory deny blocks `cd /outside && git ...`// but not `git -C /outside ...` / `make -C /outside ...`.import{Effect,Stream}from"effect"import{execSync}from"node:child_process"import{mkdirSync,mkdtempSync,writeFileSync}from"node:fs"import{tmpdir}from"node:os"import{join}from"node:path"import{Llm,OpenCodeDriver}from"opencode-drive"// A git repo outside the project, with a recognisable commit message and a Makefile.constoutside=mkdtempSync(join(tmpdir(),"outside-"))mkdirSync(outside,{recursive: true})writeFileSync(join(outside,"Makefile"),"all:\n\t@echo MAKE-RAN-IN-OUTSIDE\n")execSync(`git init -q && git -c user.email=a@b -c user.name=x commit -q --allow-empty -m SECRET-OUTSIDE-COMMIT`,{cwd: outside})exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),tui: {recording: true},project: {git: true,files: {"README.md": "project\n"}},config: {permissions: [{action: "shell",resource: "*",effect: "allow"},{action: "external_directory",resource: "*",effect: "deny"},],}asany,},({ ui, llm })=>Effect.gen(function*(){conststeps: Record<string,{command: string;done: string}>={"STEP1 run git log in the outside dir via cd": {command: `cd ${outside} && git log --oneline`,done: "STEP1-DONE"},"STEP2 run git log in the outside dir via git -C": {command: `git -C ${outside} log --oneline`,done: "STEP2-DONE"},"STEP3 run make in the outside dir via make -C": {command: `make -C ${outside}`,done: "STEP3-DONE"},}letcurrent=""// Simulated model: title requests get a title; a user prompt gets the shell call; a tool result ends the turn.yield*llm.serve((request: any)=>{constmessages=request.body?.messages??[]if(String(messages[0]?.content??"").startsWith("You name conversations"))returnStream.make(Llm.text("Title"))constlast=messages[messages.length-1]if(last?.role==="tool")returnStream.make(Llm.text(steps[current].done))conststep=Object.keys(steps).find((k)=>JSON.stringify(last?.content??"").includes(k))!current=stepreturnStream.make(Llm.toolCall({index: 0,id: `call_${step.slice(0,5)}`,name: "shell",input: {command: steps[step].command}}),Llm.finish("tool-calls"),)})letn=0for(const[prompt,step]ofObject.entries(steps)){n++yield*ui.submit(prompt)yield*ui.waitFor(step.done,{timeout: 8000}).pipe(Effect.catchTag("UiWaitTimeoutError",()=>Effect.void))yield*Effect.sleep(1500)yield*ui.screenshot(`step-${n}`)}}),)
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.
Activity
opencode-agent commentedon Oct 27, 2026
Thanks for the detailed report. I can reproduce this on 2.1.4, and it still happens on the latest
dev(8697644).I used your config (
shellallowed for*,external_directorydenied for*) and a simulated model that makes three shell calls against a git repo outside the project:cd <outside> && git log --oneline→Permission denied: external_directory(as expected)git -C <outside> log --oneline→ runs without a prompt and prints the outside repo's commitmake -C <outside>→ runs the outside Makefile without a promptThe recording and screenshots below show this, and the script below reproduces it. As you found, the shell scanner only reports directories for
cd/pushd-style commands, so directories passed as flags never reach theexternal_directorycheck.Related: #56288 (file-path arguments and redirects in shell also skip
external_directory) and its open PR #56390.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.