- 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.
61 lines
2.4 KiB
TypeScript
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("; ")}` };
|
||
});
|
||
}
|