pi-tool-guard
Pi extension that corrects LLM tool calls: normalizes argument aliases for edit/write/read and strips trailing pipeline extractors from bash commands
Package details
Install pi-tool-guard from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-tool-guard- Package
pi-tool-guard- Version
0.5.0- Published
- Sep 15, 2026
- Downloads
- 418/mo · 375/wk
- Author
- tychenjiajun
- License
- MIT
- Types
- extension
- Size
- 91.8 KB
- Dependencies
- 1 dependency · 2 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-tool-guard
A pi extension that corrects common LLM tool call mistakes: normalizes argument aliases for edit/write/read and strips trailing pipeline extractors from bash commands.
Install
pi install npm:pi-tool-guard
Features
1. Argument alias normalization (edit / write / read)
When the LLM calls a tool with wrong field names, the extension normalizes them before schema validation. No error, no re-execution.
| Tool | Canonical | Accepted aliases |
|---|---|---|
| edit | path |
file, filePath, file_path, target, filename, file_name |
| edit | edits[].oldText |
old_str, old_string, oldContent, old, original, search |
| edit | edits[].newText |
new_str, new_string, newContent, new, replacement, replace |
| write | path |
file, filePath, file_path, target, filename, file_name |
| write | content |
text, body, code, data, fileContent, contents |
| read | path |
file, filePath, file_path, target, filename, file_name |
| read | offset |
start, startLine, start_line, from, line |
| read | limit |
lines, maxLines, max_lines, count, numLines, num_lines |
Edit tool shorthand: top-level
oldText/newText(or aliases) are automatically wrapped into aneditsarray.Read tool type coercion: string values for
offsetandlimitare coerced to numbers.
2. Bash pipeline extractor stripping
When the LLM appends truncation commands (tail, head, grep, etc.) to bash commands, the extension strips them and applies the extractor intelligently.
Four-case strategy:
| Scenario | UI Notification | LLM Response |
|---|---|---|
| Fast command (< 10s), truncated | — (none; identical to the built-in bash tool) | Filtered result only |
| Fast command (< 10s), not truncated | — (none; identical to the built-in bash tool) | Filtered result only |
| Slow command (truncated or not) | The full output is above... + guard receipt |
Projected view + Full-Output Readback notice (inlines the saved path and says to read it instead of re-running) |
| Error (non-zero exit) | Filtered via ... |
Projected text + [Command exited with code N. Full output: <path>] |
Notification format: The UI notification shows both the full original command and the extracted pipeline. Example:
Removed trailing pipeline commands: \grep FAIL | head -5` from `npm test | grep FAIL | head -5`.` Fast commands show no notice and no receipt, so they look exactly like the built-in tool.
Example: vitest run | tail -n 10
- If vitest finishes in < 10s → run
tailon result (or full output file if truncated), no UI - If vitest is slow → persist the full, unextracted output to a temp file, return the projected view, notify UI, append a Full-Output Readback notice (path + "read it instead of re-running") to the LLM
- If vitest exits non-zero → recover the output and
Full output: <path>pointer from the error, applytailto the available full output, throw an error whose message is the projected text followed by[Command exited with code N. Full output: <path>]
Extractor split: Not all trailing pipeline commands are stripped. The guard distinguishes:
- View extractors (safe to strip; the guard re-applies them):
head,tail,grep,egrep,fgrep,rg,less,more - Transform extractors (NOT stripped):
sed,awk,cut,sort,uniq,wc,column,jq,yq,tr
If the trailing pipeline contains any transform extractor, the guard leaves the command completely unchanged and runs it as-is (so the shell applies the transform). Stripping a transform would silently drop the transformation the user asked for.
Only view extractors are stripped: npm test | grep FAIL | head -5 → strips grep FAIL | head -5
Multi-statement safety: stripping is only attempted when the trailing view's stdout is the whole command's stdout. A lone pipeline, or one preceded by a silent prefix (cd, bare assignments, true, :, test, [, without redirects — e.g. cd /tmp && npm test | tail), is eligible. Anything else is left untouched: for a && b | head, re-applying head after stripping would return the head of a, not of b. This covers ;/&&/|| lists, grouped (...)/{ ...; } commands, and backgrounded earlier statements.
Fast-path fidelity: for fast commands the guard executes the cleaned command and re-applies the stripped extractor to pi's formatted result, rather than running the native cmd | extractor pipeline. This is faithful in the common case, with three accepted, documented deviations: pi merges stdout+stderr before the guard projects, so a stripped grep/head also filters stderr lines (a real shell pipeline would not); the guard trims trailing whitespace from the projection; and during the run pi's live onUpdate stream shows the cleaned command's unfiltered output — only the returned content is the projection. These are known limitations, not bugs.
Exit codes: Extractor exit codes are propagated, matching pi's own cmd | extractor behavior: a stripped pipeline whose re-applied extractor exits non-zero (e.g. cmd | grep no-match, where grep exits 1) fails with Command exited with code 1, instead of returning an empty success. On slow commands the error appends Full output: <path>.
Empty output: an empty inline projection renders as (no output), matching pi's own empty-result rendering.
Slow commands: For slow commands (≥ 10s), the guard persists the full, unextracted output to a temp file (pi-bash-guard-<hex>.log in the OS temp dir; swept best-effort after 24h via FULL_OUTPUT_TTL_MS on subsequent persists) even when it is below pi's 50KB/2000-line truncation limit, and returns the projected view (outcome projection-applied) instead of dumping the entire output. The pi-bash- name plus a details.fullOutputPath pointer make that file readable by SoL-Pi's evidence-preserving reducer, so the full log stays reducible even when pi did not truncate. If the full output cannot be persisted (filesystem error) and pi did not truncate, the guard falls back to returning the full output (outcome full-output) rather than a projection, so the readback notice is never misleading. The LLM notice inlines that file path and tells the agent to read it on demand — e.g. the read tool with offset/limit, or sed -n 'START,ENDp' <path> — rather than re-running the expensive command. The guard never replays a previous run; every invocation executes the command again.
Timing split: pi's built-in renderer prints one opaque Took <total> line. For a slow command where the guard ran a stripped extractor, that line is replaced with a SoL-Pi-style receipt. Fast commands keep pi's plain line — no receipt, no UI:
🛡 pi-tool-guard · grep FAIL | head -5
Context saved · 1,234 → 20 lines · 98% smaller · 19 KiB saved
Took 6.5s ██████████████░░░░ cmd 5.0s · filter 1.5s
The bar is proportional — accent █ for the cleaned command, warning ░ for the guard's extractor.
Silent no-op: when applying the extractor leaves the output unchanged (e.g. tail -20 on a 2-line result), the guard shows no UI notice and no receipt — pi's plain Took line stays. Notices and receipts only fire when the projection actually changed the content. No-op detection is only computed for inline (non-truncated) projections; for projections from a saved file the guard cannot cheaply compare against the full text, so it does not claim a no-op.
Architecture
Alias normalization uses prepareArguments on the overridden edit/write/read tools — the cleanest pi extension pattern for argument correction:
- edit/write/read:
createXxxToolDefinition(cwd)+prepareArgumentsnormalizes aliases before schema validation
Bash runs a custom execute override:
executeparses the command with unbash and strips trailing view extractors (transform extractors are left in place). It only strips a lone final pipeline or one behind a silent prefix; multi-statement, grouped, and backgrounded commands are passed through unchanged. Stripping happens here (not inprepareArguments) sotool_result.input.commandkeeps the command the user wrote.- The cleaned command is run through the original built-in execute.
- If fast (< 10s) + truncated → runs extractor on the full output file via
pi.exec - If fast (< 10s) + not truncated → pipes result through extractor via
pi.exec - If slow (truncated or not) → persists the full, unextracted output to a temp file (
pi-bash-guard-<hex>.log, OS temp dir; swept best-effort after 24h viaFULL_OUTPUT_TTL_MSon subsequent persists), runs the extractor, returns the projected view + Full-Output Readback notice (falls back to outcomefull-outputif persisting fails and pi did not truncate) - If the command exits non-zero → recovers the output and
Full output: <path>pointer from the error, applies the stripped (view) extractor to the available full output, and throws an error whose message is the projected text followed by a bracket note preserving the exit code and full-output path
No session scanning. Error path: a thrown error cannot carry details, so no piToolGuard contract is published on the error path — this is a documented limitation.
Interop: details.piToolGuard
When the guard strips extractors it stamps a versioned record on details.piToolGuard so other tool_result handlers — notably SoL-Pi's evidence-preserving reducer — can tell what the visible content really is:
interface PiToolGuardDetails {
version: 1;
originalCommand: string; // e.g. "npm test | tail -20"
cleanedCommand: string; // e.g. "npm test"
removed: string[]; // e.g. ["tail"]
removedPipeline: string; // e.g. "tail -20"
outcome: "projection-applied" | "full-output";
sourcePath?: string; // untruncated output, when Pi produced one
timing?: { // per-phase wall clock, when guard took effect
commandMs: number; // cleaned command execution
extractorMs: number; // guard's extractor projection (0 when none ran)
totalMs: number; // commandMs + extractorMs
};
projection?: { // before/after size, when measurable
inputLines: number;
inputBytes: number;
outputLines: number;
outputBytes: number;
};
noop?: boolean; // projection left the output unchanged (inline projections only)
slow?: boolean; // cleaned command ran >= 10s; only slow runs show guard UI
}
outcome: "projection-applied"—contentis the user's projection, applied as a fallback. The guard always keepsdetails.fullOutputPath/truncation(so a reducer still archives the full log) and mirrors the path aspiToolGuard.sourcePath. When pi did not truncate but the guard persisted its own slow log, the guard also publishes thatpi-bash-guard-*.logpath asdetails.fullOutputPath. SoL-Pi's reducer readsfullOutputPath, reduces the full log, and overrides guard's projection with its receipt.outcome: "full-output"—contentis the cleaned command's full output anddetails.fullOutputPathis kept. Also the fallback when a slow command's full output cannot be persisted (filesystem error) and pi did not truncate.slow— optional boolean;truemeans the cleaned command ran for at leastFAST_THRESHOLD_MS. Only slow runs surface guard UI (Notice, footer status, receipt); fast commands look exactly like the built-in bash tool.- No extractors stripped → no record;
detailsis left untouched.
The contract is additive and versioned. Consumers must parse with readPiToolGuardDetails (exported from contract.ts) and fall back to the inline content for unknown versions.
Development
pnpm install
pnpm test # Run all tests
pnpm typecheck # Type check
License
MIT