Commit b626269eb8ca

Vincent Demeester <vincent@sbr.pm>
2026-02-15 22:17:28
feat(pi): add filter-output extension for secret redaction
Redacts sensitive data from tool outputs before the LLM sees them: API keys, tokens, passwords, private keys, age secret keys, and connection strings. Also blocks reading .env, secrets, and credentials files entirely.
1 parent b46ef27
Changed files (2)
dots
pi
agent
extensions
dots/pi/agent/extensions/filter-output/index.ts
@@ -0,0 +1,117 @@
+/**
+ * Filter Output Extension for Pi
+ *
+ * Redacts sensitive data from tool outputs before the LLM sees them.
+ * Prevents accidental leakage of API keys, tokens, passwords,
+ * private keys, and connection strings into LLM context.
+ *
+ * Also blocks reading of known sensitive files entirely.
+ *
+ * Source: adapted from michalvavra/agents filter-output.ts
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+// ── Sensitive data patterns ───────────────────────────────────
+
+const sensitivePatterns = [
+	// API keys
+	{ pattern: /\b(sk-[a-zA-Z0-9]{20,})\b/g, replacement: "[OPENAI_KEY_REDACTED]" },
+	{ pattern: /\b(ghp_[a-zA-Z0-9]{36,})\b/g, replacement: "[GITHUB_TOKEN_REDACTED]" },
+	{ pattern: /\b(gho_[a-zA-Z0-9]{36,})\b/g, replacement: "[GITHUB_OAUTH_REDACTED]" },
+	{ pattern: /\b(ghs_[a-zA-Z0-9]{36,})\b/g, replacement: "[GITHUB_SERVER_REDACTED]" },
+	{ pattern: /\b(xox[baprs]-[a-zA-Z0-9-]{10,})\b/g, replacement: "[SLACK_TOKEN_REDACTED]" },
+	{ pattern: /\b(AKIA[A-Z0-9]{16})\b/g, replacement: "[AWS_KEY_REDACTED]" },
+
+	// Generic key=value secrets
+	{
+		pattern: /\b(api[_-]?key|apikey)\s*[=:]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?/gi,
+		replacement: "$1=[REDACTED]",
+	},
+	{
+		pattern: /\b(secret|token|password|passwd|pwd)\s*[=:]\s*['"]?([^\s'"]{8,})['"]?/gi,
+		replacement: "$1=[REDACTED]",
+	},
+
+	// Bearer tokens
+	{ pattern: /\b(bearer)\s+([a-zA-Z0-9._-]{20,})\b/gi, replacement: "Bearer [REDACTED]" },
+
+	// Connection strings with embedded passwords
+	{ pattern: /(mongodb(\+srv)?:\/\/[^:]+:)[^@]+(@)/gi, replacement: "$1[REDACTED]$3" },
+	{ pattern: /(postgres(ql)?:\/\/[^:]+:)[^@]+(@)/gi, replacement: "$1[REDACTED]$3" },
+	{ pattern: /(mysql:\/\/[^:]+:)[^@]+(@)/gi, replacement: "$1[REDACTED]$3" },
+	{ pattern: /(redis:\/\/[^:]+:)[^@]+(@)/gi, replacement: "$1[REDACTED]$3" },
+
+	// Private keys
+	{
+		pattern: /-----BEGIN (RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----[\s\S]*?-----END \1PRIVATE KEY-----/g,
+		replacement: "[PRIVATE_KEY_REDACTED]",
+	},
+
+	// Age keys (used by agenix)
+	{ pattern: /\bAGE-SECRET-KEY-[A-Z0-9]+\b/g, replacement: "[AGE_SECRET_KEY_REDACTED]" },
+
+	// ntfy/webhook tokens in URLs
+	{ pattern: /(https?:\/\/[^/]+\/)[a-zA-Z0-9_-]{20,}$/gm, replacement: "$1[TOKEN_REDACTED]" },
+];
+
+// ── Files to block reading entirely ───────────────────────────
+
+const sensitiveFiles = [
+	/\.env$/,
+	/\.env\.[^/]+$/,
+	/\.dev\.vars($|\.[^/]+$)/,
+	/secrets?\.(json|ya?ml|toml)$/i,
+	/credentials/i,
+];
+
+// ── Extension entry point ─────────────────────────────────────
+
+export default function (pi: ExtensionAPI) {
+	pi.on("tool_result", async (event, ctx) => {
+		if (event.isError) return undefined;
+
+		const textContent = event.content.find(
+			(c): c is { type: "text"; text: string } => c.type === "text",
+		);
+		if (!textContent) return undefined;
+
+		// Block reading sensitive files entirely
+		if (event.toolName === "read") {
+			const filePath = (event.input.path as string) || "";
+			// Allow .env.example files
+			if (/(^|\/)\.env\.example$/i.test(filePath)) {
+				return undefined;
+			}
+			for (const pattern of sensitiveFiles) {
+				if (pattern.test(filePath)) {
+					ctx.ui.notify(`🔒 Redacted contents of sensitive file: ${filePath}`, "info");
+					return {
+						content: [{ type: "text", text: `[Contents of ${filePath} redacted for security]` }],
+					};
+				}
+			}
+		}
+
+		// Redact sensitive patterns from all tool outputs
+		let result = textContent.text;
+		let wasModified = false;
+
+		for (const { pattern, replacement } of sensitivePatterns) {
+			// Reset lastIndex for global regexes
+			pattern.lastIndex = 0;
+			const newResult = result.replace(pattern, replacement);
+			if (newResult !== result) {
+				wasModified = true;
+				result = newResult;
+			}
+		}
+
+		if (wasModified) {
+			ctx.ui.notify("🔒 Sensitive data redacted from output", "info");
+			return { content: [{ type: "text", text: result }] };
+		}
+
+		return undefined;
+	});
+}
dots/pi/agent/extensions/filter-output/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "filter-output",
+  "version": "1.0.0",
+  "type": "module",
+  "main": "index.ts",
+  "dependencies": {
+    "@mariozechner/pi-coding-agent": "*"
+  }
+}