feat(hooks): enforce-semgrep-security — block git commit when auth/billing/CSV/webhook in staged без Semgrep (Phase 4 #9)

This commit is contained in:
Дмитрий
2026-05-28 13:40:07 +03:00
parent d646097103
commit b369b2b7a5
6 changed files with 433 additions and 7 deletions
+10
View File
@@ -115,6 +115,16 @@
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-semgrep-security.mjs",
"timeout": 10
}
]
}
],
"PostToolUse": [
+13
View File
@@ -150,6 +150,19 @@ export function parseCoverageLine(text) {
return { channel: m[1].toLowerCase(), id: m[2] };
}
export function sessionToolUses(entries) {
if (!Array.isArray(entries)) return [];
const uses = [];
for (const e of entries) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
}
}
return uses;
}
export function turnToolUses(entries) {
const turn = lastTurnEntries(entries);
const uses = [];
+36
View File
@@ -20,8 +20,44 @@ import {
isDocsOnlyChange,
detectGitCommandKind,
detectFullTestRun,
sessionToolUses,
} from './enforce-hook-helpers.mjs';
describe('sessionToolUses', () => {
it('returns ALL tool uses across the full session, not just last turn', () => {
const entries = [
// turn 1
{ type: 'user', message: { content: [{ type: 'text', text: 'first' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo a' } }] } },
// turn 2
{ type: 'user', message: { content: [{ type: 'text', text: 'second' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'composer sast' } }] } },
// turn 3 (current)
{ type: 'user', message: { content: [{ type: 'text', text: 'third' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'git status' } }] } },
];
const uses = sessionToolUses(entries);
expect(uses).toHaveLength(3);
expect(uses.map(u => u.input.command)).toEqual(['echo a', 'composer sast', 'git status']);
});
it('returns [] for empty entries', () => {
expect(sessionToolUses([])).toEqual([]);
});
it('skips non-tool_use blocks', () => {
const entries = [
{ type: 'assistant', message: { content: [
{ type: 'text', text: 'hi' },
{ type: 'tool_use', name: 'Bash', input: { command: 'pwd' } },
] } },
];
const uses = sessionToolUses(entries);
expect(uses).toHaveLength(1);
expect(uses[0].name).toBe('Bash');
});
});
describe('parseEventJson', () => {
it('parses well-formed JSON', () => {
expect(parseEventJson('{"a":1}')).toEqual({ a: 1 });
+59 -7
View File
@@ -4,37 +4,89 @@
"phrases": [
{
"phrase": "без скилов",
"suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch", "graph-first", "chain-recommendation"],
"suppresses": [
"skill-required",
"coverage-skill-match",
"classifier-mismatch",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"description": "Skill discipline relaxed for this one prompt"
},
{
"phrase": "direct ok",
"suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch", "graph-first", "chain-recommendation"],
"suppresses": [
"skill-required",
"coverage-skill-match",
"classifier-mismatch",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"description": "Direct work allowed without skill invocation"
},
{
"phrase": "срочно",
"suppresses": ["verify-before-commit", "verify-before-push", "tdd-gate", "graph-first", "chain-recommendation"],
"suppresses": [
"verify-before-commit",
"verify-before-push",
"tdd-gate",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"description": "Urgency override: skip verification + TDD gate + graph/chain enforcement"
},
{
"phrase": "быстрый коммит",
"suppresses": ["verify-before-commit", "tdd-gate", "writing-plans-required", "graph-first", "chain-recommendation"],
"suppresses": [
"verify-before-commit",
"tdd-gate",
"writing-plans-required",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"description": "Quick commit: skip TDD + verify + plans + graph/chain enforcement"
},
{
"phrase": "recovery",
"suppresses": ["branch-switch", "git-recovery", "graph-first", "chain-recommendation"],
"suppresses": [
"branch-switch",
"git-recovery",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"description": "Git recovery operation, branch-state mismatch ok"
},
{
"phrase": "memory dump",
"suppresses": ["memory-sync-coverage", "skill-required", "graph-first", "chain-recommendation"],
"suppresses": [
"memory-sync-coverage",
"skill-required",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"description": "Memory write without separate coverage announcement"
},
{
"phrase": "ремонт инфраструктуры",
"suppresses": ["tdd-gate", "verify-before-commit", "verify-before-push", "writing-plans-required", "skill-required", "memory-sync-coverage", "classifier-mismatch", "coverage-skill-match", "graph-first", "chain-recommendation"],
"suppresses": [
"tdd-gate",
"verify-before-commit",
"verify-before-push",
"writing-plans-required",
"skill-required",
"memory-sync-coverage",
"classifier-mismatch",
"coverage-skill-match",
"graph-first",
"chain-recommendation",
"semgrep-security"
],
"requires_justification": "ремонт:",
"description": "Bypass all rules (full opt-out). Requires 'ремонт: <what>' line in same prompt."
}
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Rule — Semgrep on security-edit.
*
* PreToolUse Bash hook. When the controller invokes `git commit` and the staged
* diff includes auth/billing/CSV/webhook files but Semgrep has not been run in
* this session, block with remediation instructions.
*
* Three escape hatches:
* 1. Run Semgrep first via Bash (`npm run sast`, `semgrep ...`).
* 2. Write semgrep-skip: <non-empty reason> on a line in the assistant text.
* 3. User prompt contains a global override phrase (vocab-driven).
*
* Spec: self-retrospect 28.05 habit #4. brain-retro #9 + retro-7 background.
*/
import { execFileSync } from 'child_process';
import {
readStdin,
parseEventJson,
readTranscript,
lastUserPromptText,
lastAssistantText,
sessionToolUses,
findOverride,
logOverride,
exitDecision,
} from './enforce-hook-helpers.mjs';
const RULE_KEY = 'semgrep-security';
const GIT_COMMIT_RE = /^\s*git\s+commit\b/;
const SEMGREP_SKIP_RE = /^semgrep-skip:\s*\S+/m;
const SEMGREP_CMD_RE = /\b(semgrep\b|composer\s+sast\b|npm\s+run\s+sast\b)/i;
const SECURITY_PATH_PATTERNS = [
/(?:^|\/)(?:Auth|Authenticate|Authenticated|Authorization|Authorize)\b/i,
/Billing/i,
/Ledger/i,
/(?:Csv|CSV)/i,
/(?:^|\/)Imports\b/i,
/Webhook/i,
];
export function isSecurityRelevantPath(path) {
if (!path || typeof path !== 'string') return false;
const norm = path.replace(/\\/g, '/');
for (const re of SECURITY_PATH_PATTERNS) {
if (re.test(norm)) return true;
}
return false;
}
export function extractStagedFiles(stdout) {
if (!stdout || typeof stdout !== 'string') return [];
return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
}
export function sessionRanSemgrep(toolUses) {
if (!Array.isArray(toolUses)) return false;
for (const u of toolUses) {
if (!u || u.name !== 'Bash') continue;
const cmd = String((u.input && u.input.command) || '');
if (SEMGREP_CMD_RE.test(cmd)) return true;
}
return false;
}
export function decide({ command, stagedFiles, semgrepRan, assistantText, override }) {
// Step 1: only act on git commit invocations.
if (typeof command !== 'string' || !GIT_COMMIT_RE.test(command)) return { block: false };
// Step 2: global override -> pass.
if (override) return { block: false };
// Step 3: identify security-relevant staged files.
const security = (Array.isArray(stagedFiles) ? stagedFiles : []).filter(isSecurityRelevantPath);
if (security.length === 0) return { block: false };
// Step 4: Semgrep already ran this session -> pass.
if (semgrepRan) return { block: false };
// Step 5: inline semgrep-skip with non-empty reason -> pass.
if (typeof assistantText === 'string' && SEMGREP_SKIP_RE.test(assistantText)) return { block: false };
// Step 6: block.
const list = security.slice(0, 5).map((p) => ' - ' + p).join('\n');
const extra = security.length > 5 ? ' ... (+' + (security.length - 5) + ' ещё)\n' : '';
const message = [
'[enforce-semgrep-security] В коммите есть ' + security.length + ' файл(ов) с security-влиянием (auth/billing/CSV/webhook):',
list + (extra ? '\n' + extra : ''),
'но Semgrep не запускался в этой сессии (self-retrospect 28.05 привычка #4).',
'Сделай ОДНО из трёх:',
' 1. Запусти Semgrep на diff: `npm run sast` (или `semgrep scan --config p/php app/`).',
' 2. Добавь строку semgrep-skip: <одна строка причины> в свой ответ.',
' 3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).',
].join('\n');
return { block: true, message };
}
function readStagedFilesSafe() {
try {
const out = execFileSync('git', ['diff', '--cached', '--name-only'], { encoding: 'utf-8' });
return extractStagedFiles(out);
} catch {
return [];
}
}
async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
if (event.tool_name !== 'Bash') { exitDecision({ block: false }); return; }
const command = String((event.tool_input && event.tool_input.command) || '');
if (!GIT_COMMIT_RE.test(command)) { exitDecision({ block: false }); return; }
const transcript = readTranscript(event.transcript_path);
const userPrompt = lastUserPromptText(transcript);
const assistantText = lastAssistantText(transcript);
const sessionUses = sessionToolUses(transcript);
const override = findOverride(userPrompt, RULE_KEY);
if (override) logOverride(RULE_KEY, override, event.session_id);
const stagedFiles = readStagedFilesSafe();
const semgrepRan = sessionRanSemgrep(sessionUses);
exitDecision(decide({ command, stagedFiles, semgrepRan, assistantText, override }));
} catch {
exitDecision({ block: false });
}
}
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-semgrep-security.mjs');
if (isCli) main();
+180
View File
@@ -0,0 +1,180 @@
import { describe, it, expect } from 'vitest';
import { decide, extractStagedFiles, isSecurityRelevantPath, sessionRanSemgrep } from './enforce-semgrep-security.mjs';
import { findOverride } from './enforce-hook-helpers.mjs';
describe('isSecurityRelevantPath', () => {
it('matches auth files', () => {
expect(isSecurityRelevantPath('app/Http/Controllers/Auth/LoginController.php')).toBe(true);
expect(isSecurityRelevantPath('app/Http/Middleware/Authenticate.php')).toBe(true);
});
it('matches billing/ledger files', () => {
expect(isSecurityRelevantPath('app/Services/BillingService.php')).toBe(true);
expect(isSecurityRelevantPath('app/Services/LedgerService.php')).toBe(true);
});
it('matches CSV import/export files', () => {
expect(isSecurityRelevantPath('app/Imports/SupplierLeadsImport.php')).toBe(true);
expect(isSecurityRelevantPath('app/Jobs/CsvReconcileJob.php')).toBe(true);
expect(isSecurityRelevantPath('app/Http/Controllers/DealCsvController.php')).toBe(true);
});
it('matches webhook files', () => {
expect(isSecurityRelevantPath('app/Http/Controllers/SupplierWebhookController.php')).toBe(true);
expect(isSecurityRelevantPath('app/Services/WebhookSignatureVerifier.php')).toBe(true);
});
it('does NOT match docs/normal files', () => {
expect(isSecurityRelevantPath('docs/superpowers/plans/2026-05-28-phase4.md')).toBe(false);
expect(isSecurityRelevantPath('memory/feedback_communication.md')).toBe(false);
expect(isSecurityRelevantPath('app/Models/Tenant.php')).toBe(false);
expect(isSecurityRelevantPath('app/Http/Controllers/HomeController.php')).toBe(false);
});
it('returns false for null/empty', () => {
expect(isSecurityRelevantPath(null)).toBe(false);
expect(isSecurityRelevantPath('')).toBe(false);
});
});
describe('extractStagedFiles', () => {
it('parses git diff --cached --name-only output', () => {
const stdout = 'app/Services/BillingService.php\napp/Models/Deal.php\n';
expect(extractStagedFiles(stdout)).toEqual([
'app/Services/BillingService.php',
'app/Models/Deal.php',
]);
});
it('skips blank lines', () => {
expect(extractStagedFiles('a.php\n\nb.php\n')).toEqual(['a.php', 'b.php']);
});
it('returns [] for empty stdout', () => {
expect(extractStagedFiles('')).toEqual([]);
expect(extractStagedFiles(null)).toEqual([]);
});
});
describe('sessionRanSemgrep', () => {
it('returns true when a Bash tool_use ran semgrep CLI', () => {
const sessionUses = [
{ name: 'Bash', input: { command: 'pwd' } },
{ name: 'Bash', input: { command: 'semgrep scan --config p/php' } },
];
expect(sessionRanSemgrep(sessionUses)).toBe(true);
});
it('returns true when "composer sast" ran', () => {
expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'composer sast' } }])).toBe(true);
expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'composer sast -- --diff' } }])).toBe(true);
});
it('returns true when "npm run sast" ran', () => {
expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'npm run sast' } }])).toBe(true);
});
it('returns false when no semgrep-like command ran', () => {
expect(sessionRanSemgrep([
{ name: 'Bash', input: { command: 'git status' } },
{ name: 'Bash', input: { command: 'npm test' } },
])).toBe(false);
});
it('returns false for empty list', () => {
expect(sessionRanSemgrep([])).toBe(false);
});
it('ignores tool_use that is not Bash', () => {
expect(sessionRanSemgrep([{ name: 'Skill', input: { skill: 'semgrep' } }])).toBe(false);
});
});
describe('decide() — enforce-semgrep-security', () => {
it('passes when command is NOT a git commit', () => {
expect(decide({
command: 'git status',
stagedFiles: ['app/Services/BillingService.php'],
semgrepRan: false,
assistantText: '',
override: null,
})).toEqual({ block: false });
});
it('passes when no security-relevant files in staged', () => {
expect(decide({
command: 'git commit -m "docs: update"',
stagedFiles: ['docs/foo.md', 'memory/bar.md'],
semgrepRan: false,
assistantText: '',
override: null,
})).toEqual({ block: false });
});
it('passes when Semgrep ran this session', () => {
expect(decide({
command: 'git commit -m "feat: billing"',
stagedFiles: ['app/Services/BillingService.php'],
semgrepRan: true,
assistantText: '',
override: null,
})).toEqual({ block: false });
});
it('passes with global override', () => {
expect(decide({
command: 'git commit -m "fix"',
stagedFiles: ['app/Services/BillingService.php'],
semgrepRan: false,
assistantText: '',
override: { phrase: 'срочно' },
})).toEqual({ block: false });
});
it('passes with inline semgrep-skip with non-empty reason', () => {
expect(decide({
command: 'git commit -m "fix"',
stagedFiles: ['app/Services/BillingService.php'],
semgrepRan: false,
assistantText: 'something\nsemgrep-skip: тривиальный docstring fix\nother',
override: null,
})).toEqual({ block: false });
});
it('does NOT pass with empty semgrep-skip reason', () => {
const r = decide({
command: 'git commit -m "fix"',
stagedFiles: ['app/Services/BillingService.php'],
semgrepRan: false,
assistantText: 'semgrep-skip: ',
override: null,
});
expect(r.block).toBe(true);
});
it('blocks when commit has security file + no Semgrep + no override', () => {
const r = decide({
command: 'git commit -m "feat: billing fix"',
stagedFiles: ['app/Services/BillingService.php', 'app/Models/Deal.php'],
semgrepRan: false,
assistantText: '',
override: null,
});
expect(r.block).toBe(true);
expect(r.message).toContain('Semgrep');
expect(r.message).toContain('BillingService');
});
});
describe('override vocab coverage', () => {
it("global override \"без скилов\" suppresses semgrep-security", () => {
const o = findOverride("без скилов", 'semgrep-security');
expect(o).toBeTruthy();
});
it("global override \"direct ok\" suppresses semgrep-security", () => {
const o = findOverride("direct ok", 'semgrep-security');
expect(o).toBeTruthy();
});
it("global override \"срочно\" suppresses semgrep-security", () => {
const o = findOverride("срочно", 'semgrep-security');
expect(o).toBeTruthy();
});
it("global override \"быстрый коммит\" suppresses semgrep-security", () => {
const o = findOverride("быстрый коммит", 'semgrep-security');
expect(o).toBeTruthy();
});
it("global override \"recovery\" suppresses semgrep-security", () => {
const o = findOverride("recovery", 'semgrep-security');
expect(o).toBeTruthy();
});
it("global override \"memory dump\" suppresses semgrep-security", () => {
const o = findOverride("memory dump", 'semgrep-security');
expect(o).toBeTruthy();
});
it("global override \"ремонт инфраструктуры\" suppresses semgrep-security", () => {
const o = findOverride("ремонт инфраструктуры\nремонт: test reason", 'semgrep-security');
expect(o).toBeTruthy();
});
});