♠ Problem statement: block destructive shell commands issued by pi without resorting to grepping shell source with regular expressions.
My first guard was a direct port of an older hook in Claude Code:
const BLOCKED_COMMANDS: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
{ pattern: /terraform\s+apply/, reason: "terraform apply is blocked - use terraform plan first" },
{ pattern: /terraform\s+destroy/, reason: "terraform destroy is blocked for safety" },
{ pattern: /git\s+reset\s+.*--hard/, reason: "git reset --hard is blocked - discards changes irreversibly" },
];
const blocked = BLOCKED_COMMANDS.find(({ pattern }) => pattern.test(command));
This matched harmless strings such as echo 'terraform destroy'
, while shell quoting, wrappers, substitutions, and reordered options kept opening holes. Regexes aren’t robust in this context.
Enter ASTs! Shell source could leverage a shell parser.
I switched the extension to the Bash grammar from tree-sitter-bash, loaded through the WASM Tree-sitter runtime:
await Parser.init();
const languagePath = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
const language = await Language.load(languagePath);
return new Parser().setLanguage(language);
Tree sitter is an amazing parser.
The policy now works on executable names and arguments:
if (executable === "rm") {
const recursive = staticArguments.some(
(argument) => argument === "--recursive" || hasShortOption(argument, "r") || hasShortOption(argument, "R"),
);
const force = staticArguments.some(
(argument) => argument === "--force" || hasShortOption(argument, "f"),
);
if (recursive && force) return { command, reason: "rm -rf is blocked for safety" };
}
The AST walker also follows command substitutions, heredocs, bash -c
, eval
,
find -exec
, xargs
, and common wrappers. Comments and quoted data remain data. Malformed syntax and dynamic guarded arguments fail closed.
ℹ tests 48
ℹ suites 0
ℹ pass 48
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Local extensions do not get npm dependencies installed by Pi. I kept index.ts
dependency-free: on first load it checks for the parser files, runs a pinned installation when needed, then imports the real guard.
const result = await pi.exec("npm", ["ci", "--ignore-scripts"], {
cwd: extensionDirectory,
timeout: 120_000,
});
const { default: dangerousCommandGuard } = await import("./guard.ts");
await dangerousCommandGuard(pi);
Finally, testing the real tool call:
% rm -rf /tmp/foo
rm -rf is blocked for safety
% test -f /tmp/foo/marker && cat /tmp/foo/marker
guard-test
The guard blocked the command; /tmp/foo/marker
survived.
The source lives in my dotfiles.
🤖 *Drafted with *
/bloggify
.— § —
Reply via [email](mailto:serendipity@perrotta.dev?subject=Reply to: pi: block dangerous commands syntax-aware)