ML-175: parse bash commands structurally via unbash

Replace regex-based command blocking with structural AST parsing
using unbash (zero-dependency TypeScript bash parser). The walker
collects command names only from executable positions (simple
commands, pipelines, subshells, command substitutions, control-flow
bodies), excluding arguments, comments, and quoted strings.

- Convert to directory-style extension with pinned unbash@3.0.0
- Add AST walker (ast.ts) covering 15 shell construct types
- Update blocked_commands to plain names instead of regexes
- Keep regex fallback for parse failures (degradation path)
- Path-based blocking unchanged for non-bash tools
- 41 tests cover blocked/allowed cases including false positives
This commit is contained in:
Claudio Ortolina
2026-05-11 17:42:48 +01:00
parent 79d821e70f
commit a88c704fe5
8 changed files with 1062 additions and 19 deletions
+1
View File
@@ -56,3 +56,4 @@ npm-debug.log
# Superpowers brainstorming mockups # Superpowers brainstorming mockups
/.superpowers/ /.superpowers/
/.pi/extensions/s3-browser/node_modules /.pi/extensions/s3-browser/node_modules
/.pi/extensions/sensitive-file-guard/node_modules
+212
View File
@@ -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<string>` of lowercased command names, or an empty set if
* parsing fails.
*/
export function extractCommandNames(command: string): Set<string> {
const names = new Set<string>();
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<string>): void {
const commands = script?.commands;
if (!Array.isArray(commands)) return;
for (const stmt of commands) {
walkStatement(stmt, names);
}
}
function walkStatement(stmt: any, names: Set<string>): void {
if (!stmt) return;
walkNode(stmt.command, names);
}
function walkCompoundList(node: any, names: Set<string>): 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<string>): 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;
}
}
}
@@ -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);
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { normalize, resolve } from "node:path"; import { normalize, resolve } from "node:path";
import { extractCommandNames } from "./ast.ts";
interface Config { interface Config {
blocked_paths: string[]; blocked_paths: string[];
@@ -30,6 +31,11 @@ export default function (pi: ExtensionAPI) {
} }
const pathRegexes = config.blocked_paths.map(patternToRegex); 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")); 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 // Proactively tell the agent which paths are off-limits so it doesn't try in the first place
@@ -98,7 +104,27 @@ export default function (pi: ExtensionAPI) {
return { block: true, reason }; return { block: true, reason };
} }
// Check for blocked commands (env, printenv, set) // --- 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)); const cmdHit = commandRegexes.find((r) => r.test(command));
if (cmdHit) { if (cmdHit) {
const reason = const reason =
@@ -108,5 +134,6 @@ export default function (pi: ExtensionAPI) {
return { block: true, reason }; return { block: true, reason };
} }
} }
}
}); });
} }
+567
View File
@@ -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"
}
}
}
}
@@ -0,0 +1,11 @@
{
"name": "sensitive-file-guard",
"private": true,
"type": "module",
"dependencies": {
"unbash": "3.0.0"
},
"devDependencies": {
"tsx": "^4.21.0"
}
}
+2 -2
View File
@@ -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": [ "blocked_paths": [
"mise.local.toml", "mise.local.toml",
"*.pem", "*.pem",
@@ -15,6 +15,6 @@
], ],
"blocked_commands": [ "blocked_commands": [
"printenv", "printenv",
"\\benv\\b" "env"
] ]
} }
@@ -1,10 +1,10 @@
--- ---
id: ML-175 id: ML-175
title: Parse bash commands in sensitive file guard title: Parse bash commands in sensitive file guard
status: To Do status: In Progress
assignee: [] assignee: []
created_date: "2026-05-10 06:29" created_date: "2026-05-10 06:29"
updated_date: "2026-05-11 06:46" updated_date: "2026-05-11 16:41"
labels: labels:
- pi - pi
- ready - ready
@@ -15,6 +15,14 @@ references:
documentation: documentation:
- "https://github.com/webpro-nl/unbash#readme" - "https://github.com/webpro-nl/unbash#readme"
- "https://www.npmjs.com/package/unbash" - "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 priority: medium
--- ---
@@ -30,13 +38,13 @@ Make `.pi/extensions/000-sensitive-file-guard.ts` more robust by parsing bash to
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Bash tool command blocking is based on parsed shell syntax rather than regex matching over the entire command string. - [x] #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. - [x] #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. - [x] #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. - [x] #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. - [x] #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. - [x] #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] #7 Focused tests or a documented local verification cover allowed and blocked bash examples, including false-positive and nested-command cases.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan