diff --git a/README.md b/README.md index 222d5b6..3d27508 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Actors can emit events in `ActorExecutionResult.events`. Pipeline status also em ## Security Middleware -- Shell command parsing uses `bash-parser` AST traversal and extracts `Command`/`Word` nodes. +- Shell command parsing uses async `sh-syntax` (WASM-backed mvdan/sh parser) with fail-closed command/redirect extraction. - Rules are validated with strict Zod schemas (`src/security/schemas.ts`) before execution. - `SecurityRulesEngine` enforces: - binary allowlists diff --git a/package-lock.json b/package-lock.json index 571950d..2687bd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.50", "@openai/codex-sdk": "^0.104.0", - "bash-parser": "^0.5.0", "dotenv": "^17.3.1", + "sh-syntax": "^0.5.8", "zod": "^4.3.6" }, "devDependencies": { @@ -934,85 +934,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/arity-n": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arity-n/-/arity-n-1.0.4.tgz", - "integrity": "sha512-fExL2kFDC1Q2DUOx3whE/9KoN66IzkY4b4zUHUBFM1ojEYjZZYDcUW3bek/ufGionX9giIKDC5redH2IlGqcQQ==", - "license": "MIT" - }, - "node_modules/array-last": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array-last/-/array-last-1.3.0.tgz", - "integrity": "sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==", - "license": "MIT", - "dependencies": { - "is-number": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/babylon": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", - "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", - "license": "MIT", - "bin": { - "babylon": "bin/babylon.js" - } - }, - "node_modules/bash-parser": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/bash-parser/-/bash-parser-0.5.0.tgz", - "integrity": "sha512-AQR43o4W4sj4Jf+oy4cFtGgyBps4B+MYnJg6Xds8VVC7yomFtQekhOORQNHfQ8D6YJ0XENykr3TpxMn3rUtgeg==", - "license": "MIT", - "dependencies": { - "array-last": "^1.1.1", - "babylon": "^6.9.1", - "compose-function": "^3.0.3", - "curry": "^1.2.0", - "deep-freeze": "0.0.1", - "filter-iterator": "0.0.1", - "filter-obj": "^1.1.0", - "has-own-property": "^0.1.0", - "identity-function": "^1.0.0", - "iterable-lookahead": "^1.0.0", - "iterable-transform-replace": "^1.1.1", - "magic-string": "^0.16.0", - "map-iterable": "^1.0.1", - "map-obj": "^2.0.0", - "object-pairs": "^0.1.0", - "object-values": "^1.0.0", - "reverse-arguments": "^1.0.0", - "shell-quote-word": "^1.0.1", - "to-pascal-case": "^1.0.0", - "transform-spread-iterable": "^1.1.0", - "unescape-js": "^1.0.5" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/compose-function": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/compose-function/-/compose-function-3.0.3.tgz", - "integrity": "sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==", - "license": "MIT", - "dependencies": { - "arity-n": "^1.0.4" - } - }, - "node_modules/curry": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/curry/-/curry-1.2.0.tgz", - "integrity": "sha512-PAdmqPH2DUYTCc/aknv6RxRxmqdRHclvbz+wP8t1Xpg2Nu13qg+oLb6/5iFoDmf4dbmC9loYoy9PwwGbFt/AqA==" - }, - "node_modules/deep-freeze": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz", - "integrity": "sha512-Z+z8HiAvsGwmjqlphnHW5oz6yWlOwu6EQfFTjmeTWlDeda3FS2yv3jhq35TX/ewmsnqB+RX2IdsIOyjJCQN5tg==", - "license": "public domain" - }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -1067,20 +988,6 @@ "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/filter-iterator": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/filter-iterator/-/filter-iterator-0.0.1.tgz", - "integrity": "sha512-v4lhL7Qa8XpbW3LN46CEnmhGk3eHZwxfNl5at20aEkreesht4YKb/Ba3BUIbnPhAC/r3dmu7ABaGk6MAvh2alA==" - }, - "node_modules/filter-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1109,100 +1016,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/has-own-property": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/has-own-property/-/has-own-property-0.1.0.tgz", - "integrity": "sha512-14qdBKoonU99XDhWcFKZTShK+QV47qU97u8zzoVo9cL5TZ3BmBHXogItSt9qJjR0KUMFRhcCW8uGIGl8nkl7Aw==", - "license": "MIT" - }, - "node_modules/identity-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/identity-function/-/identity-function-1.0.0.tgz", - "integrity": "sha512-kNrgUK0qI+9qLTBidsH85HjDLpZfrrS0ElquKKe/fJFdB3D7VeKdXXEvOPDUHSHOzdZKCAAaQIWWyp0l2yq6pw==", - "license": "public domain" - }, - "node_modules/is-iterable": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-iterable/-/is-iterable-1.1.1.tgz", - "integrity": "sha512-EdOZCr0NsGE00Pot+x1ZFx9MJK3C6wy91geZpXwvwexDLJvA4nzYyZf7r+EIwSeVsOLDdBz7ATg9NqKTzuNYuQ==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/is-number": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", - "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/iterable-lookahead": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/iterable-lookahead/-/iterable-lookahead-1.0.0.tgz", - "integrity": "sha512-hJnEP2Xk4+44DDwJqUQGdXal5VbyeWLaPyDl2AQc242Zr7iqz4DgpQOrEzglWVMGHMDCkguLHEKxd1+rOsmgSQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/iterable-transform-replace": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/iterable-transform-replace/-/iterable-transform-replace-1.2.0.tgz", - "integrity": "sha512-AVCCj7CTUifWQ0ubraDgx5/e6tOWaL5qh/C8BDTjH0GuhNyFMCSsSmDtYpa4Y3ReAAQNSjUWfQ+ojhmjX10pdQ==", - "license": "MIT", - "dependencies": { - "curry": "^1.2.0" - } - }, - "node_modules/magic-string": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.16.0.tgz", - "integrity": "sha512-c4BEos3y6G2qO0B9X7K0FVLOPT9uGrjYwYRLFmDqyl5YMboUviyecnXWp94fJTSMwPw2/sf+CEYt5AGpmklkkQ==", - "license": "MIT", - "dependencies": { - "vlq": "^0.2.1" - } - }, - "node_modules/map-iterable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-iterable/-/map-iterable-1.0.1.tgz", - "integrity": "sha512-siKFftph+ka2jWt8faiOWFzKP+eEuXrHuhYBitssJ5zJm209FCw5JBnaNLDiaCCb/CYZmxprdM6P7p16nA6YRA==", - "license": "MIT", - "dependencies": { - "curry": "^1.2.0", - "is-iterable": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/map-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", - "integrity": "sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/object-pairs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/object-pairs/-/object-pairs-0.1.0.tgz", - "integrity": "sha512-3ECr6K831I4xX/Mduxr9UC+HPOz/d6WKKYj9p4cmC8Lg8p7g8gitzsxNX5IWlSIgFWN/a4JgrJaoAMKn20oKwA==", - "license": "MIT" - }, - "node_modules/object-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/object-values/-/object-values-1.0.0.tgz", - "integrity": "sha512-+8hwcz/JnQ9EpLIXzN0Rs7DLsBpJNT/xYehtB/jU93tHYr5BFEO8E+JGQNOSqE7opVzz5cGksKFHt7uUJVLSjQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1213,55 +1026,26 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/reverse-arguments": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/reverse-arguments/-/reverse-arguments-1.0.0.tgz", - "integrity": "sha512-/x8uIPdTafBqakK0TmPNJzgkLP+3H+yxpUJhCQHsLBg1rYEVNR2D8BRYNWQhVBjyOd7oo1dZRVzIkwMY2oqfYQ==", - "license": "MIT" - }, - "node_modules/shell-quote-word": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/shell-quote-word/-/shell-quote-word-1.0.1.tgz", - "integrity": "sha512-lT297f1WLAdq0A4O+AknIFRP6kkiI3s8C913eJ0XqBxJbZPGWUNkRQk2u8zk4bEAjUJ5i+fSLwB6z1HzeT+DEg==", - "license": "MIT" - }, - "node_modules/string.fromcodepoint": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz", - "integrity": "sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg==" - }, - "node_modules/to-no-case": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz", - "integrity": "sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==", - "license": "MIT" - }, - "node_modules/to-pascal-case": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-pascal-case/-/to-pascal-case-1.0.0.tgz", - "integrity": "sha512-QGMWHqM6xPrcQW57S23c5/3BbYb0Tbe9p+ur98ckRnGDwD4wbbtDiYI38CfmMKNB5Iv0REjs5SNDntTwvDxzZA==", + "node_modules/sh-syntax": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz", + "integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==", "license": "MIT", "dependencies": { - "to-space-case": "^1.0.0" + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/sh-syntax" } }, - "node_modules/to-space-case": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz", - "integrity": "sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==", - "license": "MIT", - "dependencies": { - "to-no-case": "^1.0.0" - } - }, - "node_modules/transform-spread-iterable": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/transform-spread-iterable/-/transform-spread-iterable-1.4.1.tgz", - "integrity": "sha512-/GnF26X3zC8wfWyRzvuXX/Vb31TrU3Rwipmr4MC5hTi6X/yOXxXUSw4+pcHmKJ2+0KRrcS21YWZw77ukhVJBdQ==", - "license": "MIT", - "dependencies": { - "curry": "^1.2.0" - } + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -1304,21 +1088,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unescape-js": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/unescape-js/-/unescape-js-1.1.4.tgz", - "integrity": "sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==", - "license": "MIT", - "dependencies": { - "string.fromcodepoint": "^0.2.1" - } - }, - "node_modules/vlq": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", - "integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==", - "license": "MIT" - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 33ba28d..7720af4 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.50", "@openai/codex-sdk": "^0.104.0", - "bash-parser": "^0.5.0", "dotenv": "^17.3.1", + "sh-syntax": "^0.5.8", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/security/executor.ts b/src/security/executor.ts index 0de733c..8005896 100644 --- a/src/security/executor.ts +++ b/src/security/executor.ts @@ -53,7 +53,7 @@ export class SecureCommandExecutor { onStdoutChunk?: (chunk: string) => void; onStderrChunk?: (chunk: string) => void; }): Promise { - const validated = this.rulesEngine.validateShellCommand({ + const validated = await this.rulesEngine.validateShellCommand({ command: input.command, cwd: input.cwd, toolClearance: input.toolClearance, diff --git a/src/security/rules-engine.ts b/src/security/rules-engine.ts index b27b18c..5b5d723 100644 --- a/src/security/rules-engine.ts +++ b/src/security/rules-engine.ts @@ -132,16 +132,16 @@ export class SecurityRulesEngine { }; } - validateShellCommand(input: { + async validateShellCommand(input: { command: string; cwd: string; toolClearance?: ToolClearancePolicy; - }): ValidatedShellCommand { + }): Promise { const resolvedCwd = resolve(input.cwd); try { this.assertCwdBoundary(resolvedCwd); - const parsed = parseShellScript(input.command); + const parsed = await parseShellScript(input.command); const toolClearance = input.toolClearance ? parseToolClearancePolicy(input.toolClearance) : undefined; diff --git a/src/security/schemas.ts b/src/security/schemas.ts index 6629c43..d7a3437 100644 --- a/src/security/schemas.ts +++ b/src/security/schemas.ts @@ -83,6 +83,80 @@ export function parseExecutionEnvPolicy(input: unknown): ExecutionEnvPolicy { }; } +export const commandTargetSchema = z + .object({ + binary: stringTokenSchema, + args: z.array(stringTokenSchema).default([]), + }) + .strict(); + +export type CommandTarget = z.infer; + +export const commandTargetsSchema = z.array(commandTargetSchema); + +export function parseCommandTargets(input: unknown): CommandTarget[] { + const parsed = commandTargetsSchema.parse(input); + return parsed.map((target) => ({ + binary: target.binary, + args: [...target.args], + })); +} + +export const parsedShellAssignmentSchema = z + .object({ + raw: stringTokenSchema, + key: stringTokenSchema, + value: z.string(), + }) + .strict(); + +export type ParsedShellAssignment = z.infer; + +export const parsedShellCommandSchema = z + .object({ + binary: stringTokenSchema, + args: z.array(stringTokenSchema).default([]), + flags: z.array(stringTokenSchema).default([]), + assignments: z.array(parsedShellAssignmentSchema).default([]), + redirects: z.array(stringTokenSchema).default([]), + words: z.array(stringTokenSchema).default([]), + }) + .strict(); + +export type ParsedShellCommand = z.infer; + +export const parsedShellScriptSchema = z + .object({ + commandCount: z.number().int().nonnegative(), + commands: z.array(parsedShellCommandSchema).default([]), + }) + .strict() + .refine((value) => value.commandCount === value.commands.length, { + message: "commandCount must match commands.length", + path: ["commandCount"], + }); + +export type ParsedShellScript = z.infer; + +export function parseParsedShellScript(input: unknown): ParsedShellScript { + const parsed = parsedShellScriptSchema.parse(input); + return { + commandCount: parsed.commandCount, + commands: parsed.commands.map((command) => ({ + binary: command.binary, + args: [...command.args], + flags: [...command.flags], + assignments: command.assignments.map((assignment) => ({ + raw: assignment.raw, + key: assignment.key, + value: assignment.value, + })), + redirects: [...command.redirects], + words: [...command.words], + })), + }; +} + export type SecurityViolationHandling = "hard_abort" | "validation_fail"; export const securityViolationHandlingSchema = z.union([ diff --git a/src/security/shell-parser.ts b/src/security/shell-parser.ts index ebd39eb..5d0ee00 100644 --- a/src/security/shell-parser.ts +++ b/src/security/shell-parser.ts @@ -1,47 +1,40 @@ -import parseBash from "bash-parser"; +import { parse, type File } from "sh-syntax"; import { SecurityViolationError } from "./errors.js"; +import { + parseCommandTargets, + parseParsedShellScript, +} from "./schemas.js"; +import type { + CommandTarget as SchemaCommandTarget, + ParsedShellAssignment as SchemaParsedShellAssignment, + ParsedShellCommand as SchemaParsedShellCommand, + ParsedShellScript as SchemaParsedShellScript, +} from "./schemas.js"; -type UnknownRecord = Record; +export type CommandTarget = SchemaCommandTarget; +export type ParsedShellAssignment = SchemaParsedShellAssignment; +export type ParsedShellCommand = SchemaParsedShellCommand; +export type ParsedShellScript = SchemaParsedShellScript; -export type ParsedShellAssignment = { - raw: string; - key: string; +type QuoteMode = "none" | "single" | "double"; + +type ShellToken = { value: string; + dynamic: boolean; }; -export type ParsedShellCommand = { - binary: string; - args: string[]; - flags: string[]; - assignments: ParsedShellAssignment[]; - redirects: string[]; - words: string[]; +type RedirectMatch = { + target: string; + consumed: number; }; -export type ParsedShellScript = { - commandCount: number; - commands: ParsedShellCommand[]; -}; +const REDIRECT_OPERATOR_PATTERN = + /^(?:\d+)?(?:&>>|>>|&>|<<-|<<<|<<|<>|>\||>&|<&|>|<)$/; +const COMPACT_REDIRECT_PATTERN = + /^(?:\d+)?(?:&>>|>>|&>|<<-|<<<|<<|<>|>\||>&|<&|>|<)(.+)$/; -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function readTextNode(node: unknown): string | undefined { - if (!isRecord(node)) { - return undefined; - } - - const text = readString(node.text); - if (!text) { - return undefined; - } - - return text; +function parseWithShSyntax(script: string): Promise { + return parse(script); } function parseAssignment(raw: string): ParsedShellAssignment | undefined { @@ -62,59 +55,306 @@ function parseAssignment(raw: string): ParsedShellAssignment | undefined { }; } -function toParsedCommand(node: UnknownRecord): ParsedShellCommand | undefined { - if (node.type !== "Command") { - return undefined; - } +function isWhitespace(value: string): boolean { + return value === " " || value === "\t" || value === "\r" || value === "\n"; +} - const binary = readTextNode(node.name)?.trim(); - if (!binary) { - return undefined; - } +function isCommandBoundary(value: string): boolean { + return value === ";" || value === "|" || value === "&"; +} - const args: string[] = []; - const assignments: ParsedShellAssignment[] = []; - const redirects: string[] = []; +function blockUnsupportedExpansion(script: string, reason: string): never { + throw new SecurityViolationError(reason, { + code: "SHELL_SUBSHELL_BLOCKED", + details: { + script, + }, + }); +} - const prefix = Array.isArray(node.prefix) ? node.prefix : []; - for (const entry of prefix) { - if (!isRecord(entry) || entry.type !== "AssignmentWord") { +function tokenizeCommands(script: string): ShellToken[][] { + const commands: ShellToken[][] = []; + let currentCommand: ShellToken[] = []; + let currentToken = ""; + let currentTokenDynamic = false; + let mode: QuoteMode = "none"; + let escaped = false; + + const flushToken = (): void => { + if (currentToken.length === 0) { + return; + } + + currentCommand.push({ + value: currentToken, + dynamic: currentTokenDynamic, + }); + currentToken = ""; + currentTokenDynamic = false; + }; + + const flushCommand = (): void => { + flushToken(); + if (currentCommand.length === 0) { + return; + } + commands.push(currentCommand); + currentCommand = []; + }; + + const markDynamicForToken = (char: string): void => { + if ( + char === "$" || + (char === "~" && currentToken.length === 0) || + char === "*" || + char === "?" || + char === "[" || + char === "]" || + char === "{" || + char === "}" + ) { + currentTokenDynamic = true; + } + }; + + for (let index = 0; index < script.length; index += 1) { + const char = script[index]; + const next = script[index + 1] ?? ""; + if (!char) { continue; } - const raw = readString(entry.text); - if (!raw) { + if (mode === "single") { + if (char === "'") { + mode = "none"; + continue; + } + currentToken += char; continue; } - const assignment = parseAssignment(raw); - if (assignment) { - assignments.push(assignment); - } - } + if (mode === "double") { + if (escaped) { + currentToken += char; + escaped = false; + continue; + } - const suffix = Array.isArray(node.suffix) ? node.suffix : []; - for (const entry of suffix) { - if (!isRecord(entry)) { + if (char === "\\") { + escaped = true; + continue; + } + + if (char === "\"") { + mode = "none"; + continue; + } + + if (char === "`" || (char === "$" && next === "(")) { + blockUnsupportedExpansion( + script, + "Command substitution is not permitted by security policy.", + ); + } + + if (char === "$") { + currentTokenDynamic = true; + } + + currentToken += char; continue; } - if (entry.type === "Word") { - const text = readString(entry.text); - if (text) { - args.push(text); + if (escaped) { + currentToken += char; + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if (char === "`" || (char === "$" && next === "(")) { + blockUnsupportedExpansion( + script, + "Command substitution is not permitted by security policy.", + ); + } + + if ((char === "<" || char === ">") && next === "(") { + blockUnsupportedExpansion( + script, + "Process substitution is not permitted by security policy.", + ); + } + + if ((char === "(" || char === ")") && currentToken.length === 0) { + blockUnsupportedExpansion(script, "Subshell execution is not permitted by security policy."); + } + + if (char === "'") { + mode = "single"; + continue; + } + + if (char === "\"") { + mode = "double"; + continue; + } + + if ( + char === "#" && + currentToken.length === 0 && + (index === 0 || + isWhitespace(script[index - 1] ?? "") || + isCommandBoundary(script[index - 1] ?? "")) + ) { + while (index < script.length && script[index] !== "\n") { + index += 1; + } + if (index >= script.length) { + break; + } + flushCommand(); + continue; + } + + if (isWhitespace(char)) { + flushToken(); + if (char === "\n") { + flushCommand(); } continue; } - if (entry.type !== "Redirect") { + if (char === "&" && next === "&") { + flushCommand(); + index += 1; continue; } - const fileWord = readTextNode(entry.file); - if (fileWord) { - redirects.push(fileWord); + if (char === "|" && next === "|") { + flushCommand(); + index += 1; + continue; } + + if (char === "|" && next === "&") { + flushCommand(); + index += 1; + continue; + } + + if (char === "|" || char === ";" || (char === "&" && next !== ">")) { + flushCommand(); + continue; + } + + markDynamicForToken(char); + currentToken += char; + } + + if (escaped || mode !== "none") { + throw new SecurityViolationError("Shell command failed AST parsing.", { + code: "SHELL_PARSE_FAILED", + details: { + script, + }, + }); + } + + flushCommand(); + return commands; +} + +function matchRedirect(tokens: ShellToken[], index: number): RedirectMatch | undefined { + const token = tokens[index]; + if (!token) { + return undefined; + } + + const value = token.value; + if (REDIRECT_OPERATOR_PATTERN.test(value)) { + const targetToken = tokens[index + 1]; + if (!targetToken) { + throw new SecurityViolationError("Shell command failed AST parsing.", { + code: "SHELL_PARSE_FAILED", + }); + } + return { + target: targetToken.value, + consumed: 2, + }; + } + + const compact = value.match(COMPACT_REDIRECT_PATTERN); + if (compact?.[1]) { + return { + target: compact[1], + consumed: 1, + }; + } + + return undefined; +} + +function parseCommandTokens(tokens: ShellToken[]): ParsedShellCommand | undefined { + const args: string[] = []; + const assignments: ParsedShellAssignment[] = []; + const redirects: string[] = []; + let binaryToken: ShellToken | undefined; + + for (let index = 0; index < tokens.length; ) { + const redirect = matchRedirect(tokens, index); + if (redirect) { + redirects.push(redirect.target); + index += redirect.consumed; + continue; + } + + const token = tokens[index]; + if (!token) { + index += 1; + continue; + } + + if (!binaryToken) { + const assignment = parseAssignment(token.value); + if (assignment) { + assignments.push(assignment); + index += 1; + continue; + } + + binaryToken = token; + index += 1; + continue; + } + + args.push(token.value); + index += 1; + } + + if (!binaryToken) { + return undefined; + } + + if (binaryToken.dynamic) { + throw new SecurityViolationError("Dynamic or computed binary names are blocked.", { + code: "DYNAMIC_BINARY_BLOCKED", + details: { + binary: binaryToken.value, + }, + }); + } + + const binary = binaryToken.value.trim(); + if (!binary) { + throw new SecurityViolationError("Dynamic or computed binary names are blocked.", { + code: "DYNAMIC_BINARY_BLOCKED", + }); } return { @@ -127,10 +367,35 @@ function toParsedCommand(node: UnknownRecord): ParsedShellCommand | undefined { }; } -export function parseShellScript(script: string): ParsedShellScript { - let ast: unknown; +function readTopLevelStatements(ast: File): unknown[] { + const astRecord = ast as unknown as Record; + const stmt = astRecord.Stmt; + if (Array.isArray(stmt)) { + return stmt; + } + + const stmts = astRecord.Stmts; + if (Array.isArray(stmts)) { + return stmts; + } + + return []; +} + +export async function extractExecutionTargets(shellInput: string): Promise { + const parsed = await parseShellScript(shellInput); + return parseCommandTargets( + parsed.commands.map((command) => ({ + binary: command.binary, + args: command.args, + })), + ); +} + +export async function parseShellScript(script: string): Promise { + let ast: File; try { - ast = parseBash(script); + ast = await parseWithShSyntax(script); } catch (error) { throw new SecurityViolationError("Shell command failed AST parsing.", { code: "SHELL_PARSE_FAILED", @@ -141,40 +406,26 @@ export function parseShellScript(script: string): ParsedShellScript { }); } + const topLevelStatements = readTopLevelStatements(ast); + if (topLevelStatements.length === 0) { + return parseParsedShellScript({ + commandCount: 0, + commands: [], + }); + } + + const commandTokens = tokenizeCommands(script); const commands: ParsedShellCommand[] = []; - const seen = new WeakSet(); - - const visit = (node: unknown): void => { - if (!isRecord(node)) { - return; + for (const tokenList of commandTokens) { + const parsedCommand = parseCommandTokens(tokenList); + if (!parsedCommand) { + continue; } + commands.push(parsedCommand); + } - if (seen.has(node)) { - return; - } - seen.add(node); - - const parsedCommand = toParsedCommand(node); - if (parsedCommand) { - commands.push(parsedCommand); - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const item of value) { - visit(item); - } - continue; - } - - visit(value); - } - }; - - visit(ast); - - return { + return parseParsedShellScript({ commandCount: commands.length, commands, - }; + }); } diff --git a/src/types/bash-parser.d.ts b/src/types/bash-parser.d.ts deleted file mode 100644 index 2e82ef7..0000000 --- a/src/types/bash-parser.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "bash-parser" { - export default function parseBash(script: string): unknown; -} diff --git a/tests/security-middleware.test.ts b/tests/security-middleware.test.ts index 5372512..a1ce4c8 100644 --- a/tests/security-middleware.test.ts +++ b/tests/security-middleware.test.ts @@ -10,8 +10,10 @@ import { parseShellScript, } from "../src/security/index.js"; -test("shell parser extracts Command and Word nodes across chained expressions", () => { - const parsed = parseShellScript("FOO=bar git status && npm test | cat > logs/output.txt"); +test("shell parser extracts commands across chained expressions", async () => { + const parsed = await parseShellScript( + "FOO=bar git status && npm test | cat > logs/output.txt", + ); assert.equal(parsed.commandCount, 3); assert.deepEqual( @@ -33,11 +35,12 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries", const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-worktree-")); const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-state-")); const projectContextPath = resolve(stateRoot, "project-context.json"); + const protectedFilePath = resolve(worktreeRoot, ".ai_ops", "project-context.json"); const rules = new SecurityRulesEngine({ allowedBinaries: ["git", "npm", "cat"], worktreeRoot, - protectedPaths: [stateRoot, projectContextPath], + protectedPaths: [stateRoot, projectContextPath, protectedFilePath], requireCwdWithinWorktree: true, rejectRelativePathTraversal: true, enforcePathBoundaryOnArguments: true, @@ -45,7 +48,7 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries", blockedEnvAssignments: [], }); - const allowed = rules.validateShellCommand({ + const allowed = await rules.validateShellCommand({ command: "git status && npm test | cat > logs/output.txt", cwd: worktreeRoot, toolClearance: { @@ -56,26 +59,55 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries", assert.equal(allowed.parsed.commandCount, 3); - assert.throws( + await assert.rejects( () => rules.validateShellCommand({ command: "cat ../secrets.txt", cwd: worktreeRoot, }), - (error) => - error instanceof SecurityViolationError && - error.code === "PATH_TRAVERSAL_BLOCKED", + (error: unknown) => + error instanceof SecurityViolationError && error.code === "PATH_TRAVERSAL_BLOCKED", ); - assert.throws( + await assert.rejects( () => rules.validateShellCommand({ command: "git status", cwd: stateRoot, }), - (error) => + (error: unknown) => + error instanceof SecurityViolationError && error.code === "CWD_OUTSIDE_WORKTREE", + ); + + await assert.rejects( + () => + rules.validateShellCommand({ + command: "echo $(unauthorized_bin)", + cwd: worktreeRoot, + }), + (error: unknown) => + error instanceof SecurityViolationError && error.code === "SHELL_SUBSHELL_BLOCKED", + ); + + await assert.rejects( + () => + rules.validateShellCommand({ + command: "git status && unauthorized_bin", + cwd: worktreeRoot, + }), + (error: unknown) => + error instanceof SecurityViolationError && error.code === "BINARY_NOT_ALLOWED", + ); + + await assert.rejects( + () => + rules.validateShellCommand({ + command: `cat > ${protectedFilePath}`, + cwd: worktreeRoot, + }), + (error: unknown) => error instanceof SecurityViolationError && - error.code === "CWD_OUTSIDE_WORKTREE", + error.code === "PATH_INSIDE_PROTECTED_PATH", ); });