fix(core): check redirects, PowerShell method calls, and dynamic directory changes (#60376)

This commit is contained in:
Kit Langton
2026-10-29 15:29:19 -07:00
committed by GitHub
Unverified
parent 067b741783
commit dc0bfc5ea4
18 changed files with 788 additions and 250 deletions
+1 -1
View File
@@ -344,7 +344,7 @@ const scanShell = Effect.fnUntraced(function* (command: string) {
.resolve({ path: location.directory, kind: "directory" })
.pipe(Effect.mapError((error) => error.message))
const shell = yield* shellSelect.resolve({ priority: "compat" })
const parsed = yield* ShellParse.scanPortable(command, shell, target.absolute).pipe(
const parsed = yield* ShellParse.scan(command, shell, target.absolute).pipe(
Effect.mapError((error) => error.message),
)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
+245 -51
View File
@@ -1,16 +1,19 @@
export * as ShellParse from "./parse.js"
import { Effect } from "effect"
import { Effect, Result } from "effect"
import fs from "fs"
import os from "os"
import path from "path"
import { ShellScan } from "./scan.js"
import { ShellSelect } from "./select.js"
import { Wildcard } from "../util/wildcard.js"
type Part = { type: string; text: string; unquotedTilde?: boolean }
type Part = { type: string; text: string; raw: string; unquotedTilde?: boolean }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAG_RE =
/^-(?:p(?:ath|at|a)?|lp|l(?:i(?:t(?:e(?:r(?:a(?:l(?:p(?:a(?:t(?:h)?)?)?)?)?)?)?)?)?)?|psp(?:a(?:t(?:h)?)?)?):?$/i
const POWERSHELL_LITERAL_FLAG_RE = /^-(?:lp|l(?:i(?:t(?:e(?:r(?:a(?:l(?:p(?:a(?:t(?:h)?)?)?)?)?)?)?)?)?)?|psp(?:a(?:t(?:h)?)?)?):?$/i
const POWERSHELL_STACK_FLAG_RE = /^-(?:stack(?:name|nam|na|n)?|st):?$/i
export type Result = {
commands: Array<{ resource: string; save: string }>
@@ -172,6 +175,13 @@ export function scan(command: string, shell: string, cwd: string): Effect.Effect
for (const item of scanned.commands) {
// Declarations are skipped as standalone permission resources, while nested substitutions remain.
if (item.declaration) continue
if (item.redirectPrefixEnd !== undefined) {
const head = item.resource.slice(0, item.redirectPrefixEnd)
const next = item.resource[item.redirectPrefixEnd]
const separator = next === "\t" || next === "\n" ? next : " "
output.commands.push({ resource: item.resource, save: `${head}${separator}*` })
continue
}
const words = item.redirectWordCount === undefined ? item.rawWords : item.rawWords.slice(0, item.redirectWordCount)
// Bare statement-head foreach blocks act as PowerShell control flow rather than cmdlet invocations.
if (
@@ -186,29 +196,33 @@ export function scan(command: string, shell: string, cwd: string): Effect.Effect
? words.flatMap((raw, index): Part[] => {
const text =
!raw.includes("`$") && /['"`]/.test(raw) ? (item.words[index] ?? raw) : raw.replaceAll("`$", "\0")
if (!/^-[A-Za-z_?][\w?-]*(?::|$)/.test(raw)) return [{ type: "word", text }]
if (!/^-[A-Za-z_?][\w?-]*(?::|$)/.test(raw)) return [{ type: "word", text, raw }]
const colon = text.indexOf(":")
if (colon >= 0)
if (colon >= 0) {
const rawColon = raw.indexOf(":")
return colon + 1 < text.length
? [
{ type: "command_parameter", text: text.slice(0, colon) },
{ type: "word", text: text.slice(colon + 1) },
{ type: "command_parameter", text: text.slice(0, colon), raw: raw.slice(0, rawColon) },
{ type: "word", text: text.slice(colon + 1), raw: rawColon >= 0 ? raw.slice(rawColon + 1) : raw },
]
: [{ type: "command_parameter", text: text.slice(0, colon) }]
return [{ type: "command_parameter", text }]
: [{ type: "command_parameter", text: text.slice(0, colon), raw }]
}
return [{ type: "command_parameter", text, raw }]
})
: item.rawWords.map((raw, index): Part => {
const word = item.words[index] ?? raw
return {
type: "word",
text: isStaticBashWord(raw) ? word.replaceAll("$", "\0") : "$",
raw,
unquotedTilde: raw.startsWith("~"),
}
})
const directoryCommand = unwrapDirectoryCommand(words, parts, powershell)
const directoryCommand = unwrapDirectoryCommand(powershell ? words : item.rawWords, parts, powershell)
if (directoryCommand) {
output.directories.push(...directoryArgs(directoryCommand, powershell, cwd, shell))
if (directoryCommand === parts) continue
const resolved = directoryArgs(directoryCommand, powershell, cwd, shell)
output.directories.push(...resolved.directories)
if (directoryCommand === parts && resolved.complete) continue
}
const selected = prefix(words.slice(0, PREFIX_LENGTH))
const conventional = `${selected.join(" ")} *`
@@ -217,14 +231,16 @@ export function scan(command: string, shell: string, cwd: string): Effect.Effect
const save =
!powershell || end === undefined || Wildcard.match(item.resource, conventional)
? conventional
: (() => {
const boundary =
item.wordEnds?.find(
(value) => value >= end && (value >= item.resource.length || /\s/.test(item.resource[value])),
) ?? end
const separator = /^\s+(?:`(?:\r\n|\r|\n)\s*)?/.exec(item.resource.slice(boundary))?.[0]
return `${item.resource.slice(0, boundary)}${separator ?? " "}*`
})()
: item.resource[end] === "("
? `${item.resource.slice(0, end)}(*`
: (() => {
const boundary =
item.wordEnds?.find(
(value) => value >= end && (value >= item.resource.length || /\s/.test(item.resource[value])),
) ?? end
const separator = /^\s+(?:`(?:\r\n|\r|\n)\s*)?/.exec(item.resource.slice(boundary))?.[0]
return `${item.resource.slice(0, boundary)}${separator ?? " "}*`
})()
output.commands.push({
resource: item.resource,
save,
@@ -234,8 +250,6 @@ export function scan(command: string, shell: string, cwd: string): Effect.Effect
})
}
export const scanPortable = scan
function unwrapDirectoryCommand(rawWords: string[], parts: Part[], powershell: boolean) {
const head = powershell ? rawWords[0]?.toLowerCase() : rawWords[0]
if (CWD.has(head ?? "")) return parts
@@ -256,24 +270,58 @@ function unwrapDirectoryCommand(rawWords: string[], parts: Part[], powershell: b
function isStaticBashWord(raw: string) {
let quote: "'" | '"' | "$'" | undefined
let segmentStarted = false
let segmentLeadingDot = false
let segmentGlob = false
let hasAnsiEscape = false
let hasUnquotedGlob = false
const braces: boolean[] = []
const recordChar = (char: string, unquotedGlob = false) => {
if (unquotedGlob) hasUnquotedGlob = true
if (hasAnsiEscape && hasUnquotedGlob) return false
if (char === "/") {
segmentStarted = false
segmentLeadingDot = false
segmentGlob = false
return true
}
if (!segmentStarted) {
segmentStarted = true
segmentLeadingDot = char === "."
}
if (unquotedGlob) segmentGlob = true
return !(segmentLeadingDot && segmentGlob)
}
for (let index = 0; index < raw.length; index++) {
const char = raw[index]
const next = raw[index + 1] ?? ""
if (quote === "'") {
if (char === "'") quote = undefined
if (char === "'") {
quote = undefined
continue
}
if (!recordChar(char)) return false
continue
}
if (quote === "$'") {
if (char === "\\") {
hasAnsiEscape = true
if (hasUnquotedGlob) return false
index++
continue
}
if (char === "'") quote = undefined
if (char === "'") {
quote = undefined
continue
}
if (!recordChar(char)) return false
continue
}
if (quote === '"') {
if (char === "\\" && '$`"\\\n'.includes(next)) {
index++
if (next !== "\n" && !recordChar(next)) return false
continue
}
if (char === '"') {
@@ -282,10 +330,12 @@ function isStaticBashWord(raw: string) {
}
if (char === "`" || (char === "$" && (/[A-Za-z_0-9@*#?$!({[-]/.test(next) || raw.startsWith("\\\n", index + 1))))
return false
if (!recordChar(char)) return false
continue
}
if (char === "\\") {
index++
if (next !== "\n" && !recordChar(next)) return false
continue
}
if (char === "'") {
@@ -312,53 +362,165 @@ function isStaticBashWord(raw: string) {
(char === "$" && (/[A-Za-z_0-9@*#?$!({[-]/.test(next) || raw.startsWith("\\\n", index + 1)))
)
return false
if (char === "{") {
braces.push(false)
if (!recordChar(char)) return false
continue
}
if (braces.length > 0 && (char === "," || (char === "." && next === "."))) {
braces[braces.length - 1] = true
}
if (char === "}" && braces.length > 0) {
if (braces.pop()) return false
if (!recordChar(char)) return false
continue
}
if (!recordChar(char, char === "*" || char === "?" || char === "[")) return false
}
return quote === undefined
}
function hasPowerShellDotWildcard(raw: string) {
let quote: "'" | '"' | undefined
let segmentStarted = false
let segmentLeadingDot = false
let segmentGlob = false
let hasQuoteEscape = false
let hasUnquotedGlob = false
const recordChar = (char: string, unquotedGlob = false) => {
if (unquotedGlob) hasUnquotedGlob = true
if (hasQuoteEscape && hasUnquotedGlob) return true
if (char === "/" || char === "\\") {
segmentStarted = false
segmentLeadingDot = false
segmentGlob = false
return false
}
if (!segmentStarted) {
segmentStarted = true
segmentLeadingDot = char === "."
}
if (unquotedGlob) segmentGlob = true
return segmentLeadingDot && segmentGlob
}
for (let index = 0; index < raw.length; index++) {
const char = raw[index]
const next = raw[index + 1] ?? ""
if (quote === "'") {
if (char === "'" && next === "'") {
index++
if (recordChar("'")) return true
continue
}
if (char === "'") {
quote = undefined
continue
}
if (recordChar(char)) return true
continue
}
if (quote === '"') {
if (char === "`") {
hasQuoteEscape = true
if (hasUnquotedGlob) return true
index++
continue
}
if (char === '"' && next === '"') {
index++
if (recordChar('"')) return true
continue
}
if (char === '"') {
quote = undefined
continue
}
if (recordChar(char)) return true
continue
}
if (char === "`") {
index++
if (recordChar(next)) return true
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (recordChar(char, char === "*" || char === "?" || char === "[")) return true
}
return false
}
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
const head = command[0]?.text.toLowerCase() ?? ""
if (head === "popd" || head === "pop-location") return { directories: [], complete: false }
if (!powershell) {
let endOfOptions = false
return command
.slice(1)
.filter((part) => {
if (endOfOptions) return true
if (part.text === "--") {
endOfOptions = true
return false
}
return !part.text.startsWith("-")
})
.map((part) => directoryArgument(part.text, powershell, cwd, shell, part.unquotedTilde))
.filter((part) => part !== undefined)
const operands = command.slice(1).filter((part) => {
if (endOfOptions) return true
if (part.text === "--") {
endOfOptions = true
return false
}
if (part.text === "-") return true
return !part.text.startsWith("-")
})
if (operands.length === 0) return { directories: [], complete: false }
const resolved = operands.map((part) =>
part.text === "-" ? undefined : directoryArgument(part.text, false, cwd, shell, part.unquotedTilde),
)
const directories = resolved.filter((item): item is string => item !== undefined)
return { directories, complete: directories.length === operands.length }
}
const directories: string[] = []
let path = false
let pathFlag = false
let literalFlag = false
let endOfParameters = false
let sawOperand = false
let complete = true
for (const part of command.slice(1)) {
if (!endOfParameters && part.text === "--") {
endOfParameters = true
continue
}
if (path) {
const value = directoryArgument(part.text, powershell, cwd, shell)
if (pathFlag) {
sawOperand = true
const value =
!literalFlag && (hasPowerShellDotWildcard(part.raw) || part.text === "-" || part.text === "+")
? undefined
: directoryArgument(part.text, true, cwd, shell)
if (value) directories.push(value)
path = false
if (!value) complete = false
pathFlag = false
literalFlag = false
continue
}
if (!endOfParameters && part.type === "command_parameter") {
path = POWERSHELL_PATH_FLAG_RE.test(part.text)
if (POWERSHELL_STACK_FLAG_RE.test(part.text)) {
complete = false
return { directories, complete }
}
pathFlag = POWERSHELL_PATH_FLAG_RE.test(part.text)
literalFlag = POWERSHELL_LITERAL_FLAG_RE.test(part.text)
continue
}
const value = directoryArgument(part.text, powershell, cwd, shell)
sawOperand = true
const value =
hasPowerShellDotWildcard(part.raw) || (!endOfParameters && (part.raw === "-" || part.raw === "+"))
? undefined
: directoryArgument(part.text, true, cwd, shell)
if (value) directories.push(value)
if (!value) complete = false
}
return directories
return { directories, complete: sawOperand && complete }
}
function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string, unquotedTilde = false) {
if (!powershell) return expandKnownDirectory(value, false, unquotedTilde)
if (!powershell) return expandKnownDirectory(value, false, cwd, unquotedTilde)
const quote = value[0]
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
@@ -375,10 +537,11 @@ function directoryArgument(value: string, powershell: boolean, cwd: string, shel
return path.dirname(shell)
}),
true,
cwd,
)
}
function expandKnownDirectory(value: string, powershell: boolean, unquotedTilde = false) {
function expandKnownDirectory(value: string, powershell: boolean, cwd: string, unquotedTilde = false) {
// Unknown shell expressions cannot be resolved safely during permission analysis.
if (value.includes("$") || value.includes("`") || (powershell && value.startsWith("("))) return
const resolved = value.replaceAll("\0", "$")
@@ -387,21 +550,52 @@ function expandKnownDirectory(value: string, powershell: boolean, unquotedTilde
return path.join(os.homedir(), resolved.slice(2))
}
if (unquotedTilde && process.platform !== "win32") {
const userMatch = /^~([A-Za-z0-9._-]+)(?=$|\/)/.exec(resolved)
if (resolved === "~+") return cwd
if (resolved.startsWith("~+/")) return path.join(cwd, resolved.slice(3))
if (/^~(?:[-+](?:\d+)?|\d+)(?=$|\/)/.test(resolved)) return
const userMatch = /^~([A-Za-z_][A-Za-z0-9._-]*)(?=$|\/)/.exec(resolved)
if (userMatch) {
const user = userMatch[1]
const home =
user === "root"
? process.platform === "darwin"
? "/var/root"
: "/root"
: path.join(process.platform === "darwin" ? "/Users" : "/home", user)
const home = resolveUserHome(user)
if (!home) return
return path.join(home, resolved.slice(user.length + 1))
}
}
return resolved
}
let cachedPasswdHomes: ReadonlyMap<string, string> | undefined
function passwdHomes() {
if (cachedPasswdHomes) return cachedPasswdHomes
const content = Result.try(() => fs.readFileSync("/etc/passwd", "utf8"))
const entries = Result.isSuccess(content)
? content.success.split("\n").flatMap((line): Array<[string, string]> => {
if (!line || line.startsWith("#")) return []
const fields = line.split(":")
const name = fields[0]
const home = fields[5]
return name && home ? [[name, home]] : []
})
: []
cachedPasswdHomes = new Map(entries)
return cachedPasswdHomes
}
function resolveUserHome(user: string) {
const current = Result.try(() => os.userInfo())
if (Result.isSuccess(current) && current.success.username === user && current.success.homedir) {
return current.success.homedir
}
const fromPasswd = passwdHomes().get(user)
if (fromPasswd) return fromPasswd
if (process.platform === "darwin") {
const candidate = path.join("/Users", user)
const exists = Result.try(() => fs.statSync(candidate).isDirectory())
if (Result.isSuccess(exists) && exists.success) return candidate
}
}
function environment(key: string) {
if (process.platform !== "win32") return process.env[key]
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
+154 -46
View File
@@ -19,8 +19,10 @@ type Command = {
wordEnds?: number[]
statementHead?: true
declaration?: true
// Words after a trailing redirect are destinations in the legacy command span.
// Words before the first redirect when redirects appear between arguments.
redirectWordCount?: number
// Exclusive prefix end relative to resource for commandless or group redirects.
redirectPrefixEnd?: number
}
// Opaque describes a parsing limitation, not a permission decision.
@@ -185,9 +187,8 @@ type BashState = {
wordStart: number
wordEnd: number
commandStart: number | undefined
commandEnd: number
resourceEnd: number | undefined
redirectWordCount: number | undefined
redirectPrefixEnd: number | undefined
// Posix shells such as Dash read `&>` as `&` and `>`, so later words would start another command there.
ampersandRedirectWords: number | undefined
commandWordIndex: number
@@ -195,8 +196,10 @@ type BashState = {
assignmentHeadUnsafe: boolean
invalid: OpaqueReason | undefined
redirectTarget: boolean
dupRedirect: boolean
hereString: boolean
hasRedirect: boolean
hasFileRedirect: boolean
compoundEnd: boolean
// A list operator (&&, ||, |, |&) still awaits its right-hand command.
dangling: boolean
@@ -286,10 +289,14 @@ function finishBashWord(state: BashState) {
state.words.push(state.word)
// Unquoted trailing continuations are ignored syntax, not part of the raw token.
state.rawWords.push(state.input.slice(state.wordStart, state.wordEnd))
state.commandEnd = state.wordEnd
}
if (state.hereString && state.resourceEnd === undefined) state.commandEnd = state.wordEnd
if (state.redirectTarget) {
if (!state.dupRedirect || !/^(?:\d+-?|-)$/.test(state.input.slice(state.wordStart, state.wordEnd)))
state.hasFileRedirect = true
state.redirectPrefixEnd ??= state.wordEnd
}
state.redirectTarget = false
state.dupRedirect = false
state.hereString = false
state.word = ""
state.literal = ""
@@ -369,14 +376,15 @@ function finishBashCommand(state: BashState, boundary = false, newline = false)
const inHeader = bashInHeader(state)
if (name >= 0 && !state.words[name]) state.invalid ??= "invalid-structure"
if (name >= 0 && !inHeader) {
const resource = state.input
.slice(state.commandStart, state.resourceEnd ?? state.wordEnd)
.replace(/^[ \t\n]+|[ \t\n]+$/g, "")
const resource = state.input.slice(state.commandStart, state.wordEnd).replace(/^[ \t\n]+|[ \t\n]+$/g, "")
const command: Command = {
resource,
words: state.words.slice(name),
rawWords: state.rawWords.slice(name),
...(name === 0 && BASH_DECLARATIONS.has(state.rawWords[0]) && resource.startsWith(state.rawWords[0])
...(name === 0 &&
!state.hasRedirect &&
BASH_DECLARATIONS.has(state.rawWords[0]) &&
resource.startsWith(state.rawWords[0])
? { declaration: true as const }
: {}),
...(state.redirectWordCount !== undefined && state.redirectWordCount < state.words.length
@@ -424,11 +432,42 @@ function finishBashCommand(state: BashState, boundary = false, newline = false)
for (const heredoc of state.heredocs) {
if (heredoc.owned) continue
heredoc.owned = true
if (newline && state.resourceEnd === undefined) {
if (newline) {
heredoc.command = command
heredoc.start = state.commandStart
}
}
} else if (
name < 0 &&
!inHeader &&
state.commandStart !== undefined &&
(state.hasFileRedirect || (state.hasRedirect && !state.compoundEnd))
) {
const rawSpan = state.input.slice(state.commandStart, state.wordEnd)
const leading = /^[ \t\n]+/.exec(rawSpan)?.[0].length ?? 0
const resource = rawSpan.slice(leading).replace(/[ \t\n]+$/g, "")
if (resource) {
const prefixEnd =
state.redirectPrefixEnd !== undefined
? Math.max(1, Math.min(resource.length, state.redirectPrefixEnd - state.commandStart - leading))
: resource.length
const prefixToken = resource.slice(0, prefixEnd)
const command: Command = {
resource,
words: [prefixToken],
rawWords: [prefixToken],
redirectPrefixEnd: prefixEnd,
}
state.commands.push(command)
for (const heredoc of state.heredocs) {
if (heredoc.owned) continue
heredoc.owned = true
if (newline) {
heredoc.command = command
heredoc.start = state.commandStart
}
}
}
}
for (const heredoc of state.heredocs) heredoc.owned = true
if (boundary && !state.words.length && !state.hasRedirect && !state.compoundEnd && !inHeader)
@@ -440,9 +479,11 @@ function finishBashCommand(state: BashState, boundary = false, newline = false)
state.rawWords.length = 0
state.commandWordIndex = -1
state.commandStart = undefined
state.dupRedirect = false
state.hasRedirect = false
state.resourceEnd = undefined
state.hasFileRedirect = false
state.redirectWordCount = undefined
state.redirectPrefixEnd = undefined
state.ampersandRedirectWords = undefined
state.compoundEnd = false
state.dangling = false
@@ -475,17 +516,18 @@ function scanBash(
wordStart: start,
wordEnd: start,
commandStart: undefined,
commandEnd: start,
resourceEnd: undefined,
redirectWordCount: undefined,
redirectPrefixEnd: undefined,
ampersandRedirectWords: undefined,
commandWordIndex: -1,
assignmentWord: false,
assignmentHeadUnsafe: false,
invalid: undefined,
redirectTarget: false,
dupRedirect: false,
hereString: false,
hasRedirect: false,
hasFileRedirect: false,
compoundEnd: false,
dangling: false,
outer,
@@ -681,8 +723,6 @@ function scanBashOperatorOrSeparator(
const fdPrefix =
state.wordStarted && !state.assignmentHeadUnsafe && /^(?:\d+|\{[A-Za-z_][A-Za-z0-9_]*\})$/.test(state.word)
if (fdPrefix) {
// A continuation separates the legacy number token from the redirect descriptor.
if (state.wordEnd < index) state.commandEnd = state.wordEnd
state.word = ""
state.literal = ""
state.output = ""
@@ -690,23 +730,24 @@ function scanBashOperatorOrSeparator(
}
if (!fdPrefix) finishBashWord(state)
if (redirect.startsWith("&") && context.dialect === "posix") state.ampersandRedirectWords ??= state.words.length
// Trailing redirects wrap a whole list/pipeline in the legacy grammar, not its last command.
// Prefix redirects remain part of the command, and later words remain redirect destinations.
if (state.redirectWordCount === undefined && state.commandWordIndex >= 0) {
state.redirectWordCount = state.words.length
}
if (state.dangling && redirect !== "<<<" && state.commandWordIndex >= 0) {
state.resourceEnd ??= state.commandEnd
}
if (redirect === "<<" || redirect === "<<-") {
const delimiter = bashHeredocDelimiter(input, index + redirect.length, redirect === "<<-")
if (!delimiter) return { kind: "opaque", reason: "invalid-redirect" }
state.hasFileRedirect = true
state.redirectPrefixEnd ??=
" \t".includes(input[index + redirect.length] ?? "") ? index + redirect.length : delimiter.end + 1
state.heredocs.push(delimiter)
state.wordEnd = delimiter.end + 1
state.redirectTarget = false
state.dupRedirect = false
return delimiter.end
}
if (" \t\n".includes(input[index + redirect.length] ?? "")) state.redirectPrefixEnd ??= index + redirect.length
state.redirectTarget = true
state.dupRedirect = redirect === "<&" || redirect === ">&"
state.hereString = redirect === "<<<"
return index + redirect.length - 1
}
@@ -1838,6 +1879,9 @@ function scanPowerShellList(
let clause: PowerShellClause = initialClause
let after: "do" | "if" | "try" | undefined
let indexable: false | "variable" | "closed" | "member" = false
let memberChainStart: number | undefined
let hasMemberAccess = false
let hasFileRedirect = false
let stopParsing = false
let commandEnd = start
let statementHead = true
@@ -1846,6 +1890,11 @@ function scanPowerShellList(
let dangling = false
let invocation = false
const resetMember = () => {
indexable = false
memberChainStart = undefined
hasMemberAccess = false
}
const finishWord = (end: number) => {
if (!started) return
// Generic tokens can spell stop-parsing with escapes or embedded quotes; literal strings cannot.
@@ -1861,7 +1910,7 @@ function scanPowerShellList(
)
stopParsing = true
if (!redirectTarget) {
if (!words.length && !invocation && !expression) segment = wordStart
if (!words.length && !invocation) segment = wordStart
words.push(word)
rawWords.push(input.slice(wordStart, end))
wordEnds.push(end)
@@ -1874,13 +1923,13 @@ function scanPowerShellList(
}
const finishCommand = (end: number, required = false) => {
finishWord(end)
if (words.length && !expression && !clause)
if (words.length && ((!expression && !clause) || hasFileRedirect))
commands.push({
resource: input.slice(segment, commandEnd).trimEnd(),
words: [...words],
rawWords: [...rawWords],
wordEnds: wordEnds.map((value) => value - segment),
...(statementHead && !invocation ? { statementHead: true as const } : {}),
...(statementHead && !invocation && !expression && !clause ? { statementHead: true as const } : {}),
})
else if (
(!expression && !clause && invocation) ||
@@ -1894,11 +1943,12 @@ function scanPowerShellList(
rawWords.length = 0
wordEnds.length = 0
redirectTarget = false
hasFileRedirect = false
invocation = false
expression = initialExpression
clause = initialClause
after = undefined
indexable = false
resetMember()
stopParsing = false
}
@@ -1960,8 +2010,13 @@ function scanPowerShellList(
if (char === "'" || char === '"') {
if (!started && words.length === 0 && !invocation) expression = true
quote = char
standalone = atBoundary || (expression && !redirectTarget) || (indexable === "member" && /[.:]$/.test(word))
if (!standalone) indexable = false
const dynamicMember = indexable === "member" && /[.:]$/.test(started ? word : (words.at(-1) ?? ""))
standalone = atBoundary || (expression && !redirectTarget) || dynamicMember
if (dynamicMember) hasMemberAccess = true
else if (standalone) {
memberChainStart = index
hasMemberAccess = false
} else resetMember()
started = true
continue
}
@@ -1973,7 +2028,7 @@ function scanPowerShellList(
// At a token boundary escaped whitespace is trivia, not a new argument.
else if (!atBoundary || !rawSpace) {
started = true
indexable = false
if (!(indexable === "member" && /[\p{L}\p{Nl}\d_]/u.test(escape.value))) resetMember()
word += escape.value
}
index = escape.end
@@ -1998,7 +2053,7 @@ function scanPowerShellList(
if (started) finishWord(index)
const end = input.indexOf("#>", index + 2)
if (end < 0) return { kind: "opaque", reason: "invalid-structure" }
if (!memberOperator) indexable = false
if (!memberOperator) resetMember()
index = end + 1
continue
}
@@ -2035,7 +2090,8 @@ function scanPowerShellList(
if (redirectTarget || words.length === 0 || (clause && clause !== "member"))
return { kind: "opaque", reason: "invalid-redirect" }
redirectTarget = !redirect.includes("&")
indexable = false
if (redirectTarget) hasFileRedirect = true
resetMember()
index += redirect.length - 1
commandEnd = index + 1
continue
@@ -2104,7 +2160,8 @@ function scanPowerShellList(
rawWords.length = 0
wordEnds.length = 0
expression = false
indexable = false
hasFileRedirect = false
resetMember()
if (!assign || char !== "=") index++
continue
}
@@ -2121,6 +2178,8 @@ function scanPowerShellList(
started = true
index = literal.end
finishWord(index + 1)
memberChainStart = wordStart
hasMemberAccess = false
indexable = "closed"
continue
}
@@ -2128,7 +2187,15 @@ function scanPowerShellList(
const end = powerShellBracedVariable(input, index, skipped)
if (end === undefined) return { kind: "opaque", reason: "invalid-structure" }
if (!started && !words.length && !invocation && hash !== "switch") expression = true
indexable = atBoundary || (indexable === "member" && /[.:]$/.test(word)) ? "closed" : false
const dynamicMember = indexable === "member" && /[.:]$/.test(started ? word : (words.at(-1) ?? ""))
if (dynamicMember) {
indexable = "closed"
hasMemberAccess = true
} else if (atBoundary) {
memberChainStart = index
hasMemberAccess = false
indexable = "closed"
} else resetMember()
word += input.slice(index, end + 1)
started = true
index = end
@@ -2149,7 +2216,17 @@ function scanPowerShellList(
? index
: undefined
if (opener !== undefined) {
const tokenIndex = index
const openChar = input[opener]
const prevMemberWord = started ? word : (words.at(-1) ?? "")
const dynamicMemberOpener = indexable === "member" && /[.:]$/.test(prevMemberWord)
const priorIndexable = Boolean(indexable)
const isMethodCall =
char === "(" &&
opener === tokenIndex &&
memberChainStart !== undefined &&
((indexable === "member" && started && !/[.:]$/.test(word)) ||
(indexable === "closed" && !started && hasMemberAccess))
const standaloneBlock = atBoundary || inExpression || char === "(" || openChar === "{"
const blockClose = openChar === "(" ? ")" : openChar === "[" ? "]" : "}"
const blockHash =
@@ -2179,12 +2256,28 @@ function scanPowerShellList(
started = true
word += input.slice(index, result.end + 1)
index = result.end
if (openChar === "[") {
if (isMethodCall && memberChainStart !== undefined) {
finishWord(index + 1)
const callResource = input.slice(memberChainStart, result.end + 1)
const head = input.slice(memberChainStart, opener)
const args = input.slice(opener, result.end + 1)
nestedCommands.push({
resource: callResource,
words: [head, args],
rawWords: [head, args],
wordEnds: [head.length, callResource.length],
})
indexable = "closed"
hasMemberAccess = false
} else if (openChar === "[") {
indexable = "closed"
if (!priorIndexable) memberChainStart = tokenIndex
if (input[index + 1] !== "[") finishWord(index + 1)
} else if (standaloneBlock) {
finishWord(index + 1)
indexable = "closed"
if (!dynamicMemberOpener) memberChainStart = tokenIndex
hasMemberAccess = dynamicMemberOpener
}
if ((clause === "paren" && char === "(") || (clause && clause !== "paren" && char === "{")) {
const completed = clause === "do" || clause === "if" || clause === "try" ? clause : undefined
@@ -2210,8 +2303,9 @@ function scanPowerShellList(
continue
}
if (/\s/.test(char) && char !== "\n" && char !== "\r") {
const memberOperator = indexable === "member" && /[.:]$/.test(started ? word : (words.at(-1) ?? ""))
finishWord(index)
indexable = false
if (!memberOperator) resetMember()
continue
}
const next = input[index + 1]
@@ -2235,12 +2329,12 @@ function scanPowerShellList(
if ((clause && clause !== "member") || prev.endsWith(",") || memberOperator) {
finishWord(index)
index += separator.length - 1
if (!memberOperator) indexable = false
if (!memberOperator) resetMember()
continue
}
if (!started && !words.length && !invocation) {
index += separator.length - 1
indexable = false
resetMember()
continue
}
}
@@ -2252,39 +2346,53 @@ function scanPowerShellList(
}
if (!started && !words.length && !invocation && hash !== "switch" && testAt(POWERSHELL_EXPRESSION_RE, input, index))
expression = true
if ((atBoundary || (indexable === "member" && /[.:]$/.test(word))) && char === "$")
indexable = testAt(POWERSHELL_INDEXABLE_VAR_RE, input, index + 1) ? "variable" : false
else if (indexable === "closed")
const prevMember = started ? word : (words.at(-1) ?? "")
if ((atBoundary || (indexable === "member" && /[.:]$/.test(prevMember))) && char === "$") {
if (testAt(POWERSHELL_INDEXABLE_VAR_RE, input, index + 1)) {
if (indexable === "member" && /[.:]$/.test(prevMember)) hasMemberAccess = true
else {
memberChainStart = index
hasMemberAccess = false
}
indexable = "variable"
} else resetMember()
} else if (indexable === "closed") {
indexable =
(char === "." && input[index + 1] !== "." && !/\s/.test(input[index + 1] ?? "")) ||
(!started && char === ":" && input[index + 1] === ":" && !/\s/.test(input[index + 2] ?? "")) ||
(char === "." && input[index + 1] !== "." && (!/\s/.test(input[index + 1] ?? "") || inExpression)) ||
(!started && char === ":" && input[index + 1] === ":") ||
(!started &&
char === "?" &&
input[index + 1] === "." &&
input[index + 2] !== "." &&
!/\s/.test(input[index + 2] ?? ""))
(!/\s/.test(input[index + 2] ?? "") || inExpression))
? "member"
: !started && char === "?" && input[index + 1] === "["
? "closed"
: false
else if (indexable === "variable")
if (indexable === "member") hasMemberAccess = true
else if (!indexable) resetMember()
} else if (indexable === "variable") {
indexable =
(char === "." && input[index + 1] !== "." && !/\s/.test(input[index + 1] ?? "")) ||
(char === ":" && input[index + 1] === ":" && !/\s/.test(input[index + 2] ?? ""))
(char === "." && input[index + 1] !== "." && (!/\s/.test(input[index + 1] ?? "") || inExpression)) ||
(char === ":" && input[index + 1] === ":")
? "member"
: (char === "." && input[index + 1] === ".") || !/[\p{L}\p{Nl}\d_:?]/u.test(char)
? false
: "variable"
else if (indexable === "member")
if (indexable === "member") hasMemberAccess = true
else if (!indexable) resetMember()
} else if (indexable === "member") {
indexable =
char === "." && input[index + 1] === "."
? false
: (char === "." && !/\s/.test(input[index + 1] ?? "")) ||
: (char === "." && (!/\s/.test(input[index + 1] ?? "") || inExpression)) ||
(char === ":" && (input[index + 1] === ":" || input[index - 1] === ":")) ||
(char === "?" && input[index + 1] === "." && input[index + 2] !== ".") ||
/[\p{L}\p{Nl}\d_]/u.test(char)
? "member"
: false
if (!indexable) resetMember()
}
after = undefined
started = true
dangling = false
+65 -15
View File
@@ -521,9 +521,9 @@ describe("shell scanner permission impact", () => {
command: "printf ok && git status > output",
approved: ["printf *", "git status *"],
exact: ["printf ok", "git status"],
denied: "git status",
denied: "git status *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
exactEffect: ["allow", "ask"],
deniedEffect: ["deny", "deny"],
},
{
@@ -532,9 +532,9 @@ describe("shell scanner permission impact", () => {
command: "printf ok | cat < input > output",
approved: ["printf *", "cat *"],
exact: ["printf ok", "cat"],
denied: "cat",
denied: "cat *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
exactEffect: ["allow", "ask"],
deniedEffect: ["deny", "deny"],
},
{
@@ -543,10 +543,10 @@ describe("shell scanner permission impact", () => {
command: "FOO=bar > output; printf done",
approved: ["printf *"],
exact: ["printf done"],
denied: "FOO=bar > output; printf done",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
denied: "FOO=bar > output",
savedEffect: ["ask", "ask"],
exactEffect: ["ask", "ask"],
deniedEffect: ["deny", "deny"],
},
{
name: "assignment redirect with an approved command substitution",
@@ -555,9 +555,9 @@ describe("shell scanner permission impact", () => {
approved: ["git describe *"],
exact: ["git describe --tags"],
denied: "VERSION=$(git describe --tags) > build/version.txt",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
savedEffect: ["ask", "ask"],
exactEffect: ["ask", "ask"],
deniedEffect: ["deny", "deny"],
},
{
name: "substitution in a saved prefix",
@@ -603,6 +603,39 @@ describe("shell scanner permission impact", () => {
exactEffect: ["allow", "ask"],
deniedEffect: ["deny", "allow"],
},
{
name: "trailing redirect after list operator",
shell: "bash",
command: "true && cat > secret",
approved: ["true *", "cat *"],
exact: ["true", "cat"],
denied: "* > secret*",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "ask"],
deniedEffect: ["allow", "deny"],
},
{
name: "commandless redirect write",
shell: "zsh",
command: ">> ~/.zshrc <<< 'x'",
approved: [">> *"],
exact: [">> ~/.zshrc <<< 'x'"],
denied: ">> *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "PowerShell .NET method invocation",
shell: "pwsh",
command: '[IO.File]::Delete("C:\\victim")',
approved: ["[IO.File]::Delete(*"],
exact: ['[IO.File]::Delete("C:\\victim")'],
denied: "*Delete(*",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
] as const) {
for (const scenario of [
{ name: "no approval", saved: [], rules: [], expected: ["ask", "ask"] },
@@ -718,14 +751,31 @@ describe("shell scanner permission impact", () => {
name: "assignment redirect prefix",
shell: "bash",
command: "FOO=bar > output; printf done",
grants: [["printf *"], ["printf *"]],
// Identical saved rules cover only the native resource, regardless of which parser saved them.
grants: [
["printf *"],
["FOO=bar > *", "printf *"],
],
repeat: [
["ask", "allow"],
["ask", "allow"],
],
next: "printf next",
nextEffect: ["allow", "allow"],
next: "FOO=bar rm -rf /",
nextEffect: ["ask", "ask"],
},
{
name: "PowerShell method prefix",
shell: "pwsh",
command: '[IO.File]::Delete("C:\\first")',
grants: [
["[IO.File]::Delete(*"],
["[IO.File]::Delete(*"],
],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: '[IO.File]::WriteAllText("C:\\first", "x")',
nextEffect: ["ask", "ask"],
},
{
name: "PowerShell tab prefix",
@@ -45,14 +45,37 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
"bash",
"cd $HOME; pwd",
{ HOME: "/session-home" },
{ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] },
{
commands: [
{ resource: "cd $HOME", save: "cd *" },
{ resource: "pwd", save: "pwd *" },
],
directories: [],
},
],
[
"bash",
"cd; pwd",
{ HOME: "/session-home" },
{
commands: [
{ resource: "cd", save: "cd *" },
{ resource: "pwd", save: "pwd *" },
],
directories: [],
},
],
["bash", "cd; pwd", { HOME: "/session-home" }, { commands: [{ resource: "pwd", save: "pwd *" }], directories: [] }],
[
"bash",
'target=/outside; cd "$target"; pwd',
{},
{ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] },
{
commands: [
{ resource: 'cd "$target"', save: "cd *" },
{ resource: "pwd", save: "pwd *" },
],
directories: [],
},
],
[
"bash",
@@ -81,7 +104,18 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
{},
{ commands: [{ resource: "FOO=bar git status", save: "git status *" }], directories: [] },
],
["bash", "HOME=/outside; cd; pwd", {}, { commands: [{ resource: "pwd", save: "pwd *" }], directories: [] }],
[
"bash",
"HOME=/outside; cd; pwd",
{},
{
commands: [
{ resource: "cd", save: "cd *" },
{ resource: "pwd", save: "pwd *" },
],
directories: [],
},
],
["bash", "CDPATH=/outside cd child", {}, { commands: [], directories: ["child"] }],
[
"bash",
@@ -103,7 +137,7 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
{
commands: [
{ resource: "printf ok", save: "printf *" },
{ resource: "git status", save: "git status *" },
{ resource: "git status > output", save: "git status *" },
],
directories: [],
},
@@ -115,7 +149,7 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
{
commands: [
{ resource: "printf ok", save: "printf *" },
{ resource: "cat", save: "cat *" },
{ resource: "cat < input > output", save: "cat *" },
],
directories: [],
},
@@ -126,7 +160,20 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
{},
{ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["-/../../../etc"] },
],
["bash", "cd -; pushd; popd; pwd", {}, { commands: [{ resource: "pwd", save: "pwd *" }], directories: [] }],
[
"bash",
"cd -; pushd; popd; pwd",
{},
{
commands: [
{ resource: "cd -", save: "cd *" },
{ resource: "pushd", save: "pushd *" },
{ resource: "popd", save: "popd *" },
{ resource: "pwd", save: "pwd *" },
],
directories: [],
},
],
[
"bash",
"command cd /outside; builtin cd /elsewhere",
@@ -171,7 +218,10 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
"pwsh",
"Set-Location $HOME; Set-Location $PWD; Set-Location $target",
{ HOME: "/session-home" },
{ commands: [], directories: [process.platform === "win32" ? os.homedir() : "/session-home", "/workspace"] },
{
commands: [{ resource: "Set-Location $target", save: "Set-Location *" }],
directories: [process.platform === "win32" ? os.homedir() : "/session-home", "/workspace"],
},
],
[
"pwsh",
@@ -209,8 +259,7 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
const command = ${JSON.stringify(command)}
const shell = ${JSON.stringify(shell)}
const scanned = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
console.log(JSON.stringify([scanned, native]))
console.log(JSON.stringify(scanned))
`,
],
cwd: `${import.meta.dir}/..`,
@@ -224,14 +273,11 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
child.exited,
])
expect(code, error).toBe(0)
const [scanned, native] = JSON.parse(output)
expect(scanned).toEqual(expected)
expect(native).toEqual(scanned)
expect(JSON.parse(output)).toEqual(expected)
return
}
const scanned = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
expect(scanned).toEqual(expected)
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(scanned)
},
)
@@ -243,7 +289,7 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
test("extracts inline PowerShell directory flags with case-insensitive names and quoted values", async () => {
const result = await Effect.runPromise(
ShellParse.scanPortable(
ShellParse.scan(
"Set-Location -LITERALPATH:C:\\outside; Set-Location -pAtH:'../other dir'; Set-Location -p:/short; Set-Location -lp /literal; Set-Location -l:/prefix; Set-Location -lit:/litprefix; Set-Location -pat:/patprefix; Set-Location -PSPath:/pspath; Set-Location '-outside'; Set-Location -/../../outside; Set-Location -Path -- -/../../outside2; Set-Location -- -outside2; Set-Location -`outside3; Set-Location \"../`$a/../outside4\"; Set-Location /out`side; Set-Location 'C:\\quoted'",
"pwsh",
"/workspace",
@@ -274,7 +320,7 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
test("retains statement-head ForEach-Object cmdlet calls when non-scriptblock parameters are present", async () => {
const result = await Effect.runPromise(
ShellParse.scanPortable("ForEach-Object -InputObject $proc -MemberName Kill", "pwsh", "/workspace"),
ShellParse.scan("ForEach-Object -InputObject $proc -MemberName Kill", "pwsh", "/workspace"),
)
expect(result).toEqual({
commands: [{ resource: "ForEach-Object -InputObject $proc -MemberName Kill", save: "ForEach-Object *" }],
@@ -286,16 +332,22 @@ const compatibilityCases: Array<[string, string, Record<string, string>, ShellPa
describe("scanner behavior on former tree-sitter gaps", () => {
for (const fixture of [
{
name: "omits synthetic command nodes for an assignment with redirection",
name: "retains redirect resource for an assignment with redirection",
shell: "bash",
command: "FOO=bar > output",
expected: { commands: [], directories: [] },
expected: { commands: [{ resource: "FOO=bar > output", save: "FOO=bar > *" }], directories: [] },
},
{
name: "retains nested executable commands without empty assignment command nodes",
name: "retains both redirect resource and nested executable commands on assignment redirection",
shell: "bash",
command: "FOO=$(printf value) > output",
expected: { commands: [{ resource: "printf value", save: "printf *" }], directories: [] },
expected: {
commands: [
{ resource: "FOO=$(printf value) > output", save: "FOO=$(printf value) > *" },
{ resource: "printf value", save: "printf *" },
],
directories: [],
},
},
{
name: "keeps numeric arguments in saved prefixes",
@@ -357,13 +409,24 @@ describe("scanner behavior on former tree-sitter gaps", () => {
command: "Set-Location a,b",
expected: { commands: [], directories: ["a,b"] },
},
{
name: "retains unresolved PowerShell stack, history, and dot-wildcard directory commands",
shell: "pwsh",
command: "Set-Location -StackName old; Set-Location -; Set-Location +; Set-Location .[.] ; Set-Location -LiteralPath '.[.]'",
expected: {
commands: [
{ resource: "Set-Location -StackName old", save: "Set-Location *" },
{ resource: "Set-Location -", save: "Set-Location *" },
{ resource: "Set-Location +", save: "Set-Location *" },
{ resource: "Set-Location .[.]", save: "Set-Location *" },
],
directories: [".[.]"],
},
},
]) {
test(fixture.name, async () => {
const scanned = await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace"))
expect(scanned).toEqual(fixture.expected)
expect(await Effect.runPromise(ShellParse.scanPortable(fixture.command, fixture.shell, "/workspace"))).toEqual(
fixture.expected,
)
})
}
})
+18 -13
View File
@@ -13,7 +13,6 @@ describe("ShellParse native parity", () => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned.kind, context).toBe("scanned")
const parsed = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace")), context).toEqual(parsed)
for (const item of parsed.commands) {
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(item.resource)) {
expect(Wildcard.match(item.resource, item.save), context).toBe(true)
@@ -50,22 +49,28 @@ describe("ShellParse native parity", () => {
["pwsh", "git status; npm run test; docker compose up"],
["pwsh", 'git "status"; npm "run" test; docker "compose" up'],
["pwsh", "Write-Output done # comment\nGet-ChildItem"],
])("native resources, saved prefixes, and directories match in %s: %s", async (shell, command) => {
])("extracts resources, saved prefixes, and directories in %s: %s", async (shell, command) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned.kind).toBe("scanned")
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
expect(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))).toEqual(native)
await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
})
test.each(["> output", "FOO=bar", "2>> output"])(
"returns an explicit empty result for statements without executable command nodes: %s",
async (command) => {
expect(ShellScan.scan(command)).toEqual({ kind: "scanned", commands: [] })
const native = await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))
expect(native).toEqual({ commands: [], directories: [] })
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace"))).toEqual(native)
},
)
test("returns an explicit empty result for pure variable assignments without redirects", async () => {
expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] })
expect(await Effect.runPromise(ShellParse.scan("FOO=bar", "bash", "/workspace"))).toEqual({
commands: [],
directories: [],
})
})
test.each([
["> output", [{ resource: "> output", save: "> *" }]],
["2>> output", [{ resource: "2>> output", save: "2>> *" }]],
] as const)("retains commandless redirects with matching saved prefixes: %s", async (command, commands) => {
const parsed = await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace"))
expect(parsed).toEqual({ commands: [...commands], directories: [] })
for (const item of parsed.commands) expect(Wildcard.match(item.resource, item.save)).toBe(true)
})
})
describe("ShellParse malformed native syntax", () => {
@@ -65,7 +65,7 @@ describe("native shell syntax compatibility", () => {
"F='$HOME/bin'; SIZE=4096; N=$(( (SIZE + 1023) / 1024 )); echo $N",
])(`${shell} extracts commands without rejecting ordinary syntax: %s`, async (command) => {
const scanned = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(scanned)
expect(scanned.commands.length).toBeGreaterThanOrEqual(0)
})
}
@@ -89,7 +89,7 @@ describe("native shell syntax compatibility", () => {
).toEqual({
commands: [
{ resource: "true", save: "true *" },
{ resource: "cat", save: "cat *" },
{ resource: "cat <<'EOF'", save: "cat *" },
{ resource: "git status", save: "git status *" },
],
directories: [],
@@ -125,7 +125,7 @@ describe("native shell syntax compatibility", () => {
).toEqual({
commands: [
{ resource: "true", save: "true *" },
{ resource: "git add -p file.rs <<< $'n\\ny\\n'", save: "git add *" },
{ resource: "git add -p file.rs <<< $'n\\ny\\n' >/dev/null", save: "git add *" },
],
directories: [],
})
@@ -196,6 +196,6 @@ describe("native shell syntax compatibility", () => {
"git st`atus",
])("PowerShell extracts commands without rejecting ordinary syntax: %s", async (command) => {
const scanned = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, "pwsh", "/workspace"))).toEqual(scanned)
for (const item of scanned.commands) expect(Wildcard.match(item.resource, item.save), item.resource).toBe(true)
})
})
+60 -1
View File
@@ -48,7 +48,7 @@ describe("ShellParse", () => {
for (const command of commands) {
const scanned = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, "/bin/bash", "/workspace"))).toEqual(scanned)
expect(scanned.commands.length).toBeGreaterThan(0)
}
})
@@ -165,9 +165,68 @@ describe("ShellParse", () => {
process.platform === "darwin" ? "/var/root/secret" : "/root/secret",
"~root",
])
const tildeForms = await Effect.runPromise(
ShellParse.scan(
"cd ~+/../outside && cd ~- && cd ~+1 && cd ~0 && cd ~__opencode_nonexistent_user__",
"/bin/bash",
"/workspace",
),
)
expect(tildeForms.directories).toEqual(["/outside"])
expect(tildeForms.commands).toEqual([
{ resource: "cd ~-", save: "cd *" },
{ resource: "cd ~+1", save: "cd *" },
{ resource: "cd ~0", save: "cd *" },
{ resource: "cd ~__opencode_nonexistent_user__", save: "cd *" },
])
const dynamicCd = await Effect.runPromise(
ShellParse.scan("cd {..,x} && cd .[.] && cd .? && cd $'\\x2e'[.] && cd '.[.]'", "/bin/bash", "/workspace"),
)
expect(dynamicCd.directories).toEqual([".[.]"])
expect(dynamicCd.commands).toEqual([
{ resource: "cd {..,x}", save: "cd *" },
{ resource: "cd .[.]", save: "cd *" },
{ resource: "cd .?", save: "cd *" },
{ resource: "cd $'\\x2e'[.]", save: "cd *" },
])
const nobodyHome = await Effect.runPromise(
ShellParse.scan("cd ~nobody", "/bin/bash", "/workspace"),
)
expect(nobodyHome.directories).toHaveLength(1)
expect(nobodyHome.directories[0]).not.toBe(
path.join(process.platform === "darwin" ? "/Users" : "/home", "nobody"),
)
}
})
test("retains commandless redirects, redirected declarations, and PowerShell method calls as permission resources", async () => {
const redirects = await Effect.runPromise(
ShellParse.scan(">> ~/.zshrc <<< 'x'; export -p > ~/.bashrc", "/bin/zsh", "/workspace"),
)
expect(redirects).toEqual({
commands: [
{ resource: ">> ~/.zshrc <<< 'x'", save: ">> *" },
{ resource: "export -p > ~/.bashrc", save: "export *" },
],
directories: [],
})
const methods = await Effect.runPromise(
ShellParse.scan('[IO.File]::Delete("C:\\victim"); $proc.Kill(); "x" >> ~/.bashrc', "pwsh", "C:\\workspace"),
)
expect(methods).toEqual({
commands: [
{ resource: '[IO.File]::Delete("C:\\victim")', save: "[IO.File]::Delete(*" },
{ resource: "$proc.Kill()", save: "$proc.Kill(*" },
{ resource: '"x" >> ~/.bashrc', save: '"x" *' },
],
directories: [],
})
})
test("extracts PowerShell directory parameters", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"),
@@ -81,12 +81,19 @@ describe("Bash execution safety", () => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test.each(["X=value > /dev/null", "X=x 2> output", "X=x < input", "> /dev/null", "2>> output", ">output 2>&1"])(
"scans redirects without inventing an executable command: %s",
(source) => {
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
},
)
test.each([
["X=value > /dev/null", "X=value > /dev/null", "X=value >"],
["X=x 2> output", "X=x 2> output", "X=x 2>"],
["X=x < input", "X=x < input", "X=x <"],
["> /dev/null", "> /dev/null", ">"],
["2>> output", "2>> output", "2>>"],
[">output 2>&1", ">output 2>&1", ">output"],
] as const)("retains commandless redirects as explicit permission resources: %s", (source, resource, head) => {
expect(ShellScan.scan(source)).toMatchObject({
kind: "scanned",
commands: [{ resource, words: [head], rawWords: [head] }],
})
})
test.each([">", "X=x >", "X=x > # comment\nprintf ok", ">; printf ok", "(printf ok) >", "{ printf ok; } >"])(
"still requires redirect targets: %s",
@@ -96,26 +103,40 @@ describe("Bash execution safety", () => {
)
test.each([
"{ printf ok; } > output",
"{ printf ok; } 2> output",
"{ printf ok; } &> output",
"(printf ok) > output",
"{ printf ok; }; X=x > output",
])("scans leading groups and their redirects without synthetic commands: %s", (source) => {
expect(ShellScan.scan(source)).toEqual({
["{ printf ok; } > output", "> output", ">"],
["{ printf ok; } 2> output", "2> output", "2>"],
["{ printf ok; } &> output", "&> output", "&>"],
["(printf ok) > output", "> output", ">"],
["{ printf ok; }; X=x > output", "X=x > output", "X=x >"],
] as const)("retains file redirects on compound groups and trailing assignments: %s", (source, redirectResource, head) => {
expect(ShellScan.scan(source)).toMatchObject({
kind: "scanned",
commands: [{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] }],
commands: [
{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] },
{ resource: redirectResource, words: [head], rawWords: [head] },
],
})
})
test.each(["{ printf ok; } 2>&1", "(printf ok) 2>&1", "(printf ok) 2>&-"])(
"does not invent redirect commands for pure group descriptor duplications: %s",
(source) => {
expect(ShellScan.scan(source)).toEqual({
kind: "scanned",
commands: [{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] }],
})
},
)
test.each(["X=x >$(printf output)", ">$(printf output)", "{ :; } >$(printf output)", "(:) >$(printf output)"])(
"retains explicit substitutions in otherwise commandless redirects: %s",
"retains both redirect resources and explicit substitutions in commandless redirects: %s",
(source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
const redirectHead = source.startsWith("X=x") ? "X=x >$(printf output)" : ">$(printf output)"
expect(result.commands.map((command) => command.words[0])).toEqual(
source.includes(":") ? [":", "printf"] : ["printf"],
source.includes(":") ? [":", redirectHead, "printf"] : [redirectHead, "printf"],
)
expect(result.commands.at(-1)).toEqual({
resource: "printf output",
@@ -126,10 +147,11 @@ describe("Bash execution safety", () => {
)
test("scans a list following a group redirect suffix", () => {
expect(ShellScan.scan("(printf ok) >output && pwd")).toEqual({
expect(ShellScan.scan("(printf ok) >output && pwd")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] },
{ resource: ">output", words: [">output"], rawWords: [">output"] },
{ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] },
],
})
@@ -260,7 +282,10 @@ describe("Bash real-shell redirect-only statements", () => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0]).sort()).toEqual(observed)
expect(
result.commands.filter((command) => command.redirectPrefixEnd === undefined).map((command) => command.words[0]).sort(),
).toEqual(observed)
expect(result.commands.some((command) => command.redirectPrefixEnd !== undefined)).toBe(true)
})
}
})
@@ -156,7 +156,10 @@ describe("ordinary Bash and Zsh syntax", () => {
})
test("scans a Zsh brace group closed after a redirect", () => {
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
expect(ShellScan.scan("{ a && >f }")).toMatchObject({
kind: "scanned",
commands: [{ words: ["a"] }, { resource: ">f", words: [">f"] }],
})
})
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
@@ -188,8 +191,8 @@ describe("Bash shared heredoc delimiter grammar", () => {
'(cat <<"E\\$OF"\nhello\nE$OF\n)',
'(cat <<-"E\\OF"\n\thello\n\tE\\OF\n)',
])("preserves heredoc permission resources and saved prefixes: %s", async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands[0]?.save).toBe("cat *")
})
test.each([
@@ -202,8 +205,8 @@ describe("Bash shared heredoc delimiter grammar", () => {
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands).toHaveLength(names.length)
})
test.each([
@@ -291,7 +294,7 @@ describe("Bash dialects", () => {
test("reads a reserved word after a redirect as a Zsh keyword", () => {
const source = "if true; then >/dev/null fi; next"
expect(heads(source, "zsh")).toEqual(["true", "next"])
expect(heads(source, "zsh")).toEqual(["true", ">/dev/null", "next"])
expect(heads(source, "bash")).toBe("opaque")
expect(heads(source, "posix")).toBe("opaque")
})
@@ -313,7 +316,7 @@ describe("Bash dialects", () => {
["/bin/dash", ["x = 1", "y"]],
["/opt/bin/mksh", ["x = 1", "y"]],
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
const result = await Effect.runPromise(ShellParse.scan("(( x = 1 )); y", shell, "/workspace"))
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
})
@@ -26,11 +26,10 @@ describe("compound function acceptance", () => {
const name = head.includes("probe-name") ? "probe-name" : "probe"
const source = context(`${head} ${body}; ${name}`)
test(`${shell}: ${source}`, async () => {
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
const legacy = await Effect.runPromise(
const braced = await Effect.runPromise(
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))).toEqual(braced)
})
}
}
@@ -79,8 +78,8 @@ describe("Zsh parenthesized loop acceptance", () => {
for (const context of contexts) {
const source = context(fixture.source)
test(source, async () => {
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
const equivalent = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))).toEqual(equivalent)
})
}
+1 -1
View File
@@ -752,7 +752,7 @@ describe.skipIf(process.platform === "win32")("real-shell directory oracle", ()
)
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
for (const executable of changed) {
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
const parsed = await Effect.runPromiseExit(ShellParse.scan(source, executable, root))
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
}
},
@@ -30,17 +30,16 @@ describe("legacy-accepted shell syntax regressions", () => {
"preserves deeply indented function definitions: %s",
async (source) => {
const command = `( ${" ".repeat(32_000)}${source} )`
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
expect(parsed.commands.some((item) => item.resource === "scan_probe")).toBe(true)
},
)
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
"retains conditional process substitutions and permission resources: %s",
async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
expect(parsed.commands.some((command) => command.resource === "scan_probe")).toBe(true)
},
)
@@ -52,9 +51,8 @@ describe("legacy-accepted shell syntax regressions", () => {
),
),
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(parsed.commands.some((command) => command.resource === "scan_probe")).toBe(true)
})
test.each(
@@ -64,17 +62,15 @@ describe("legacy-accepted shell syntax regressions", () => {
),
),
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(parsed.commands.some((command) => command.resource === "scan_probe")).toBe(true)
})
test.each(functions)(
`${shell} preserves function resources, saved prefixes, and directories: %s`,
async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(parsed.commands.some((command) => command.resource === "scan_probe")).toBe(true)
},
)
@@ -116,9 +112,8 @@ describe("legacy-accepted shell syntax regressions", () => {
"while() { scan_probe; break; }",
"until() { scan_probe; break; }",
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
const parsed = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
expect(parsed.commands.some((command) => command.resource === "scan_probe")).toBe(true)
})
test.each([
@@ -60,6 +60,26 @@ describe("PowerShell practical syntax", () => {
['@"\n$(Write-Output \'\n"@\'; git status)\n"@', ["Write-Output '\n\"@'", "git status"]],
["class C { [int]$x = (git status); [void] M() { git diff } }", ["git status", "git diff"]],
["enum Color { Red; Green = 2 }; git status", ["git status"]],
["[IO.File]::Delete('C:\\victim')", ["[IO.File]::Delete('C:\\victim')"]],
[
'[scriptblock]::Create("Remove-Item victim").Invoke()',
['[scriptblock]::Create("Remove-Item victim")', '[scriptblock]::Create("Remove-Item victim").Invoke()'],
],
[
'([scriptblock]::Create("Remove-Item victim")).Invoke()',
['[scriptblock]::Create("Remove-Item victim")', '([scriptblock]::Create("Remove-Item victim")).Invoke()'],
],
[
'$ExecutionContext.InvokeCommand.InvokeScript("Remove-Item victim")',
['$ExecutionContext.InvokeCommand.InvokeScript("Remove-Item victim")'],
],
["Write-Output $proc.Kill()", ["Write-Output $proc.Kill()", "$proc.Kill()"]],
["$proc.'Kill'(); $proc.('Ki' + 'll')(); $proc?.Dispose()", ["$proc.'Kill'()", "$proc.('Ki' + 'll')()", "$proc?.Dispose()"]],
[
"[IO.File]::De`lete('x'); [IO.File]:: Delete('y'); $proc.\nKill()",
["[IO.File]::De`lete('x')", "[IO.File]:: Delete('y')", "$proc.\nKill()"],
],
['"payload" >> ~/.bashrc; (git status) > out.txt', ['"payload" >> ~/.bashrc', "(git status) > out.txt", "git status"]],
] as const)("extracts command resources from %s", (source, resources) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
+46 -40
View File
@@ -4,29 +4,36 @@ import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
import { Wildcard } from "../../src/util/wildcard.js"
async function parity(source: string) {
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(native, source).toEqual(legacy)
async function checkResources(source: string) {
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
for (const command of parsed.commands) {
if (
!/^[A-Za-z_][A-Za-z0-9_]*=/.test(command.resource) &&
!/^(?:\d*>>?|\d*>&|&>>?|\d*<|\d*<>|>\|)/.test(command.resource) &&
!command.resource.includes("\\\n")
) {
expect(Wildcard.match(command.resource, command.save), `${source}: ${JSON.stringify(command)}`).toBe(true)
}
}
}
describe("Bash redirect resource oracle", () => {
test.each([
["printf hello | cat > marker", ["printf hello", "cat"]],
["printf ok && git status > output", ["printf ok", "git status"]],
["printf hello | cat > marker", ["printf hello", "cat > marker"]],
["printf ok && git status > output", ["printf ok", "git status > output"]],
["cat > output", ["cat > output"]],
["cat > output | cat", ["cat > output", "cat"]],
["pwd | > output cat > tail", ["pwd", "> output cat"]],
["pwd && cat > output file", ["pwd", "cat"]],
["pwd | > output cat > tail", ["pwd", "> output cat > tail"]],
["pwd && cat > output file", ["pwd", "cat > output file"]],
["pwd; cat > output", ["pwd", "cat > output"]],
["pwd\ncat > output", ["pwd", "cat > output"]],
] as const)("matches exact permission resources: %s", async (source, resources) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(legacy.commands.map((command) => command.resource)).toEqual([...resources])
await parity(source)
] as const)("keeps redirects visible across lists and pipelines: %s", async (source, resources) => {
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands.map((command) => command.resource)).toEqual([...resources])
await checkResources(source)
})
test("matches redirect positions across generated list and pipeline boundaries", async () => {
test("scans redirect positions across generated list and pipeline boundaries", async () => {
const redirects = [">output", ">>output", "<input", "2>err", "2>&1", "<&0", ">|output", "&>output", "&>>output"]
const separators = [" | ", " |& ", " && ", " || ", "; ", " & ", "\n"]
for (const redirect of redirects) {
@@ -43,10 +50,10 @@ describe("Bash redirect resource oracle", () => {
expect(ShellScan.scan(command).kind).toBe("opaque")
continue
}
await parity(command)
await checkResources(command)
for (const separator of separators) {
await parity(`printf ok${separator}${command}`)
await parity(`${command}${separator}pwd >last`)
await checkResources(`printf ok${separator}${command}`)
await checkResources(`${command}${separator}pwd >last`)
}
}
}
@@ -88,55 +95,54 @@ describe("Bash redirect resource oracle", () => {
"time git status",
"time -p git status",
"coproc git status",
])("preserves nested commands, prefixes, and context: %s", parity)
])("preserves nested commands, prefixes, and context: %s", checkResources)
test("keeps lexical words and nested redirect-target commands after narrowing the resource", () => {
const result = ShellScan.scan('pwd | git >"$(printf output)" status')
test("keeps full redirect resource, lexical words, and nested redirect-target commands", async () => {
const source = 'pwd | git >"$(printf output)" status'
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(`Unexpected opacity: ${result.reason}`)
expect(result.commands[1]).toMatchObject({
resource: "git",
resource: 'git >"$(printf output)" status',
words: ["git", "status"],
rawWords: ["git", "status"],
redirectWordCount: 1,
})
expect(result.commands[2]).toMatchObject({ resource: "printf output", rawWords: ["printf", "output"] })
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands[1]).toEqual({ resource: 'git >"$(printf output)" status', save: "git *" })
expect(Wildcard.match(parsed.commands[1]!.resource, parsed.commands[1]!.save)).toBe(true)
})
test("excludes ignored trailing continuations from narrowed command prefixes", async () => {
test("retains trailing redirect span after a continuation on pipeline RHS", async () => {
const source = "pwd | cat\\\n >out"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(`Unexpected opacity: ${result.reason}`)
expect(result.commands.map((command) => command.resource)).toEqual(
legacy.commands.map((command) => command.resource),
)
expect(result.commands[1]?.rawWords).toEqual(["cat"])
expect(legacy.commands[1]).toEqual({ resource: "cat", save: "cat *" })
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(native).toEqual(legacy)
expect(native.commands.every((command) => Wildcard.match(command.resource, command.save))).toBe(true)
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands).toEqual([
{ resource: "pwd", save: "pwd *" },
{ resource: "cat\\\n >out", save: "cat *" },
])
})
test.each(["cat\\\n", "cat \\\n", "cat\\\n\\\n", "cat\\\n;", "cat >out\\\n", "cat >out \\\n"])(
"saved prefixes cover their standalone continuation command: %j",
async (source) => {
await parity(source)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands[0]?.rawWords).toEqual(["cat"])
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(native.commands[0]).toEqual({ resource: source.includes(">out") ? "cat >out" : "cat", save: "cat *" })
expect(native.commands.every((command) => Wildcard.match(command.resource, command.save))).toBe(true)
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands[0]).toEqual({ resource: source.includes(">out") ? "cat >out" : "cat", save: "cat *" })
expect(parsed.commands.every((command) => Wildcard.match(command.resource, command.save))).toBe(true)
},
)
test.each(["printf 'literal\\\n'\\\n", 'printf "literal\\\n"\\\n', "printf a\\\nb\\\n", 'printf a\\\n""\\\n'])(
"preserves meaningful raw syntax before an ignored trailing continuation: %j",
async (source) => {
await parity(source)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
@@ -145,18 +151,18 @@ describe("Bash redirect resource oracle", () => {
},
)
test("assignment then redirect on a pipeline RHS retains the command", async () => {
test("assignment then redirect on a pipeline RHS retains the full command and trailing redirect", async () => {
const source = "printf ok | FOO=bar >output git status 3>tail"
const native = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(native.commands).toEqual([
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed.commands).toEqual([
{ resource: "printf ok", save: "printf *" },
{ resource: "FOO=bar >output git status", save: "git status *" },
{ resource: "FOO=bar >output git status 3>tail", save: "git status *" },
])
})
test("redirect before cd operand retains the target directory", async () => {
const source = "pwd && cd >out /outside"
const native = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
const parsed = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(parsed).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
})
})
+3 -3
View File
@@ -166,12 +166,12 @@ describe("ShellScan", () => {
},
)
test("keeps first-command redirects but excludes list-level redirects from resources", () => {
test("keeps redirects in resources across list operators", () => {
expect(ShellScan.scan("FOO=bar 2>>err printf ok > out && cat < input")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "FOO=bar 2>>err printf ok > out", words: ["printf", "ok"] },
{ resource: "cat", words: ["cat"] },
{ resource: "cat < input", words: ["cat"] },
],
})
})
@@ -181,7 +181,7 @@ describe("ShellScan", () => {
kind: "scanned",
commands: [
{ resource: "printf ok>out 2>&1", words: ["printf", "ok"] },
{ resource: "cat", words: ["cat"] },
{ resource: "cat<input", words: ["cat"] },
],
})
})
+15 -4
View File
@@ -358,7 +358,7 @@ describe("ShellTool scanner permissions", () => {
}),
))
test("pipeline redirect preserves exact approval and denial despite broad saved approval", () =>
test("pipeline redirect keeps the redirect target visible to exact approval and denial", () =>
withScanner((registry, fixture) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
@@ -366,6 +366,13 @@ describe("ShellTool scanner permissions", () => {
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["printf hello", "cat"] })
const marker = path.join(fixture.active, "marker")
const command = "printf hello | cat > marker"
const unapprovedRedirect = yield* runPermissionCommand(registry, command, marker, ["reject"])
expect(unapprovedRedirect.requests).toMatchObject([
{ action: "shell", resources: ["printf hello", "cat > marker"], save: ["printf *", "cat *"] },
])
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["cat > marker"] })
const exact = yield* runPermissionCommand(registry, command, marker, [])
expect(exact.requests).toEqual([])
expect(exact.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
@@ -381,7 +388,7 @@ describe("ShellTool scanner permissions", () => {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(toolIdentity.agent, (agent) => {
agent.permissions = [{ action: "shell", resource: "cat", effect: "deny" }]
agent.permissions = [{ action: "shell", resource: "* > marker", effect: "deny" }]
}),
)
yield* Effect.promise(() => fs.rm(marker))
@@ -858,12 +865,16 @@ describe("ShellTool", () => {
{
sessionID,
action: "shell",
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
resources: isWindows
? ["[Console]::Out.Write('hello')", "Start-Sleep -Milliseconds 100"]
: [helloCommand],
agent: toolIdentity.agent,
source: { type: "tool", messageID: toolIdentity.messageID, id: "call-shell" },
},
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
expect(assertions[0]?.save).toEqual(
isWindows ? ["[Console]::Out.Write(*", "Start-Sleep *"] : ["printf *"],
)
}),
)
},
+1 -1
View File
@@ -7,7 +7,7 @@ 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.",
description: "Deprecated: the built-in shell permission scanner is now always enabled.",
}),
todo: Schema.Boolean.pipe(optional).annotate({
description: "Enable the experimental todowrite tool. Defaults to false.",