A pi extension that exposes an ssh_exec
tool to the LLM for running allow-listed, read-only commands on remote
hosts over SSH.
The shipped
commands.yamlsetssettings.strict_mode: true, which removes pi's built-inbashtool from the active toolset while this extension is loaded. This is intentional and is the whole point of the extension.Without strict mode, the LLM could trivially bypass every safeguard here by just calling
bashwithssh user@host '<whatever>'— every allowlist, every regex, every host gate would be meaningless. If you wantssh_execto mean anything at all, the bash tool has to go.If you don't want this behaviour (e.g. you're fine with the LLM running arbitrary shell commands locally and only want
ssh_execas a convenience wrapper), setsettings.strict_mode: falsein yourcommands.yamland run/ssh-reload. The built-inbashtool will be restored.
- Registers a new tool
ssh_exec(host, command, timeout_sec?). - Validates
commandagainst a YAML allowlist before anything touches SSH. - Rejects all shell metacharacters — no pipes, redirects, command substitution, heredocs, backgrounding, or newlines.
- Rejects hosts not in the configured list.
- Runs the command with
ssh -T -o BatchMode=yes(no password prompts, no TTY). - Enforces per-call timeout and a hard cap on returned output bytes.
- Optional: disables the built-in
bashtool entirely (strict_mode: true). - Optional: audit log of every attempted command.
- Ships a self-test suite that runs on load and on
/ssh-reloadto catch policy-file mistakes (e.g. someone accidentally addingbashto the list).
Install the extension globally into pi. This works on any machine with pi
installed — you do not need npm or node on your PATH, because pi
manages the package fetch and resolution internally.
pi install npm:@codingcoffee/pi-readonly-sshVerify:
pi list # should show @codingcoffee/pi-readonly-ssh
pi # launch pi; look for "ro-ssh: N cmds, ..." in the footerInside pi, try /ssh-allowed and /ssh-hosts.
pi install shells out to npm for the fetch. If the target machine has Bun
but no npm, point pi at Bun's bundled npm wrapper by adding this to
~/.pi/agent/settings.json:
{
"npmCommand": ["bun", "x", "--bun", "npm"]
}Then run pi install npm:@codingcoffee/pi-readonly-ssh as normal.
Pi clones the repo directly — no npm needed on the host:
pi install git:github.com/codingcoffee/pi-readonly-ssh
# or pinned to a release tag:
pi install git:github.com/codingcoffee/pi-readonly-ssh@v0.1.0pi -e npm:@codingcoffee/pi-readonly-ssh
# or from git:
pi -e git:github.com/codingcoffee/pi-readonly-sshTo install into the current project only (writes to .pi/settings.json,
shareable via git — pi auto-installs on startup for teammates):
pi install -l npm:@codingcoffee/pi-readonly-sshpi remove npm:@codingcoffee/pi-readonly-ssh
# or, if installed from git:
pi remove git:github.com/codingcoffee/pi-readonly-sshClone the repo and run directly:
git clone https://github.com/codingcoffee/pi-readonly-ssh.git
cd pi-readonly-ssh
bun install
pi -e ./index.tsOn first run the extension seeds an editable copy at
$XDG_CONFIG_HOME/pi-readonly-ssh/commands.yaml (defaults to
~/.config/pi-readonly-ssh/commands.yaml). Edit that file — upgrades via
pi install will never overwrite it because it lives outside the package.
At load time (and on every /ssh-reload) these paths are checked in order;
the first one that exists wins:
| # | Path | Purpose |
|---|---|---|
| 1 | $READONLY_SSH_CONFIG |
Explicit override via env var. Highest priority. |
| 2 | ./.pi/readonly-ssh/commands.yaml |
Project-local (CWD-relative). Check into git to share with your team. |
| 3 | $XDG_CONFIG_HOME/pi-readonly-ssh/commands.yaml |
Per-user global. Auto-seeded from the bundled default on first run. Falls back to ~/.config/... if $XDG_CONFIG_HOME is unset. |
| 4 | <installed-package>/commands.yaml |
Bundled default shipped inside the npm tarball. Read-only — treat as a template. |
The active path is printed in the header of /ssh-allowed and /ssh-hosts
so you can always tell which file is in effect.
settings:
strict_mode: false # true = disable built-in `bash` tool
max_output_bytes: 1048576
default_timeout_sec: 30
allow_globs: true
allow_any_host: false # true = let the LLM target any ssh host
audit_log: ~/.pi/readonly-ssh.log
hosts:
- name: prod-web-1
ssh: deploy@prod-web-1.example.com
- name: staging
ssh: staging # an alias from ~/.ssh/config
commands:
- name: systemctl
subcommands: [status, is-active, is-enabled, show, list-units, cat]
# ...After edits: /ssh-reload in pi (no restart needed). /ssh-reload also
re-runs the priority chain — so if you just created a new project-local
./.pi/readonly-ssh/commands.yaml, it will be picked up without restarting.
| Command | Purpose |
|---|---|
/ssh-allowed |
Print the current command allowlist |
/ssh-hosts |
Print the configured hosts |
/ssh-reload |
Re-read commands.yaml and re-run self-tests |
-
Raw-string scan. If the input contains any of
| & ; > < \$( $ { <( >( ` or a newline, reject. This happens on the raw string before any parsing, so quoting tricks don't help. -
Heredoc scan.
<<or<<<→ reject. - shell-quote parse. If the parser produces any non-string token (operators, comments, unquoted globs), reject.
-
Glob scan. Unless
allow_globs: true, reject tokens containing*,?,{a,b}, or a leading~. -
Allowlist lookup.
basename(argv[0])must be incommands:. Otherwise reject. -
Subcommand check. If the rule has
subcommands:,argv[1]must match. -
Banned flags.
banned_flagsmatched exactly against everyargv[1..]token (and against--flagprefix of--flag=value). -
Banned arg regex.
banned_args_regexmatched against everyargv[1..]token. -
max_args. Enforce the cap. -
sudospecial-case. Stripsudo+-u/-g/...args, then recursively validate the inner command against the same allowlist (depth-limited).sudo bashfails becausebashisn't allowed;sudo systemctl status nginxworks. -
Transport. Each argv token is single-quoted (
'…'with'\''escaping) before being joined and passed as one string tossh <host> -- …. Even if a token contained what looks like a metacharacter, the remote shell sees it as a literal.
This extension assumes:
- The LLM may try to construct arbitrary commands, including malicious ones.
- The LLM will not discover new hosts it isn't told about (hosts are gated).
- The SSH account on the remote is trusted only insofar as its own permissions go. If you give the remote account write access, commands that are read-only in spirit can still be chained externally. Give the SSH user the least privilege it needs.
- The
commands.yamlfile itself is trusted (it's edited by the user).
What this extension does prevent:
- Pipes, redirects, chaining, backgrounding, heredocs.
- Command substitution and parameter expansion.
- Running a disallowed binary (including
bash,sh,tee,dd,scp,rsync,ssh,nc, etc. — simply by not listing them). - Running a disallowed subcommand of an allowed multi-verb tool (
kubectl deleteis rejected even thoughkubectl getis fine). - Dangerous flags on otherwise-safe tools (
find -delete,tail -f,journalctl -f,curl -X POST, etc.). - Targeting hosts not in the allowlist (when
allow_any_host: false). - Unbounded output size or runtime.
- The LLM passing
@-prefixed commands.
What this extension does not try to prevent:
- Reading secrets that the remote account can read. If the user doesn't want
the LLM to see
/etc/shadow, don't give the SSH account sudo access to read it and don't add commands that can read it. - Covert channels via DNS or network probes that are themselves in the
allowlist (e.g.
dig,curl). Remove those if you care. - Logic bugs in the remote commands themselves (e.g. a buggy
systemctl statusthat somehow writes).
Add a commands: entry:
- name: mytool
subcommands: [inspect, report]
banned_flags: ["--write", "--apply"]
max_args: 8Then /ssh-reload. The self-test suite runs automatically to verify the
universal "must-reject" cases still fail.
user> Can you check why nginx is unhappy on prod-web-1?
assistant [ssh_exec host=prod-web-1 command="systemctl status nginx"]
... output ...
assistant [ssh_exec host=prod-web-1 command="journalctl -u nginx -n 200"]
... output ...
assistant [ssh_exec host=prod-web-1 command="ls /var/log/nginx"]
... output ...
If the assistant tries:
ssh_exec host=prod-web-1 command="tail -f /var/log/nginx/error.log | grep 500"
…it gets:
REJECTED by readonly-ssh guard: pipe '|' is not allowed; ssh_exec does not run shell pipelines
and re-plans with discrete calls.