# opl-guardian

A configurable session and tool safety extension. `opl-guardian` filters malformed assistant tool calls, prompts before dangerous Bash commands, blocks operations on protected paths, and confirms destructive session transitions. It replaces the standalone `permission-gate`, `protected-paths`, and `confirm-destructive` extensions while retaining malformed-call diagnostics. It adds no tools, commands, prompts, or model-facing schema overhead. All Guardian notification, selection-prompt, and confirmation message text is rendered in fixed red, regardless of Pi's theme; choice labels retain Pi's normal styling.

## Configuration

Copy `configs/opl-guardian.json.sample` to `~/.pi/agent/configs/opl-guardian.json` and customize it. For a package install, find the sample under the installed package's `configs/` directory. All settings are optional; absent settings use the defaults from the sample.

- `permissionGate.patterns` is a list of case-insensitive JavaScript regex strings. A match prompts for confirmation in the terminal or over RPC; Yes allows the command, while No or cancel blocks it. The prompt defaults to No (fail closed), so an accidental or unattended confirmation denies rather than approves. An empty list disables pattern matching. `permissionGate.blockWithoutUI` defaults to `true` and blocks a match when confirmation UI is unavailable. Configured patterns replace the defaults; update existing config files manually to adopt changes to default patterns.
- `protectedPaths.paths` contains `{ "path", "deny" }` entries. Valid denied operations are `read`, `write`, `edit`, and `bash`. Bare names match exact path segments for file tools; Bash matches path-shaped words in the command, resolved against the session directory and through symlinks. Absolute and `~/` entries match resolved paths and descendants for file tools, and are matched in Bash by the same resolution plus the final path segment. An empty list disables path rules. Entries name exact paths: `.env` does not cover `.env.local` or `.env.production`, and no glob syntax is supported — list each path you want denied.
- `confirmDestructive` controls confirmations for starting a new session (`clearSession`), resuming another session when unanswered user work exists (`switchWithUnsavedWork`), and forking (`forkSession`). All default to `true`. `confirmDestructive.blockWithoutUI` also defaults to `true`; set it to `false` to allow these actions without an interactive confirmation.
- `dropMalformedToolCalls` defaults to `true`. Set it to `false` to disable only malformed-call filtering and its incident log.
- `logging.incidentFile` chooses where incidents go. It defaults to `null`, which is `<agent dir>/guardian-incidents.jsonl`; a relative path also resolves under the agent dir, an absolute or `~/` path is used as given, and `false` stops writing. The session cwd is never consulted, so opening a repository cannot leave a log inside it.
- `logging.maxBytes` defaults to `262144`. Before an append that would pass it, the current log moves to `guardian-incidents.jsonl.1` and a fresh file starts: one generation of history, nothing older is kept.

Malformed or invalid config sections generate a warning when a UI is available and fall back to safe defaults for the affected settings. Invalid individual regexes or protected-path entries are reported; valid entries continue to apply. Empty arrays are explicit opt-outs.

If migrating from the standalone extensions, manually move their settings into the corresponding `permissionGate` and `protectedPaths` sections. `opl-guardian` does not read or merge legacy config files. Credits to pi's sample extensions [here](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions).

## What it guards

### Dangerous Bash commands

The default patterns cover recursive `rm` flags (including `-rf`, `-fr`, and separate `-f -r`), `sudo`, `chmod`/`chown` to mode `777`, `printenv`, and `env` **as a command** — `env`, `env | grep PATH`, `; env`, `$(env)` prompt, while `grep -n 'env' README.md` or `cat config/env.ts` do not. Matching commands require confirmation. Without a confirmation UI they are blocked by default.

### Protected paths

Defaults protect `.env` from read/write/edit/Bash, `.git/` from read/write/edit, `node_modules/` from write/edit, and `~/.pi/agent/auth.json` from read/write/edit/Bash. File-tool path checks compare both the supplied path and its filesystem-resolved target, including existing symlinks and symlinked parents for new files; paths that cannot be safely resolved are blocked when an applicable path rule is active. A path denial is a hard block and takes precedence over a dangerous-command confirmation for the same Bash call. These are preflight checks, not filesystem confinement: paths can change between the check and the tool operation.

Bash rules match **words**, not substrings: the command is split on whitespace, quotes, and shell punctuation, and each word is compared to the entry by path segment and by resolution against the session directory, including symlinks. Blocked, measured: `cat .env`, `cat /project/.env`, `head -2 .env | tail -1`, `git show HEAD:.env`, `python -c 'open(".env")'`, `x=".env"; cat $x`, `export F=$(cat .env)`, and `cat alias` where `alias` is a symlink to the protected file. Allowed, measured: `grep -rn "process.env" src`, `cat .env.example`, `cat .env.local`, `ls env` — the older substring rule blocked all of those too, which made the rule impossible to live with.

Entries name exact paths and there is no glob syntax: `.env` does not cover `.env.local` or `.env.production`, so list each path you want denied. Two further limits are structural, so Bash protection stays **advisory** while `read`/`write`/`edit` is where the guarantee lives. A name that never appears whole in the text cannot be recognised — `cat .en""v` and `$(printf '\x2eenv')` pass — and a glob names no single file to check (`rm -f .env*`). Because words resolve against the session directory, `cd elsewhere && cat .env` is judged as if the `cd` had not happened.

### Destructive session actions

Starting a new session, resuming another session with a pending user message, and forking prompt for confirmation by default. Configured actions are canceled if no UI is available unless `confirmDestructive.blockWithoutUI` is explicitly set to `false`.

### Malformed provider tool calls

A tool call is malformed when its ID or name is blank after trimming whitespace. The extension drops malformed calls before Pi persists or replays them, preserves valid sibling calls, and turns invalid-only responses into a text-only stop so the user can continue. Unfamiliar but non-empty tool names are not rejected. As defense in depth, the `tool_call` boundary also blocks any blank-id/name call that slips through, so a malformed call can never execute even if the `message_end` filter is bypassed.

A Bash call whose `command` is not a string is blocked outright rather than coerced: there is no command to inspect, and matching a coerced array (`String(["rm","-rf"])` reads as `rm,-rf`) would confirm the wrong thing.

## Forensic JSONL

Each malformed-call incident appends one JSON object to:

```text
<agent dir>/guardian-incidents.jsonl
```

That is pi's own directory (`PI_CODING_AGENT_DIR`, default `~/.pi/agent`), not the project: an untracked `err/` directory inside whatever repository a session happens to open is noise, and it would hold fragments of provider payloads. Override the location with `logging.incidentFile`, or set it to `false` to keep no log at all.

Records carry the timestamp (falling back to the current time when a provider omitted or zeroed it), session/project/provider/model metadata, and one summary per dropped tool call: tool name, sorted argument *key* names, and the byte size of the arguments. Argument values are never recorded - a malformed call can carry file contents, commands or credentials, and those belong in the session transcript, not a log file. So the log holds no prompts, assistant text, thinking, or tool results, but argument key names are still names: a tool that labels an argument after its content would show up here.

Treat it as local diagnostic data. On POSIX a newly created log is restricted to mode `0600` on a best-effort basis. If logging fails, malformed calls are still removed and the diagnostic reports the write failure.

## Testing

```bash
npm run test:opl-guardian
```
