Files
dev 7e50043984 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.
2026-07-16 13:46:22 +03:00

61 lines
2.4 KiB
TypeScript

/**
* Commit Guard Extension
*
* WHAT: On `git commit -m …`, validates the message against Conventional
* Commits and scans it for secrets. Invalid/secret → confirm (UI) or
* block (no UI). Non-`-m` commits (editor) are left alone.
*
* CONFIGURE: edit COMMIT_TYPES below. Secret patterns live 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";
// Conventional Commits types accepted in the header. Extend as needed.
const COMMIT_TYPES = ["feat", "fix", "chore", "docs", "refactor", "test", "perf", "build", "ci", "style", "revert"];
const HEADER_RE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\(.+\\))?!?: .+`);
/** Extract the -m / --message value from a `git commit` command, if present. */
function extractCommitMessage(command: string): string | null {
// Matches -m "msg", -m 'msg', --message=msg, -m msg (best effort).
const m =
command.match(/(?:-m|--message)[=\s]+"([^"]*)"/) ??
command.match(/(?:-m|--message)[=\s]+'([^']*)'/) ??
command.match(/(?:-m|--message)[=\s]+(\S+)/);
return m ? m[1] : null;
}
export default function commitGuardExtension(pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const command = (event.input.command as string | undefined) ?? "";
if (!/\bgit\s+commit\b/.test(command)) return undefined;
const message = extractCommitMessage(command);
if (message === null) return undefined; // editor-based commit; not our concern
const problems: string[] = [];
if (!HEADER_RE.test(message)) problems.push("не Conventional Commits");
const secretHits = scanSecrets(message);
if (secretHits.length > 0) problems.push(`секрет (${secretHits.join(", ")})`);
if (problems.length === 0) return undefined;
audit({ kind: "guardrail_block", guardrail: "commit-guard", problems, hasSecret: secretHits.length > 0 });
if (ctx.hasUI) {
const choice = await ctx.ui.select(
`⚠️ Проблема с сообщением коммита: ${problems.join("; ")}.\nФормат: type(scope): описание. Всё равно закоммитить?`,
["Нет", "Да"],
);
if (choice === "Да") return undefined;
}
return { block: true, reason: `Commit message rejected: ${problems.join("; ")}` };
});
}