- lib/secrets.ts: shared SECRET_PATTERNS + scanSecrets/redact (AWS/GitLab/GitHub/Slack tokens, private keys, JWT, generic credentials) - lib/audit.ts: local JSONL audit (~/.config/pi-kit/audit.jsonl, 600), optional PI_KIT_AUDIT_URL POST; never logs secret values - secret-scanner: block write/edit whose content matches a secret (complements protected-paths) - commit-guard: enforce Conventional Commits on 'git commit -m' + scan message for secrets - llm-redaction: redact secrets from outbound payload via before_provider_request - audit-log: session_start + slash-command usage; wires company.json auditEndpoint into env - config: auditEndpoint (null); package.json: npm test; test/guardrails.test.ts (16 cases, all pass) Verified: all extensions load via pi (lib/ not loaded as extensions); npm test green.
47 lines
1.7 KiB
TypeScript
47 lines
1.7 KiB
TypeScript
/**
|
||
* Secret Scanner Extension
|
||
*
|
||
* WHAT: Blocks `write`/`edit` tool calls whose content looks like a secret
|
||
* (API keys, tokens, private keys, credentials). Complements
|
||
* protected-paths (which blocks by path) by scanning content.
|
||
*
|
||
* CONFIGURE: edit SECRET_PATTERNS in extensions/lib/secrets.ts.
|
||
*
|
||
* DISABLE: remove/rename this file from the package `extensions/` folder.
|
||
*/
|
||
|
||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||
import { scanSecrets } from "./lib/secrets.ts";
|
||
import { audit } from "./lib/audit.ts";
|
||
|
||
export default function secretScannerExtension(pi: ExtensionAPI) {
|
||
pi.on("tool_call", async (event, ctx) => {
|
||
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
|
||
|
||
// Scan every string field of the input except the path (content, new_string,
|
||
// etc.) so we don't depend on a specific tool's field naming.
|
||
const input = event.input as Record<string, unknown>;
|
||
const parts: string[] = [];
|
||
for (const [key, value] of Object.entries(input)) {
|
||
if (key === "path") continue;
|
||
if (typeof value === "string") parts.push(value);
|
||
}
|
||
|
||
const hits = scanSecrets(parts.join("\n"));
|
||
if (hits.length === 0) return undefined;
|
||
|
||
const path = typeof input.path === "string" ? input.path : "(unknown)";
|
||
audit({ kind: "guardrail_block", guardrail: "secret-scanner", tool: event.toolName, path, patterns: hits });
|
||
|
||
if (ctx.hasUI) {
|
||
const choice = await ctx.ui.select(
|
||
`⚠️ Похоже на секрет (${hits.join(", ")}) в записи → ${path}\nВсё равно записать?`,
|
||
["Нет", "Да"],
|
||
);
|
||
if (choice === "Да") return undefined;
|
||
}
|
||
|
||
return { block: true, reason: `Possible secret in content (${hits.join(", ")})` };
|
||
});
|
||
}
|