cd /news/developer-tools/pi-block-dangerous-commands-syntax-a… · home topics developer-tools article
[ARTICLE · art-85808] src=perrotta.dev ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

pi: block dangerous commands syntax-aware

Pi, a command-line tool, now blocks dangerous shell commands using syntax-aware parsing with Tree-sitter instead of regex, preventing false positives and bypasses. The extension, developed by Thiago Perrotta, checks for destructive commands like `rm -rf`, `terraform apply`, and `git reset --hard`, following command substitutions and wrappers, and passes 48 tests.

read2 min views2 publishedJul 28, 2026

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)

── more in #developer-tools 4 stories · sorted by recency
── more on @pi 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/pi-block-dangerous-c…] indexed:0 read:2min 2026-07-28 ·