pi-blocklist
Blocks destructive shell commands before a pi agent runs them. A linter for accidents, not a sandbox.
Package details
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.

> 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.tshas no imports at all — pure string processing, no I/O.command-blocklist/index.tsimports onlyappendFileSync,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
bashtool — 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, orshell_commandfield is checked; add exact tools toEXPLICIT_SHELL_TOOLSinindex.ts. - Your own
!commandtyping — 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 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.
