pi-blocklist

Blocks destructive shell commands before a pi agent runs them. A linter for accidents, not a sandbox.

Packages

Package details

extension

Install pi-blocklist from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-blocklist
Package
pi-blocklist
Version
0.1.1
Published
Sep 8, 2026
Downloads
not available
Author
seraphimserapis
License
MIT
Types
extension
Size
326.8 KB
Dependencies
0 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./command-blocklist/index.ts"
  ],
  "image": "https://github.com/SeraphimSerapis/pi-blocklist/raw/main/media/screenshot.png"
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-blocklist

A pi extension that stops an agent from running destructive shell commands.

It parses each command the agent is about to execute, works out which program will run and which paths that program will actually touch after expansion, and refuses the ones that would destroy your machine or your data.

The guard refusing a deletion whose target depends on an unset variable

> rm -rf "$STEAMROOT/"*

Blocked by command-blocklist. `rm $STEAMROOT/*` targets /* if that variable is unset
or empty. Assign it and guard with `${VAR:?}` before deleting.

Segment: rm -rf "$STEAMROOT/"*
Rule: rm.unset-variable

Read this before you install it

This is a linter, not a sandbox. It reads a command as text. Anything computed at runtime is invisible to it:

bash -c "$(echo cm0gLXJmIC8K | base64 -d)"   # runs `rm -rf /` — not caught

So is a script written by the write tool and executed on the next turn, and so is any compiled binary. Deciding those requires running them.

What it does catch is accidents — the flag you fat-fingered, the variable that was never set, the cd you forgot you were still inside. That is what actually destroys people's data. Treat it as a seatbelt, not a locked door. If you need a real boundary, run the agent in a container.


What it can do to your machine

Worth checking rather than trusting, for something that sees every command your agent runs:

  • command-blocklist/analyze.ts has no imports at all — pure string processing, no I/O.
  • command-blocklist/index.ts imports only appendFileSync, homedir, platform, join.
  • Zero runtime dependencies.

The entire trust surface is appending to one log file inside your pi agent directory. No network, no subprocesses, no eval.

Package details

Type extension
Entry point command-blocklist/index.ts
Dependencies none (the analyzer is pure TypeScript)
Requires pi >= 0.74.0 (every release published to npm exports the APIs it uses)
Verified on pi 0.85.1, macOS 26 (arm64), Ubuntu 26.04 (x86_64)
Platforms POSIX shells. See Platform support

Install

pi install npm:pi-blocklist

Project-locally instead of globally:

pi install npm:pi-blocklist -l

Straight from the repository, to track main:

pi install git:github.com/SeraphimSerapis/pi-blocklist

From a working copy, without installing:

pi -e ./command-blocklist/index.ts

Or drop it straight into your extensions directory — ~/.pi/agent/extensions/ for all sessions, ./.pi/extensions/ for one project:

ln -s ~/Code/pi-blocklist/command-blocklist ~/.pi/agent/extensions/command-blocklist

Verify it loaded:

/blocklist-check rm -rf /

What you get

Three surfaces are guarded:

  • The bash tool — the agent's main way to run commands.
  • Custom and MCP tools that shell out. These arrive as ordinary tool calls and would otherwise skip the guard entirely. Any tool carrying a string command, cmd, or shell_command field is checked; add exact tools to EXPLICIT_SHELL_TOOLS in index.ts.
  • Your own !command typing — deny-tier only, since you typed it deliberately.

Plus a /blocklist-check <command> slash command that dry-runs a command through the rules without executing it, and an append-only JSONL audit log at $PI_CODING_AGENT_DIR/command-blocklist.log (default ~/.pi/agent/command-blocklist.log) recording every decision:

{"at":"2026-09-07T17:57:38.195Z","verdict":"deny","tool":"bash","rule":"rm.recursive-critical","command":"rm -rf /"}

When the guard misfires, that log is the evidence. Read it before you change a rule. It is created mode 0600, because commands routinely carry secrets as arguments.


Two tiers

Verdict Behaviour
deny Refused. The agent is told why and can adjust.
confirm Prompts you; runs only on an explicit yes.

The confirm tier prompting before a git reset --hard

The second tier exists to protect the first. Without somewhere to put git reset --hard and apt purge, everything gets crammed into deny, the guard starts blocking real work, and you turn it off. That is the failure mode this design cares most about.

With no UI attached — pi -p, RPC — a confirm fails closed and blocks, telling the agent that confirmation was needed and nobody was there to give it. An aborted prompt is also not consent.


How it works

command string
  │
  ├─ 1. lift out heredoc bodies          data, not commands
  ├─ 2. tokenize                          quotes, escapes, $'…', ${…}, $(…), backticks
  │                                       split on ; && || | & and newlines
  ├─ 3. resolve each segment              strip VAR=x, sudo, env, xargs, chroot /mnt
  │                                       → basename(argv[0])
  ├─ 4. classify paths                    expand ~ $HOME $PWD .. and in-command vars
  │                                       → critical | sensitive | safe
  └─ 5. run 19 rules                      most severe finding wins

The whole design rests on asking two structural questions — what program runs, and what path does it actually touch — rather than matching the command text against patterns. Pattern matching is simultaneously too loose and too tight: a glob for rm -rf /* also matches rm -rf /tmp/build, and one for halt* blocks cat asphalt.txt.

Because the questions are structural, one rm rule covers every spelling at once:

rm -rf /            rm -fr /              rm -r -f /
rm --recursive --force /                  rm -rf "$HOME"
rm -rf ~            rm -rf /users/tim     # macOS filesystems are case-insensitive
FOO="a b" rm -rf /  sudo -u root rm -rf /  xargs rm -rf /
cd / && rm -rf .    rm -rf \<newline>/    bash <<< 'rm -rf /'
find / -name '*.log' | xargs rm -rf       echo 'rm -rf /' | bash

Path tiers

Tier Examples
critical /, $HOME, system roots at depth ≤ 2 — /etc, /usr/local, /Users/you, /home/you
sensitive deeper under a system root, or one level under home — ~/Documents, /usr/local/lib/x
safe everything else, plus scratch (/tmp, /var/folders, /dev/shm) and /dev/null

A glob is judged by its shallowest component, so /home/*/Documents is treated as /home.


