diff --git a/.claude/settings.json b/.claude/settings.json index b40b0a6c..b77f3fe0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -115,6 +115,16 @@ "timeout": 5 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node tools/enforce-semgrep-security.mjs", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/tools/enforce-hook-helpers.mjs b/tools/enforce-hook-helpers.mjs index d2c1783c..fe4a48f7 100644 --- a/tools/enforce-hook-helpers.mjs +++ b/tools/enforce-hook-helpers.mjs @@ -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 = []; diff --git a/tools/enforce-hook-helpers.test.mjs b/tools/enforce-hook-helpers.test.mjs index bea8cc0c..675bd191 100644 --- a/tools/enforce-hook-helpers.test.mjs +++ b/tools/enforce-hook-helpers.test.mjs @@ -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 }); diff --git a/tools/enforce-override-vocab.json b/tools/enforce-override-vocab.json index 9b5f9fd7..ea831016 100644 --- a/tools/enforce-override-vocab.json +++ b/tools/enforce-override-vocab.json @@ -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 'ремонт: ' line in same prompt." } diff --git a/tools/enforce-semgrep-security.mjs b/tools/enforce-semgrep-security.mjs new file mode 100644 index 00000000..fa5d1ee2 --- /dev/null +++ b/tools/enforce-semgrep-security.mjs @@ -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: 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(); \ No newline at end of file diff --git a/tools/enforce-semgrep-security.test.mjs b/tools/enforce-semgrep-security.test.mjs new file mode 100644 index 00000000..2adf269f --- /dev/null +++ b/tools/enforce-semgrep-security.test.mjs @@ -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(); + }); +});