From 5c9612bcb45bf3ec3d52072478ff0880f1bb8609 Mon Sep 17 00:00:00 2001 From: Yehia Ezzat <74499810+0xSemizzz@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:39:06 +0300 Subject: [PATCH] fix(shell): close external_directory gaps in the command path scan `external_directory` is documented as firing whenever a tool touches paths outside the project directory, and the file tools honour that for every call. The shell tool only honoured it for a small allowlist of command names, and dropped any argument containing a `$`. Two gaps followed: tee ~/.ssh/authorized_keys -> `tee` was not scanned, so no prompt rm $HOME/.ssh/id_rsa -> `rm` is scanned, but `dynamic()` discarded the argument before it could be resolved Add the common file readers and writers that take their target as a plain positional argument, and expand `$HOME`/`$PWD` for POSIX shells the way `expand()` already does for PowerShell. Anything still unresolvable after expansion continues to fall through `dynamic()` untouched. Redirection targets (`echo x > /etc/foo`) are still not scanned; `parts()` skips redirection nodes. Left for a follow-up. --- packages/opencode/src/tool/shell.ts | 36 +++++++++++++++- packages/opencode/test/tool/shell.test.ts | 50 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..0abff18688af 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -36,6 +36,29 @@ const FILES = new Set([ "chmod", "chown", "cat", + // Writers that take their destination as a plain positional argument. Without these + // `tee ~/.ssh/authorized_keys` reaches the shell with no external_directory prompt, + // even though the documented behaviour is that the permission fires whenever a tool + // touches paths outside the project directory. + "dd", + "install", + "ln", + "rsync", + "shred", + "split", + "tee", + "truncate", + "unlink", + // Readers. These match how the read tool already prompts for out-of-project files. + "cmp", + "diff", + "head", + "less", + "more", + "sed", + "sort", + "tail", + "wc", // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir // already hit the entries above, and alias normalization should happen in one // place later so we do not risk double-prompting. @@ -159,6 +182,17 @@ function expand(text: string, cwd: string, shell: string) { return home(out) } +// POSIX counterpart to `expand`. Without it `$HOME`/`$PWD` survive into `dynamic()`, +// which drops the argument, so `rm $HOME/.ssh/id_rsa` skipped the external_directory +// scan even though `rm` is scanned. Only the two variables we can resolve without +// running the shell are substituted; anything else still falls through to `dynamic()`. +function posixExpand(text: string, cwd: string) { + const out = unquote(text).replace(/\$\{?(HOME|PWD)\}?(?=$|[\\/])/g, (_, key: string) => + key === "HOME" ? os.homedir() : cwd, + ) + return home(out) +} + function provider(text: string) { const match = text.match(/^([A-Za-z]+)::(.*)$/) if (match) { @@ -367,7 +401,7 @@ export const ShellTool = Tool.define( }) const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { - const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) + const text = ps ? expand(arg, cwd, shell) : posixExpand(arg, cwd) const file = text && prefix(text) if (!file || dynamic(file, ps)) return const next = ps ? provider(file) : file diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index a970f85d468f..45bcbaa2f279 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -263,6 +263,56 @@ describe("tool.shell permissions", () => { }), ) + if (process.platform !== "win32") { + it.live("asks for external_directory permission for tee targets", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( + { + command: "echo hi | tee /etc/opencode-external-test", + }, + capture(requests, err), + ), + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain("/etc/*") + }), + ) + }), + ) + + it.live("asks for external_directory permission for $HOME paths", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( + { + command: "rm $HOME/.ssh/opencode-external-test", + }, + capture(requests, err), + ), + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(path.join(os.homedir(), ".ssh", "*")) + }), + ) + }), + ) + } + for (const item of ps) { it.live(`parses PowerShell conditionals for permission prompts [${item.label}]`, () => withShell(