feat(security): secret-scanner, commit-guard, llm-redaction, audit-log

- 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.
This commit is contained in:
2026-07-16 13:46:22 +03:00
parent 295f620710
commit 7e50043984
9 changed files with 388 additions and 2 deletions

View File

@@ -0,0 +1,46 @@
/**
* 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(", ")})` };
});
}