- 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("; ")}` };
|
|
});
|
|
}
|