ai/pi-coding-agent.md
Table of Contents
Pi Coding Agent
Design philosophy: no built-in MCP
Pi doesn't have built-in MCP support โ this is an intentional design choice. From the docs:
No MCP. Build CLI tools with READMEs (see Skills), or build an extension that adds MCP support. Why? https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/
Two paths instead:
Option 1: Use Skills (the pi-native approach)
Skills are markdown-based capability packages that follow the Agent Skills standard. They're simpler than MCP โ a skill is just a folder with a SKILL.md that tells the LLM how to use a CLI tool.
# My Skill
Use this skill when the user asks about X.
## Steps
1. Do this
2. Then that
Place skills in:
~/.pi/agent/skills/(global).pi/skills/(project-local)
Then invoke with /skill:name or let the agent auto-detect them.
Option 2: Build an MCP extension
Since pi supports TypeScript extensions, you can build one that speaks the MCP protocol. An MCP extension would typically:
- Register tools via
pi.registerTool()for each MCP server tool - Use
@modelcontextprotocol/sdk(npm package) to connect to MCP servers via stdio or SSE - Proxy tool calls from the LLM to the appropriate MCP server
The pattern is similar to how the ssh.ts example delegates tools to a remote machine, or how the subagent example delegates tasks. Here's the basic shape:
// ~/.pi/agent/extensions/mcp.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default async function (pi: ExtensionAPI) {
// Connect to MCP servers on startup
// Register their tools with pi
// Proxy tool calls through to the MCP server
pi.on("session_start", async (_event, ctx) => {
// Initialize MCP client connections
ctx.ui.notify("MCP servers connected", "info");
});
}
There's currently no official MCP extension in pi's examples or packages, but you can ask pi itself to help you build one.
Recommendation
If your use case is fairly straightforward (e.g., the MCP server wraps a CLI tool), use Skills โ they're simpler, first-class in pi, and avoid the complexity of the MCP protocol. If you need the full MCP protocol (like connecting to a remote resource server), building an extension is the way to go.
Pi Web UI + MCP Adapter Setup
A web-based Pi with full MCP server support, proxied through nginx.
Architecture
Browser โโHTTPSโโ> pi.dph.am โโnginxโโ> pi-webui (port 49998) โโ> Pi CLI (via /resume)
โ
pi-mcp-adapter
โ
~/.config/mcp/mcp.json
โ
(symlink to ~/.mcp.json)
Components
| Component | Repo | Purpose |
|---|---|---|
| pi-webui | github.com/khimaros/pi-webui | Web-based UI for Pi โ browser access instead of terminal |
| pi-mcp-adapter | github.com/nicobailon/pi-mcp-adapter | MCP server adapter so Pi can use all configured MCP servers |
Installation & Config
npm install -g @khimaros/pi-webui
Symlink trick โ Pi-mcp-adapter reads MCP config from ~/.config/mcp/mcp.json. Link it to the global Claude MCP config:
ln -s ~/.mcp.json ~/.config/mcp/mcp.json
This way every MCP server configured for Claude Code (Asana, Granola, notes, LSP, etc.) is also available in the Pi web UI.
Environment & Startup
export PI_WEBUI_HOST=0.0.0.0
export PI_WEBUI_PORT=49998
pi-webui
Note: pi --webui-listen 0.0.0.0:49998 currently doesn't work โ always use the pi-webui CLI directly.
Session Navigation: /resume trick
Pi's pi-webui start directory determines where new sessions are rooted. To switch between projects without restarting the web UI:
-
In Terminal (direct Pi CLI): start Pi sessions in different project directories:
# Terminal 1: Qwestly cd ~/Work/qwestly-workspace && pi # Terminal 2: Personal cd ~/Work/notes && pi # etc. -
In pi-webui: use
/resumeto see and hop between existing sessions. Once resumed into a session that lives in a different directory, use/newto start a fresh Pi session in that project's context.
This effectively gives you "cd between folders" โ each session retains its original working directory, and /resume + /new lets you spawn new sessions within any of those directories.
Nginx Proxy
- Domain:
https://pi.dph.am - Backend:
localhost:49998 - Auth service: lives in
~/Work/services/nginx-auth-service- Provides an authentication layer in front of the Pi web UI
- Keeps access restricted
The proxy config and auth setup live in the nginx-auth-service project.
Plugins
pi-anthropic-auth
github.com/gotgenes/pi-anthropic-auth โ a Pi plugin for Anthropic OAuth/subscription-based auth (e.g. using a Claude Pro/Max subscription instead of raw API key billing) plus related config options.
Config option โ warnings.anthropicExtraUsage:
{
"warnings": {
"anthropicExtraUsage": false
}
}
Controls whether the plugin warns when usage would fall outside/exceed the subscription plan's included usage (i.e. spill over into extra, pay-as-you-go billing on the Anthropic account). Setting anthropicExtraUsage: false suppresses that warning.
pi-subagents
github.com/tintinweb/pi-subagents โ brings Claude Code-style autonomous sub-agents and workflow orchestration to Pi. Spawn specialized agents in isolated sessions (own tools, system prompt, model, thinking level), run them in background or blocking, steer mid-run, resume, and define custom agent types.
Install:
pi install npm:@tintinweb/pi-subagents
Requires pi 0.84.0+.
Key features:
- Adds the same tool surface Claude Code uses:
Agent,get_subagent_result,steer_subagent, plus aSubagentWorkflowtool for deterministic JS orchestration (agent(),parallel(),pipeline(),phase(),log()). - Background by default โ agents run detached, return an ID immediately, and notify on completion;
run_in_background: falseblocks for the full result inline. - Live widget + FleetView โ persistent above-editor widget showing active agents; a Claude Code-style navigable list below the editor (
โ/โat empty prompt to jump in,Enterto open an agent's live conversation,xto stop). - Custom agent types โ define in
.pi/agents/<name>.md(project) or globally, with YAML frontmatter for system prompt, model, thinking level, tool restrictions. - Agent mentions โ
@explore do Xaddresses a subagent directly (message it, resume it, or start it) without it entering the main chat. - Scripted workflows โ
SubagentWorkflowscripts are Claude CodeWorkflow-tool compatible (same globals/semantics), runs in a sandboxed worker thread. - Also supports: mid-run steering, session resume, graceful turn-limit wrap-up, fuzzy model selection (
"haiku","sonnet"), context inheritance, git worktree isolation, skill preloading, scheduled subagents (cron/interval/one-shot), tool denylists, and a cross-extension RPC event bus.
Essentially turns Pi's agent tooling into the same subagent/orchestration model this very session (Claude Code) uses โ Agent for one-off delegated tasks, SubagentWorkflow for deterministic multi-stage fan-out/pipeline work.
pi-web-access
github.com/nicobailon/pi-web-access โ web search, content extraction, and video understanding extension for Pi.
Install:
pi install npm:pi-web-access
Works with zero config (Exa MCP provides free search out of the box; Codex-subscription auth can also power OpenAI search). Optional API keys for more providers go in ~/.pi/agent/web-search.json (openaiApiKey, braveApiKey, exaApiKey, tavilyApiKey, geminiApiKey, etc.). Requires Pi v0.37.3+.
Tools it adds:
web_searchโ search via Exa, OpenAI, Brave, Tavily, Firecrawl, Kagi, Perplexity, Gemini, Kimi, self-hosted SearXNG, keyless DuckDuckGo, and many more, with automatic provider fallback chains. Supports batched queries, recency/domain filters, and an optional "curator" summary workflow.fetch_contentโ fetch a URL as readable markdown, raw HTTP body, or a grounded answer. Auto-detects and specially handles GitHub repos/PRs/issues (clones locally instead of scraping), YouTube videos, PDFs, local video files, and images.get_search_contentโ retrieve cached content from a previous search/fetch (1-hour cache, 128 entries / 128 MiB cap), with text-finding (findText, exact/case-insensitive/fuzzy) or offset/limit paging.source_checkโ fact-check a claim and return a machine-readable artifact with exact passage citations, deduplicated/capped sources.
Notable capabilities: GitHub URLs are cloned locally rather than scraped (agent gets real files + a local path); video/screen-recording understanding with transcript, visual description, and frame extraction at timestamps (needs ffmpeg/yt-dlp for frame extraction, optional otherwise); smart per-capability fallback chains (search, YouTube, page fetch each have their own provider order).
Extensions
Extensions are plain TypeScript files (no build step โ loaded via jiti) that hook into pi's lifecycle. A minimal one is a default-exported factory:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
}
Where they live (auto-discovered, hot-reloadable with /reload):
| Location | Scope |
|---|---|
~/.pi/agent/extensions/*.ts |
Global (all projects) |
.pi/extensions/*.ts |
Project-local (loads only after the project is trusted) |
Either can be a single file or a dir/index.ts (plus helper modules, plus a package.json if it needs npm deps โ npm install in the extension dir and imports from node_modules/ resolve automatically).
Security note: extensions run with pi's full process permissions โ same trust level as running arbitrary local code. Only use ones you trust; pi has no built-in sandbox (see docs/security.md).
The four things extensions can do
- Intercept events โ the big one. Lifecycle events include
session_start,before_agent_start(inject messages / rewrite system prompt per turn),tool_call(block or mutate tool args before they run โ returns{ block: true, reason }to stop a call),tool_result(rewrite results after execution),context(edit the message list sent to the LLM),before_provider_request/after_provider_response(raw payload/header access), session events (session_before_compact,session_before_fork,session_before_switch), and model events.tool_callandinputare the ones most useful for guardrails since they can outright block or transform. - Register custom tools โ
pi.registerTool()adds a new tool the LLM can call, with its own schema (viatypebox), streaming updates (onUpdate), custom rendering, and optionalpromptSnippet/promptGuidelinesso the model knows when to use it. Can even override a built-in tool by reusing its name. - Custom UI โ
ctx.ui.select/confirm/input/editorfor dialogs,notifyfor toasts,setStatus/setWidget/setFooterfor persistent footer/widget UI,ui.custom()for full custom TUI components. - Commands & shortcuts โ
pi.registerCommand("name", {...})for/name,pi.registerShortcut("ctrl+x", {...})for keybindings,pi.registerFlag()for new CLI flags.
Other useful patterns from the examples
tool_callcan mutate, not just block โ e.g. prependsource ~/.profileto every bash command before it runs (bash-spawn-hook.ts).- Persistent extension state:
pi.appendEntry()persists data in the session file without polluting LLM context; reconstruct it insession_start. - Custom providers:
pi.registerProvider()points at a local proxy, self-hosted model, or adds OAuth login flows. - Sandboxed/remote execution: the
user_bashevent plus thesandbox/,gondolin/, andssh.tsexamples route shell execution into a micro-VM or remote host instead of local โ actual isolation, not just a guardrail check. - Subagents from inside pi itself: the
subagent/example shows a tool that spawns nested agent runs. - File-mutation safety: a custom tool that writes files should wrap the write in
withFileMutationQueue()so it doesn't race with the built-inedit/writetools under parallel tool-call execution.
Full reference: docs/extensions.md in the pi-coding-agent package, with a working example for nearly every pattern above under examples/extensions/.
Example: scope-guard.ts โ block filesystem-wide scans (not project-scoped, just broad-root-scoped)
Problem: an agent working in a big monorepo can be told in AGENTS.md/AGENTS-LOCAL.md not to run find / or similar broad scans, but that's only a prompt-level hint โ it can be ignored, forgotten after compaction, or bypassed by a subagent that never saw the instruction. A tool_call hook enforces it at the runtime level instead, regardless of what the model was told.
First version restricted the agent to an allow-list of project roots. Revised to a looser rule instead: the agent is free to search anywhere โ another repo, ~/Downloads, wherever โ the only thing blocked is a small set of roots that are almost never a legitimate search target and are exactly the ones that lock things up: /, a bare home directory, /Users itself, /System, /Library, /proc, /sys, /dev, /Volumes, etc.
Placed at ~/.pi/agent/extensions/scope-guard.ts (global, applies to every project). Note: the code block below is the original block-only version (v2); the v3 section further down shows what changed to add mdfind auto-substitution, and the file on disk reflects v3.
- Intercepts
bash/powershelltool calls and scans the command text forfind/grep/ag/ls/du/treeplus any absolute or~-relative path argument (fd/rgdropped from this list in v3, see below). - Blocks only if that path (or the shell's cwd, when no path argument is given) matches the broad-root list above.
- Also checks the built-in
find/grep/lstools'pathinput the same way. PI_SCOPE_EXTRA_DENY="/a:/b"(colon-separated) adds extra path prefixes to the broad list, e.g. a large mounted volume that shouldn't be scanned.- The block message points the model at faster alternatives (see below) instead of just saying no.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import path from "node:path";
import os from "node:os";
export default function (pi: ExtensionAPI) {
const homeDir = os.homedir();
const BROAD_PATTERNS: RegExp[] = [
/^\/$/, // filesystem root
/^~\/?$/, // bare home, unexpanded
new RegExp(`^${escapeRegex(homeDir)}/?$`), // bare home, expanded
/^\/Users\/?$/, // all users
/^\/Users\/[^/]+\/?$/, // someone else's whole home dir
/^\/home\/?$/,
/^\/home\/[^/]+\/?$/,
/^\/(etc|var|usr|opt|System|Library|Applications|proc|sys|dev|private|Volumes)(\/|$)/,
];
const extraDeny = (process.env.PI_SCOPE_EXTRA_DENY?.split(":").filter(Boolean) ?? []).map((p) =>
path.resolve(p.replace(/^~(?=$|\/)/, homeDir)),
);
function escapeRegex(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function resolveAgainst(base: string, p: string) {
const expanded = p.replace(/^~(?=$|\/)/, homeDir);
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(base, expanded);
}
function isBroad(rawPath: string, absPath: string) {
if (BROAD_PATTERNS.some((re) => re.test(rawPath) || re.test(absPath))) return true;
return extraDeny.some((d) => absPath === d || absPath.startsWith(d + path.sep));
}
const SUGGESTION =
"For a whole-disk or unknown-location search, use `mdfind` (Spotlight index, no filesystem walk โ " +
"e.g. `mdfind -name 'pattern'` or `mdfind -onlyin /some/dir 'pattern'`). For a project-scoped recursive " +
"search, use `fd`/`rg` (parallel, .gitignore-aware) or the built-in find/grep tools, scoped to a subdirectory.";
function extractCandidatePaths(command: string): string[] {
const matches = command.match(/(?:^|[\s(])(~\/[^\s;|&"']*|~|\/[^\s;|&"']*)/g) ?? [];
return matches.map((m) => m.trim().replace(/^[(]/, ""));
}
function checkShellCommand(command: string, cwd: string | undefined) {
const scanners = /\b(find|grep|rg|ag|ls|du|tree|fd)\b/;
if (!scanners.test(command)) return undefined;
const effectiveCwd = cwd ? resolveAgainst(process.cwd(), cwd) : process.cwd();
for (const raw of extractCandidatePaths(command)) {
const abs = resolveAgainst(effectiveCwd, raw);
if (isBroad(raw, abs)) {
return { block: true as const, reason: `Command targets "${raw}" (resolved: ${abs}), which is a filesystem-wide/broad root and likely to lock up. ${SUGGESTION}` };
}
}
if (isBroad(effectiveCwd, effectiveCwd)) {
return { block: true as const, reason: `Shell cwd "${effectiveCwd}" is a filesystem-wide/broad root and likely to lock up. ${SUGGESTION}` };
}
return undefined;
}
pi.on("tool_call", async (event) => {
if (event.toolName === "bash" || event.toolName === "powershell") {
const input = event.input as { command?: string; cwd?: string };
const result = checkShellCommand(input.command ?? "", input.cwd);
if (result) return result;
return undefined;
}
if (event.toolName === "find" || event.toolName === "grep" || event.toolName === "ls") {
const input = event.input as { path?: string };
if (input.path) {
const abs = resolveAgainst(process.cwd(), input.path);
if (isBroad(input.path, abs)) {
return { block: true, reason: `Path "${input.path}" (resolved: ${abs}) is a filesystem-wide/broad root and likely to lock up. ${SUGGESTION}` };
}
}
}
return undefined;
});
}
Verified (v2, block-only):
find / -maxdepth 2 -name '*.txt'โ still blocked, with the reason surfaced back to the model, which explained the block and offered mdfind/fd instead of executing the broad scan.find /Users/dominick/Work/personal-notes -maxdepth 2 -name '*.md' | wc -lโ now runs fine (a different repo entirely, outside the workspace pi started in) โ confirming the guard no longer requires staying inside one project.
Since it's in the global auto-discovered directory, it's active for every session going forward; edits take effect in a running session via /reload.
v3: auto-substitute mdfind instead of just blocking
Blocking is a dead end for the model โ it has to notice the failure, remember the suggestion, and manually retype an mdfind command itself. Since tool_call handlers can mutate event.input in place before the tool actually runs (not just return { block: true }), the guard can instead rewrite the command and let it execute for real.
Added tryTranslateFindToMdfind(): for the common case โ find <broad-root> [-maxdepth N] [-type f|d] -name|-iname '<pattern>' with nothing else in play โ it rewrites the command to the mdfind equivalent (stripping glob wildcards from the pattern, since mdfind -name is substring match, not glob; adding -onlyin <root> unless the root is literally /) and prepends an echo ... 1>&2 notice so the substitution is visible in the tool output, not silent.
The translator deliberately bails out (falls back to the old hard block) on anything it can't safely reinterpret: -exec/-ok/-delete, pipes/chains, any predicate other than -maxdepth/-mindepth/-type/-name/-iname/-print. Getting this wrong silently โ e.g. mistranslating a -exec rm into something that runs mdfind and reports success without deleting anything โ is worse than just blocking, so untranslatable commands still get the old "blocked + suggestion" treatment. Content search (grep -r over a broad root) has no safe mdfind equivalent (regex vs. substring/predicate semantics differ too much) and is always just blocked.
fd/rg were also dropped from the restricted-scanner list entirely โ they're already the fast/parallel/.gitignore-aware alternative this guard exists to nudge the agent toward, so there's nothing to protect against.
function tryTranslateFindToMdfind(command: string, rawRoot: string, absRoot: string): string | undefined {
const trimmed = command.trim();
if (/[|;&]|-exec\b|-ok\b|-delete\b/.test(trimmed)) return undefined;
if (!/^find\s+/.test(trimmed)) return undefined;
const afterRoot = trimmed.slice(trimmed.indexOf(rawRoot) + rawRoot.length);
const tokens = afterRoot.match(/'[^']*'|"[^"]*"|\S+/g) ?? [];
let pattern: string | undefined;
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t === "-maxdepth" || t === "-mindepth") { i++; continue; }
if (t === "-type") { i++; continue; } // no direct mdfind equivalent, ignored
if (t === "-name" || t === "-iname") {
if (pattern !== undefined) return undefined; // ambiguous, bail
const next = tokens[i + 1];
if (!next) return undefined;
pattern = next.replace(/^['"]|['"]$/g, "");
i++;
continue;
}
if (t === "-print" || t === "-print0") continue;
return undefined; // -mtime, -size, -newer, "(", "and", "or", ... โ too much drift to translate
}
if (pattern === undefined) return undefined;
const substring = pattern.replace(/^\*+/, "").replace(/\*+$/, "");
if (!substring) return undefined;
const scopeFlag = absRoot === "/" ? "" : ` -onlyin ${shellQuote(absRoot)}`;
return `mdfind${scopeFlag} -name ${shellQuote(substring)}`;
}
In the tool_call handler, a successful translation mutates event.input.command and returns undefined (not blocked) instead of { block: true, ... }:
if (result && "rewrite" in result) {
input.command = result.rewrite; // mutate in place โ pi executes the rewritten command
return undefined;
}
Verified (v3):
find / -maxdepth 2 -name '*.txt'โ transparently substituted tomdfind -name .txt, actually ran, returned real matches (e.g. Chrome cacheindex.txtfiles) instantly instead of hanging. The stderr notice ([scope-guard] find over a broad root (/) is lockup-prone; substituting: mdfind -name .txt) showed up in the tool output so the model reported the substitution rather than silently believing the literalfindran.grep -r 'zzqwerty123' / --include='*.txt' -l(content search, no safe translation) โ still blocked; model fell back to runningmdfinditself on the search term.find /Users/dominick/Work/personal-notes -maxdepth 1 -name '*.md'(not broad) โ ran completely unchanged, no interference.find / -maxdepth 1 -name 'zzqwerty123' -exec echo hit {} \;(has-exec, translation correctly refuses) โ still blocked rather than mistranslated into a no-opmdfindcall that would silently skip the-execside effect.
Faster than find
On macOS, find and grep -r are slow specifically because they physically walk every directory entry โ that's what locks up on / or other huge trees. Alternatives, roughly fastest-to-slowest for different jobs:
mdfind(Spotlight) โ queries the pre-built system index instead of walking the filesystem, so it's the right tool for "search everywhere" or "I don't know where this file lives."mdfind -name 'pattern'for filenames,mdfind -onlyin /some/dir 'pattern'to scope it,mdfind 'kMDItemTextContent == "foo"'for content search. Doesn't work well on paths Spotlight excludes (e.g. some mounted/network volumes,.gitinternals) and only reflects what's indexed.fdโ afindreplacement: parallelized, respects.gitignoreby default, much simpler syntax (fd pattern /some/dir). This is what pi's own built-infindtool prefers under the hood when available (docs: "Default: local filesystem plus fd").rg(ripgrep) โ thegrep -rreplacement, same parallel/.gitignore-aware story; also what pi's built-ingreptool prefers by default ("Default: local filesystem plus ripgrep").- Plain
find/grep -rโ fine for anything already scoped to a normal project directory; the problem was neverfinditself, it was pointing it at/.
Both fd and rg are already installed for this account under ~/.pi/agent/bin/.
Example: secret-guard.ts โ redact secrets from tool output instead of hiding that they exist
Distinct concern from scope-guard: the agent legitimately needs to read a .env, check whether a variable is set, or grep for a key name โ that's fine and shouldn't be blocked. What's not fine is the actual secret value ending up printed into the visible transcript, which then also lands in the session file on disk and gets sent to the model provider as plain context. Blocking the read isn't the right tool here (it can't tell the difference between "cat .env to see if DEBUG is set" and "cat .env to leak the API key"); redacting the value at the boundary is.
Placed at ~/.pi/agent/extensions/secret-guard.ts (global, applies to every project). Two hooks:
tool_resultโ runs on every tool's output before it's finalized into the session/context. Scanscontenttext blocks for secret-shaped patterns and replaces just the value with[REDACTED:<kind>], leaving the surrounding line intact (turning e.g. anAPI_KEY=assignment intoAPI_KEY=[REDACTED:assignment]rather than dropping the line) so the model still knows the variable exists and is non-empty, just not the value. Appends a one-line notice when anything was redacted so the model doesn't mistake the placeholder for the real thing.message_end(assistant messages only) โ same redaction applied to the model's own reply text, in case a secret already made it into context some other way and the model tries to relay it back verbatim.
Pattern set: PEM private key blocks, AWS access key IDs (AKIA...), GitHub tokens (ghp_/gho_/etc.), Slack tokens (xox...), Stripe keys (sk_live_/pk_live_), OpenAI/Anthropic keys (sk-.../sk-ant-...), Google API keys (AIza...), JWTs (eyJ...), user:password@host embedded in URLs, and a generic KEY=value/KEY: value assignment pattern for names containing SECRET/TOKEN/PASSWORD/API_KEY/ACCESS_KEY/PRIVATE_KEY/CREDENTIAL/CLIENT_SECRET. The assignment pattern is tool-output-only (not applied to assistant prose) to avoid flagging discussion of env var names without real values, and skips an obvious-placeholder allowlist (changeme, xxxx, your_key_here, example, etc.) so docs/templates don't get needlessly redacted. An empty assignment (EMPTY_TOKEN=) is left alone โ no redaction, so the model can still tell the difference between "set" and "unset."
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
interface SecretPattern {
name: string;
regex: RegExp;
valueGroup?: number; // capture group holding just the value, if the match includes context
toolOnly?: boolean; // skip for assistant prose
}
const PLACEHOLDER_VALUES =
/^(x+|\*+|changeme|change[_-]?me|example|placeholder|your[_-]?\w*|insert[_-]?\w*|replace[_-]?\w*|todo|redacted|none|null|n\/a|xxxx+|1234+)$/i;
const SECRET_PATTERNS: SecretPattern[] = [
{ name: "private-key", regex: /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g },
{ name: "aws-access-key-id", regex: /\bAKIA[0-9A-Z]{16}\b/g },
{ name: "github-token", regex: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
{ name: "slack-token", regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
{ name: "stripe-key", regex: /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g },
{ name: "anthropic-key", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
{ name: "openai-key", regex: /\bsk-[A-Za-z0-9]{20,}\b/g },
{ name: "google-api-key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
{ name: "jwt", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
{ name: "url-basic-auth", regex: /(:\/\/[^/\s:@]+:)([^@\s]+)(@)/g, valueGroup: 2 },
{
name: "assignment",
regex:
/\b((?:[A-Z0-9]+_)*(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?|CLIENT[_-]?SECRET)(?:_[A-Z0-9]+)*)\s*([:=])\s*(['"]?)([^\s'";,]{4,})\3/gi,
valueGroup: 4,
toolOnly: true,
},
];
function redact(text: string, includeToolOnly: boolean): { text: string; count: number } {
let result = text;
let count = 0;
for (const p of SECRET_PATTERNS) {
if (p.toolOnly && !includeToolOnly) continue;
p.regex.lastIndex = 0;
result = result.replace(p.regex, (...args) => {
const groups = args.slice(0, -2) as string[];
const full = groups[0];
if (p.valueGroup === undefined) { count++; return `[REDACTED:${p.name}]`; }
const value = groups[p.valueGroup];
if (!value || PLACEHOLDER_VALUES.test(value)) return full;
count++;
return full.replace(value, `[REDACTED:${p.name}]`);
});
}
return { text: result, count };
}
export default function (pi: ExtensionAPI) {
pi.on("tool_result", async (event) => {
if (!Array.isArray(event.content)) return undefined;
let totalRedacted = 0;
const newContent = event.content.map((block) => {
if (block.type !== "text" || typeof block.text !== "string") return block;
const { text, count } = redact(block.text, true);
totalRedacted += count;
return count > 0 ? { ...block, text } : block;
});
if (totalRedacted === 0) return undefined;
return {
content: [...newContent, { type: "text" as const, text: `\n[secret-guard] Redacted ${totalRedacted} secret-shaped value(s) from this output.` }],
};
});
pi.on("message_end", async (event) => {
if (event.message.role !== "assistant") return undefined;
if (!Array.isArray(event.message.content)) return undefined;
let totalRedacted = 0;
const newContent = event.message.content.map((block) => {
if (block.type !== "text" || typeof block.text !== "string") return block;
const { text, count } = redact(block.text, false);
totalRedacted += count;
return count > 0 ? { ...block, text } : block;
});
if (totalRedacted === 0) return undefined;
return { message: { ...event.message, content: newContent } };
});
}
Verified:
- A test
.envwith a fake API key, GitHub token, and apostgres://user:password@host/db-style connection string, plusDEBUG=trueand a blankEMPTY_TOKEN=โcat .envandgrep API_KEY .envboth came back with values replaced by[REDACTED:...]placeholders,DEBUG=trueuntouched,EMPTY_TOKEN=untouched (still shows as unset, no false "it's set" signal), and a summary note appended so the model reported the redaction accurately instead of assuming it saw the real values. [ -n "$TEST_TOKEN" ] && echo 'TEST_TOKEN is set'โ an existence check with no value printed โ passed through completely unmodified, confirming the guard doesn't interfere with "does this var exist" checks.- Asking the model to repeat a secret already sitting in its context verbatim โ the
message_endhook caught and redacted it in the assistant's own reply too, not just tool output.
Known limitations: pattern-based, so it only catches secrets in recognizable shapes (known token prefixes, PEM blocks, or an assignment naming pattern) โ a bespoke internal token format with no name/prefix hint wouldn't be caught. It also doesn't redact details fields tools may populate separately from content, or tool_execution_update streaming partials shown live before the final result. Good as a backstop against the common leak shapes, not a substitute for not printing secrets in the first place.
v2: narrowed scope to the assistant's own reply only, after the tool_result hook caused real collateral damage
The original design hooked both tool_result (redact raw tool output) and message_end (redact the assistant's own prose). The tool_result half turned out to be a mistake in practice, discovered by hitting it personally:
- It corrupted this very documentation. Writing up the "Verified" bullet above involved describing a test
.envfile containing realistic-looking fake secrets (API_KEY=sk-..., apostgres://user:pass@hostURL). Later reads of this doc file through any pi tool (Read,cat,grep) ran back throughtool_result, which matched those documentation examples as if they were live secrets and redacted them โ permanently, once a subsequent edit accidentally baked the already-redacted (display-time) text back onto disk by copying from a corrupted read instead of the original source. - It corrupted reads of secret-guard's own source code.
const SECRET_PATTERNS: SecretPattern[] = [...](a TypeScript type annotation) matched the genericassignmentpattern'sKEY: valueshape โ "SECRET_PATTERNS" contains "SECRET", followed by: SecretPattern[], which reads exactly likeKEY: valueto a regex that doesn't know TypeScript syntax from an env file. - Both were fixed with better heuristics (a long-monotonic-character-run detector for sequential filler text like
abcdefghijklmnopqrstuvwxyz, and a bare-identifier/type detector for things likeSecretPattern[]) โ but fixing false positives one shape at a time is a losing game against arbitrary documentation and source code, which was never really what needed guarding anyway.
The actual requirement, restated by the person who asked for this extension partway through debugging it: "I don't care about reads and writes in a file. I just don't want the agent printing it out in the main chat thread." That's a materially narrower scope than "redact every tool's output" โ reading a .env, cat-ing a file, or grepping for a key name are all fine; the failure mode is specifically the model relaying a secret value in its own visible reply. Removing the tool_result hook entirely and keeping only message_end satisfies that scope exactly, and eliminates the whole class of false positives above by construction โ there's no legitimate reason for the assistant's own prose to contain a real secret unless it's about to print one for the user.
Verified after the narrowing:
cat .envagainst a file with a real-lookingAPI_KEY=sk-...and apostgres://user:realpassword@hostURL โ passed through completely untouched (this is now explicitly out of scope, not a regression).- Asking the model to repeat a random-looking fake key (
sk-mK7qXz2LpN9wRt4VbY8sJd3FhC) verbatim โ still correctly redacted to[REDACTED:assignment]in the assistant's own reply. - Asking it to repeat a fake key built from sequential filler (
sk-abcdefghijklmnopqrstuvwxyz...) โ correctly left alone, matching the placeholder-detection design intent. - Reading secret-guard.ts's own source and this doc file back โ both display clean now, with no live re-redaction happening on top of them.
Aside โ debugging under a still-stale cached extension: while chasing the doc corruption, every read of the affected files (Read, cat, grep, even sed) was passing back through the old, not-yet-restarted tool_result hook, which kept re-redacting the same already-corrupted text on every single read โ consistently, since replacing [REDACTED:url-basic-auth] with [REDACTED:url-basic-auth] again is a no-op-looking but still "redacted" transformation. That made it impossible to trust any tool-mediated read as ground truth for what was actually on disk. The fix was to drop to python3 file I/O directly (open(path).read()) and write assertions/diffs to a temp file, then cat only that small, secret-free verification output โ sidestepping the tool layer entirely instead of trying to out-guess which layer of redaction was live at any given moment.
Debugging lesson: extensions can be cached at the host process level, not per-session
Hit this hard while iterating on scope-guard.ts under pi-web (a persistently-running pm2-managed pi host, see Pi Web UI + MCP Adapter Setup above). The symptom: edited and overwrote the file on disk multiple times, confirmed the new content with Read, but the running session kept producing the exact old error text โ including wording (PI_SCOPE_ROOTS, "outside the allowed scope") that no longer existed anywhere in the file. /reload didn't fix it. A brand-new chat session in the same pi-web instance hit the identical stale error, immediately โ which was the tell: if extensions reloaded fresh per-session, a new session would have read the current file. It didn't, which meant the extension module was cached once at the process level and reused across every session hosted by that process, not reloaded per-session or per-/reload.
Fix: restart the actual host process โ pm2 restart pi-web:49997 โ not /reload from inside a session. Confirmed immediately after: the new file's behavior took effect for every session on that host.
Takeaway: when an extension edit doesn't seem to take effect no matter how certain you are the file changed, check whether it's running under a long-lived host (pm2, docker, systemd, any "server that spawns/hosts sessions" setup) before assuming the code itself is wrong. /reload covers the interactive-CLI case pi's docs describe; it does not necessarily cover a hosted server that loaded extensions once at its own startup. A cheap sanity check when this is suspected: open a second, independent session against the same host and see if it exhibits the identical stale behavior โ if a fresh session shows the old bug too, it's a host-level cache, not a session-level one, and no amount of /reload will fix it short of restarting the host.
Aside โ working around a misbehaving path-scoping extension to debug itself: while the guard was actively (and wrongly) blocking absolute-path commands, inspecting the extensions directory itself was awkward โ ls/cat on ~/.pi/agent/extensions/scope-guard.ts tripped the very bug being diagnosed. A simple workaround: symlink the directory into the current project so it's reachable via a relative path instead:
ln -s ~/.pi/agent/extensions ~/Work/qwestly-workspace/_private/pi-extensions
Then ls/cat/find against _private/pi-extensions/... (relative to an already-safe cwd) instead of the absolute ~/.pi/agent/extensions/... path that the broken guard was misclassifying. General trick for debugging any tool that scopes/restricts based on path shape: relocate the thing you need to inspect into a path the tool already trusts, rather than fighting the tool's own logic to reach it.
scope-guard.ts v4: precision and cost fixes after the caching detour
Once the process-restart issue was understood (and no longer confused with the guard's own logic), a few real, independent issues turned up from actually exercising v3 under load:
RESTRICTED_SCANNERSmatchedlsinside unrelated subcommand names.\bls\btreats a hyphen as a word boundary, so it matchedlsinsidegit ls-files,git ls-remote,git ls-treeโ commands that have nothing to do with the filesystemls. Low real-world risk (also requires a broad path argument in the same command to actually trigger a block), but a real false-positive class, same shape as the2>&1issue below. Fixed with a negative lookahead:\bls(?!-)\b.- Dead code:
inamewas tracked through thefindโmdfindtranslator but never used โmdfind -namehas no case-sensitive variant, so-nameand-inamewere always translated identically. Removed the unused tracking and the no-op ternary that resulted from it (iname ? "-name" : "-name"). - Substitution notice could get truncated away, making a working guard indistinguishable from a broken one. The notice was embedded via a leading
echo ... 1>&2in the substituted command, so it lived inside the same stdout stream as the (possibly huge)mdfindoutput. Pi's own tool-output cap (2000 lines / 50KB) truncates from the head when output overflows, which silently ate the echoed notice along with the first N lines of results โ hit this personally: got a 5090-line dump with no visible confirmation the substitution had even happened, had to go read a saved log file directly to confirm it wasn't just the rawfind /leaking through. Fixed by moving the notice out of the executed command entirely: tracktoolCallId โ { original, substituted }in aMapwhen a rewrite happens, then a separatetool_resulthandler appends the notice as its own trailing content block (same techniquesecret-guarduses for its redaction-count note). A trailing, separately-tracked block survives truncation in a way that text baked into the front of a giant stdout blob does not. - Uncapped
mdfindsubstitutions could return thousands of results for a generic pattern. An unscoped root search has nothing to bound it by, sofind / -name '*.txt'โmdfind -name .txtlegitimately matched 5090 files. Pi's platform-level truncation bounded what actually reached context, but that's a bigger, noisier result than useful, and there's no reason to rely on a generic backstop when the guard is generating this command itself. Fixed by appending our own| head -n 50to the executed (capped) command while keeping the notice referencing the clean, uncapped translation โ so the model sees a short, useful result and an explicit "capped to the first 50 matches" caveat, not a wall of browser-cache noise.
All four verified together: find / -name '*.txt' now returns exactly 50 results with a truncation-proof notice; git ls-files and npm ls no longer false-trigger; grep -r 'x' / (untranslatable) still blocks correctly; ls -la ~/.pi/agent/skills/vercel/ (a real, narrow path) still passes through untouched.