What it catches

Area Examples
Deletion rm at root/home/system paths, --no-preserve-root, find -delete, find | xargs rm, shred
Unset variables rm -rf "$UNSET/"* — judged by its empty expansion
Disks dd of=/dev/…, > /dev/sda, tee /dev/sda, mkfs, wipefs, fdisk, diskutil erase*
Storage stack lvremove, vgremove, blkdiscard, mdadm --zero-superblock, cryptsetup luksErase
Power shutdown, reboot, halt, poweroff, systemctl poweroff, init 0
System files truncating /etc/passwd, chmod -R on /, cp/ln/tee over critical paths
Posture csrutil disable, spctl --master-disable, nvram -c
Accounts userdel -r, deluser --remove-home
Confirm tier git reset --hard, git clean -fdx, git push --force, apt purge, dpkg --purge, grub-install, iptables -F, curl | sh

And what it deliberately leaves alone

False positives are how a guard gets disabled, so these stay silent:

cat asphalt.txt                       # `halt*` used to match this
grep -r shutdown src/                 # the word is not the command
./scripts/reboot-staging.sh
rm -rf node_modules && rm -rf ./dist
rm -rf /tmp/build-cache
OUT=dist; rm -rf "$OUT"/*             # assigned in-command, so resolvable
rm -rf "${BUILD_DIR:?}"/*             # the guard idiom the deny message recommends
sudo apt-get install -y jq
find . -name '*.tmp' -delete
cat > doc.md <<'EOF'                  # a heredoc body is data
never run rm -rf /
EOF

Related distinctions the rules make rather than flattening: > /etc/passwd denies but >> /etc/hosts only confirms, because truncating a system file and appending a line to one are different acts. Same for rm -rf ~/Code/project/dist (allowed, inside the working directory) against rm -rf ~/Downloads (confirm).


Tuning it

Rules live in command-blocklist/analyze.ts as small functions of one shape. To add a whole-command pattern without writing code, append to LEGACY_DENY_GLOBS:

export const LEGACY_DENY_GLOBS = [
  "diskutil erase*",
  "mkfs*",
  "shutdown*",
];

These are anchored and matched per segment, so halt* means "this command starts with halt". They are a poor way to express a path policy — * crosses /, so rm -rf /* would also match rm -rf /tmp/build. For anything path-shaped, add a rule instead and let classifyPath do the work.

Whether a given command should deny or confirm is a judgement call baked into the source. If you disagree with one, change the verdict on that rule and add a test both ways.


Platform support

Platform Status
macOS Verified. Case-insensitive path matching, diskutil, csrutil, spctl, tmutil, pmset
Debian / Ubuntu Verified on Ubuntu 26.04. systemd, LVM/RAID/LUKS, apt/dpkg, grub, iptables/nft/ufw
Other Linux Should work; the POSIX path model is shared. Untested
Windows POSIX shells only. The rules apply under WSL and Git Bash, but know nothing about cmd or PowerShell — format C:, del /s, rmdir /s all pass. The extension warns at session start on win32

Development

node --test command-blocklist/analyze.test.ts

206 cases covering both a macOS and a Debian/Ubuntu path layout, the tokenizer, the path classifier, runtime bounds on pathological input, and a seeded fuzz pass over 20,000 generated shell fragments (asserting the analyzer neither throws nor stalls on hostile input).

Running the suite needs a Node build with TypeScript support — 23.6+ has it on by default, 22.6+ behind --experimental-strip-types. Some distro builds ship without it and fail with ERR_NO_TYPESCRIPT; that affects only the tests, since pi loads extensions through jiti, which transpiles on its own.

analyze.ts has no dependencies and no I/O, and imports nothing from pi — it is reusable anywhere you need to judge a shell command. index.ts is the pi adapter and the only file that knows pi exists.

Releasing

package.json declares the entry point through the pi.extensions manifest and carries the pi-package keyword that lists it on pi.dev/packages. Publishing is npm publish; tag the matching commit.


License

MIT — see LICENSE. Copyright (c) 2026 Tim Messerschmidt.