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:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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("; ")}` };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user