feat(hooks): enforce-semgrep-security — block git commit when auth/billing/CSV/webhook in staged без Semgrep (Phase 4 #9)
This commit is contained in:
@@ -115,6 +115,16 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-semgrep-security.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user