diff --git a/.gitignore b/.gitignore index c33c9be3..a009f207 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ npm-debug.log # Superpowers brainstorming mockups /.superpowers/ /.pi/extensions/s3-browser/node_modules +/.pi/extensions/sensitive-file-guard/node_modules diff --git a/.pi/extensions/sensitive-file-guard/ast.ts b/.pi/extensions/sensitive-file-guard/ast.ts new file mode 100644 index 00000000..62a85d74 --- /dev/null +++ b/.pi/extensions/sensitive-file-guard/ast.ts @@ -0,0 +1,212 @@ +import { parse } from "unbash"; + +/** + * Walk a parsed `unbash` AST and collect all command names from executable + * positions (simple commands, subshell bodies, command/substitution bodies, + * pipeline members). Non-executed text (comments, quoted strings, echo + * arguments) is structurally excluded and never inspected. + * + * Returns a `Set` of lowercased command names, or an empty set if + * parsing fails. + */ +export function extractCommandNames(command: string): Set { + const names = new Set(); + + try { + const script = parse(command); + if (script && typeof script === "object" && script.commands) { + walkScript(script, names); + } + } catch { + // Parse failure — return empty set so caller can fall back to regex + } + + return names; +} + +function walkScript(script: any, names: Set): void { + const commands = script?.commands; + if (!Array.isArray(commands)) return; + for (const stmt of commands) { + walkStatement(stmt, names); + } +} + +function walkStatement(stmt: any, names: Set): void { + if (!stmt) return; + walkNode(stmt.command, names); +} + +function walkCompoundList(node: any, names: Set): void { + if (!node?.commands || !Array.isArray(node.commands)) return; + for (const stmt of node.commands) { + walkStatement(stmt, names); + } +} + +/** Recursively walk a command node and collect executable command names. */ +function walkNode(node: any, names: Set): void { + if (!node) return; + + switch (node.type) { + case "Command": { + if (node.name) { + // Collect the command name text + const nameText = node.name.text || node.name.value; + if (nameText && typeof nameText === "string") { + names.add(nameText.toLowerCase()); + } + // Walk name parts for CommandExpansion/ProcessSubstitution + if (node.name.parts && Array.isArray(node.name.parts)) { + for (const part of node.name.parts) { + walkNode(part, names); + } + } + } + + // Walk prefix (assignment) value parts for nested expansions + if (node.prefix && Array.isArray(node.prefix)) { + for (const p of node.prefix) { + if (p?.value?.parts && Array.isArray(p.value.parts)) { + for (const part of p.value.parts) { + walkNode(part, names); + } + } + } + } + + // Walk suffix parts for nested expansions (do NOT collect suffix + // text — arguments are not commands) + if (node.suffix && Array.isArray(node.suffix)) { + for (const s of node.suffix) { + if (s?.parts && Array.isArray(s.parts)) { + for (const part of s.parts) { + walkNode(part, names); + } + } + } + } + break; + } + + case "Pipeline": { + if (node.commands && Array.isArray(node.commands)) { + for (const cmd of node.commands) { + walkNode(cmd, names); + } + } + break; + } + + case "AndOr": { + if (node.commands && Array.isArray(node.commands)) { + for (const cmd of node.commands) { + walkNode(cmd, names); + } + } + break; + } + + case "Subshell": { + walkCompoundList(node.body, names); + break; + } + + case "If": { + walkCompoundList(node.clause, names); + walkCompoundList(node.then, names); + if (node.else) { + walkCompoundList(node.else, names); + } + break; + } + + case "While": { + walkCompoundList(node.clause, names); + walkCompoundList(node.body, names); + break; + } + + case "For": + case "Select": { + // Walk the wordlist for command substitutions (e.g. for f in $(env);) + if (node.wordlist && Array.isArray(node.wordlist)) { + for (const w of node.wordlist) { + if (w?.parts && Array.isArray(w.parts)) { + for (const part of w.parts) { + walkNode(part, names); + } + } + } + } + walkCompoundList(node.body, names); + break; + } + + case "Case": { + // Walk the word being matched for command substitutions (e.g. case $(env) in) + if (node.word?.parts && Array.isArray(node.word.parts)) { + for (const part of node.word.parts) { + walkNode(part, names); + } + } + if (node.items && Array.isArray(node.items)) { + for (const item of node.items) { + // Walk patterns for command substitutions + if (item.pattern && Array.isArray(item.pattern)) { + for (const p of item.pattern) { + if (p?.parts && Array.isArray(p.parts)) { + for (const part of p.parts) { + walkNode(part, names); + } + } + } + } + walkCompoundList(item.body, names); + } + } + break; + } + + case "Function": { + // Function definition — walk the body for blocked commands + if (node.body) { + walkNode(node.body, names); + } + break; + } + + case "BraceGroup": { + // { cmd; } grouping + walkCompoundList(node.body, names); + break; + } + + case "ArithmeticFor": { + // for ((i=0; i<10; i++)); do ...; done + walkCompoundList(node.body, names); + break; + } + + case "Coproc": { + // coproc cmd — the body is a Command node + if (node.body) { + walkNode(node.body, names); + } + break; + } + + case "CommandExpansion": + case "ProcessSubstitution": { + if (node.script) { + walkScript(node.script, names); + } + break; + } + + case "CompoundList": { + walkCompoundList(node, names); + break; + } + } +} diff --git a/.pi/extensions/sensitive-file-guard/extractCommandNames.test.ts b/.pi/extensions/sensitive-file-guard/extractCommandNames.test.ts new file mode 100644 index 00000000..83e19bff --- /dev/null +++ b/.pi/extensions/sensitive-file-guard/extractCommandNames.test.ts @@ -0,0 +1,217 @@ +import { extractCommandNames } from "./ast.ts"; + +// --- Test runner --- +const blocked = new Set(["printenv", "env"]); + +const tests: { + cmd: string; + expected: "BLOCKED" | "ALLOWED"; + reason: string; +}[] = [ + // Simple commands + { cmd: "printenv", expected: "BLOCKED", reason: "Simple command match" }, + { cmd: "env FOO=bar", expected: "BLOCKED", reason: "Simple command match" }, + { + cmd: "FOO=bar printenv", + expected: "BLOCKED", + reason: "Assignment prefix — printenv is still the command", + }, + { + cmd: "FOO=bar env", + expected: "BLOCKED", + reason: "Assignment prefix — env is still the command", + }, + + // Command substitution — blocked commands inside $() or backticks run + { cmd: "$(env)", expected: "BLOCKED", reason: "Command substitution" }, + { cmd: "$(printenv)", expected: "BLOCKED", reason: "Command substitution" }, + { + cmd: "`env`", + expected: "BLOCKED", + reason: "Backtick command substitution", + }, + { + cmd: "echo $(env)", + expected: "BLOCKED", + reason: "env inside command substitution in echo argument", + }, + { + cmd: "FOO=$(env) echo hi", + expected: "BLOCKED", + reason: "env inside command substitution in assignment value", + }, + + // Subshell + { cmd: "(env)", expected: "BLOCKED", reason: "Subshell — env runs inside" }, + + // Pipeline — any member triggers a block + { + cmd: "env | grep FOO", + expected: "BLOCKED", + reason: "Pipeline — env is first command", + }, + { + cmd: "grep FOO | env", + expected: "BLOCKED", + reason: "Pipeline — env is second command", + }, + { + cmd: "printenv | env", + expected: "BLOCKED", + reason: "Both blocked commands in pipeline", + }, + + // Compound lists and control flow + { + cmd: "for f in *; do env; done", + expected: "BLOCKED", + reason: "env in for loop body", + }, + { + cmd: "for f in $(env); do echo $f; done", + expected: "BLOCKED", + reason: "env in for wordlist command substitution", + }, + { + cmd: "for ((i=0;i<10;i++)); do env; done", + expected: "BLOCKED", + reason: "env in arithmetic for", + }, + { + cmd: "while true; do env; done", + expected: "BLOCKED", + reason: "env in while body", + }, + { + cmd: "until false; do env; done", + expected: "BLOCKED", + reason: "env in until body (parsed as While with kind:until)", + }, + { + cmd: "if true; then env; fi", + expected: "BLOCKED", + reason: "env in if-then", + }, + { + cmd: "case x in a) env;; esac", + expected: "BLOCKED", + reason: "env in case body", + }, + { + cmd: "case $(env) in a) echo hi;; esac", + expected: "BLOCKED", + reason: "env in Case.word substitution", + }, + { cmd: "true && env", expected: "BLOCKED", reason: "env in AndOr (&&)" }, + { + cmd: "select f in $(env); do echo $f; done", + expected: "BLOCKED", + reason: "env in select wordlist", + }, + + // Function definition, brace group, coproc + { + cmd: "foo() { env; }", + expected: "BLOCKED", + reason: "env in function body", + }, + { cmd: "{ env; }", expected: "BLOCKED", reason: "env in brace group" }, + { cmd: "coproc env", expected: "BLOCKED", reason: "env in coproc body" }, + + // --- ALLOWED: blocked command name is NOT at an executable position --- + + { + cmd: "echo printenv", + expected: "ALLOWED", + reason: "printenv is an argument to echo, not a command", + }, + { cmd: "# printenv", expected: "ALLOWED", reason: "Comment — not executed" }, + { + cmd: 'echo "use env to..."', + expected: "ALLOWED", + reason: "Inside double-quoted string", + }, + { + cmd: "echo 'printenv'", + expected: "ALLOWED", + reason: "Inside single-quoted string", + }, + { + cmd: "(echo printenv)", + expected: "ALLOWED", + reason: "printenv is argument to echo inside subshell", + }, + { + cmd: "$(echo env)", + expected: "ALLOWED", + reason: "env is argument to echo inside command substitution", + }, + { + cmd: 'echo "use env" | grep FOO', + expected: "ALLOWED", + reason: "env in double-quoted string in pipeline", + }, + + // --- ALLOWED: command is not in the blocked set --- + { + cmd: "export FOO=bar", + expected: "ALLOWED", + reason: "export is not in blocked_commands", + }, + { + cmd: "ls -la", + expected: "ALLOWED", + reason: "ls is not in blocked_commands", + }, + { + cmd: "echo hi; echo there", + expected: "ALLOWED", + reason: "Semicolon-separated harmless commands", + }, + { + cmd: "echo hi || echo fail", + expected: "ALLOWED", + reason: "Or-separated harmless commands", + }, + { + cmd: "[[ -f file ]]", + expected: "ALLOWED", + reason: "TestCommand — no commands executed", + }, + + // --- ALLOWED: no command at all --- + { cmd: "", expected: "ALLOWED", reason: "Empty command" }, + { + cmd: "FOO=bar", + expected: "ALLOWED", + reason: "Bare assignment, no command name", + }, + { + cmd: ">/dev/null", + expected: "ALLOWED", + reason: "Redirection only, no command name", + }, +]; + +let passed = 0; +let failed = 0; + +for (const { cmd, expected, reason } of tests) { + const names = extractCommandNames(cmd); + const hasBlocked = [...names].some((n) => blocked.has(n)); + const actual = hasBlocked ? "BLOCKED" : "ALLOWED"; + + if (actual === expected) { + passed++; + console.log(`✅ ${expected.padEnd(7)} | ${cmd.padEnd(45)} | ${reason}`); + } else { + failed++; + console.log( + `❌ expected ${expected.padEnd(7)} got ${actual.padEnd(7)} | ${cmd.padEnd(45)} | ${reason}`, + ); + console.log(` Command names found: [${[...names].join(", ")}]`); + } +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/.pi/extensions/000-sensitive-file-guard.ts b/.pi/extensions/sensitive-file-guard/index.ts similarity index 67% rename from .pi/extensions/000-sensitive-file-guard.ts rename to .pi/extensions/sensitive-file-guard/index.ts index cf575212..288cf085 100644 --- a/.pi/extensions/000-sensitive-file-guard.ts +++ b/.pi/extensions/sensitive-file-guard/index.ts @@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; import { readFileSync } from "node:fs"; import { normalize, resolve } from "node:path"; +import { extractCommandNames } from "./ast.ts"; interface Config { blocked_paths: string[]; @@ -30,6 +31,11 @@ export default function (pi: ExtensionAPI) { } const pathRegexes = config.blocked_paths.map(patternToRegex); + // Build a Set of lowercased blocked command names for exact matching. + // Also build regexes for regex fallback when parsing fails. + const blockedCommands = new Set( + config.blocked_commands.map((c) => c.toLowerCase()), + ); const commandRegexes = config.blocked_commands.map((r) => new RegExp(r, "i")); // Proactively tell the agent which paths are off-limits so it doesn't try in the first place @@ -98,14 +104,35 @@ export default function (pi: ExtensionAPI) { return { block: true, reason }; } - // Check for blocked commands (env, printenv, set) - const cmdHit = commandRegexes.find((r) => r.test(command)); - if (cmdHit) { - const reason = - `🚫 ACCESS DENIED by sensitive-file-guard: this command matches a blocked pattern. ` + - `DO NOT retry. Tell the user the command is blocked and STOP.`; - if (ctx.hasUI) ctx.ui.notify(`Blocked sensitive command`, "warning"); - return { block: true, reason }; + // --- Parse command structurally to detect blocked commands --- + const parsedNames = extractCommandNames(command); + + // Use parsed names if we got any (or if the command parsed to empty + // commands, which means it's a comment or similar non-executable text). + // Fall back to regex matching if parsing returned an empty set for a + // non-empty command — this handles parse failures gracefully. + if (parsedNames.size > 0 || command.trim().length === 0) { + const cmdHit = [...parsedNames].find((name) => + blockedCommands.has(name), + ); + if (cmdHit) { + const reason = + `🚫 ACCESS DENIED by sensitive-file-guard: this command matches a blocked pattern. ` + + `DO NOT retry. Tell the user the command is blocked and STOP.`; + if (ctx.hasUI) ctx.ui.notify(`Blocked sensitive command`, "warning"); + return { block: true, reason }; + } + } else { + // Fallback: parsing returned nothing for a non-empty command. + // Use regex-based matching as a conservative safety net. + const cmdHit = commandRegexes.find((r) => r.test(command)); + if (cmdHit) { + const reason = + `🚫 ACCESS DENIED by sensitive-file-guard: this command matches a blocked pattern. ` + + `DO NOT retry. Tell the user the command is blocked and STOP.`; + if (ctx.hasUI) ctx.ui.notify(`Blocked sensitive command`, "warning"); + return { block: true, reason }; + } } } }); diff --git a/.pi/extensions/sensitive-file-guard/package-lock.json b/.pi/extensions/sensitive-file-guard/package-lock.json new file mode 100644 index 00000000..4fd1bec9 --- /dev/null +++ b/.pi/extensions/sensitive-file-guard/package-lock.json @@ -0,0 +1,567 @@ +{ + "name": "sensitive-file-guard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sensitive-file-guard", + "dependencies": { + "unbash": "3.0.0" + }, + "devDependencies": { + "tsx": "^4.21.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/unbash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", + "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + } + } +} diff --git a/.pi/extensions/sensitive-file-guard/package.json b/.pi/extensions/sensitive-file-guard/package.json new file mode 100644 index 00000000..6a56f0dd --- /dev/null +++ b/.pi/extensions/sensitive-file-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "sensitive-file-guard", + "private": true, + "type": "module", + "dependencies": { + "unbash": "3.0.0" + }, + "devDependencies": { + "tsx": "^4.21.0" + } +} diff --git a/.pi/sensitive-paths.json b/.pi/sensitive-paths.json index 43629ed5..fb40d057 100644 --- a/.pi/sensitive-paths.json +++ b/.pi/sensitive-paths.json @@ -1,5 +1,5 @@ { - "_comment": "Blocked paths for pi. Glob-like patterns, matched case-insensitively against absolute paths.", + "_comment": "Blocked paths for pi. Glob-like patterns, matched case-insensitively against absolute paths. blocked_commands are exact command names (not regexes), matched case-insensitively against structurally parsed bash AST command names.", "blocked_paths": [ "mise.local.toml", "*.pem", @@ -15,6 +15,6 @@ ], "blocked_commands": [ "printenv", - "\\benv\\b" + "env" ] } diff --git a/backlog/tasks/ml-175 - Parse-bash-commands-in-sensitive-file-guard.md b/backlog/tasks/ml-175 - Parse-bash-commands-in-sensitive-file-guard.md index f44c5321..40eb1eae 100644 --- a/backlog/tasks/ml-175 - Parse-bash-commands-in-sensitive-file-guard.md +++ b/backlog/tasks/ml-175 - Parse-bash-commands-in-sensitive-file-guard.md @@ -1,10 +1,10 @@ --- id: ML-175 title: Parse bash commands in sensitive file guard -status: To Do +status: In Progress assignee: [] created_date: "2026-05-10 06:29" -updated_date: "2026-05-11 06:46" +updated_date: "2026-05-11 16:41" labels: - pi - ready @@ -15,6 +15,14 @@ references: documentation: - "https://github.com/webpro-nl/unbash#readme" - "https://www.npmjs.com/package/unbash" +modified_files: + - .pi/extensions/sensitive-file-guard/ast.ts + - .pi/extensions/sensitive-file-guard/index.ts + - .pi/extensions/sensitive-file-guard/package.json + - .pi/extensions/sensitive-file-guard/package-lock.json + - .pi/extensions/sensitive-file-guard/extractCommandNames.test.ts + - .pi/sensitive-paths.json + - .gitignore priority: medium --- @@ -30,13 +38,13 @@ Make `.pi/extensions/000-sensitive-file-guard.ts` more robust by parsing bash to -- [ ] #1 Bash tool command blocking is based on parsed shell syntax rather than regex matching over the entire command string. -- [ ] #2 Actual invocations of configured blocked commands such as `env` and `printenv` are blocked when used as simple commands. -- [ ] #3 Blocked commands are also detected in nested shell syntax that can execute commands, including command substitutions and subshells. -- [ ] #4 Non-executed text such as quoted strings and comments does not trigger blocked command detection. -- [ ] #5 Existing sensitive path blocking behavior for non-bash tools remains unchanged. -- [ ] #6 The implementation degrades safely if bash parsing fails, either by blocking the command or falling back to the existing conservative text checks. -- [ ] #7 Focused tests or a documented local verification cover allowed and blocked bash examples, including false-positive and nested-command cases. +- [x] #1 Bash tool command blocking is based on parsed shell syntax rather than regex matching over the entire command string. +- [x] #2 Actual invocations of configured blocked commands such as `env` and `printenv` are blocked when used as simple commands. +- [x] #3 Blocked commands are also detected in nested shell syntax that can execute commands, including command substitutions and subshells. +- [x] #4 Non-executed text such as quoted strings and comments does not trigger blocked command detection. +- [x] #5 Existing sensitive path blocking behavior for non-bash tools remains unchanged. +- [x] #6 The implementation degrades safely if bash parsing fails, either by blocking the command or falling back to the existing conservative text checks. +- [x] #7 Focused tests or a documented local verification cover allowed and blocked bash examples, including false-positive and nested-command cases. ## Implementation Plan