diff --git a/tools/enforce-hook-helpers.mjs b/tools/enforce-hook-helpers.mjs new file mode 100644 index 00000000..57d504f5 --- /dev/null +++ b/tools/enforce-hook-helpers.mjs @@ -0,0 +1,339 @@ +/** + * Shared helpers for the 10-rule enforcement hook layer. + * + * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md + * Plan: docs/superpowers/plans/2026-05-25-enforce-hard-rules.md + * + * Design contract: ALL hooks MUST fail-quiet on internal error (exit 0 with empty {}). + * Only deliberate enforcement violations exit 2. + * + * Security note: this file uses child_process.execFileSync with FIXED arguments + * (no user input concatenation) — pattern is safe by construction. No injection + * surface. See readGitBranch(). + * + * Security Guidance #40: pure parsing — no exec/execSync except readGitBranch which + * is the documented use case (fixed args, no user input). + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Read full stdin as utf-8 string. Returns '' on empty/error. */ +export async function readStdin(stdinStream = process.stdin) { + return new Promise((resolve) => { + let data = ''; + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; resolve(data); }, 4500); + stdinStream.setEncoding('utf-8'); + stdinStream.on('data', (chunk) => { data += chunk; }); + stdinStream.on('end', () => { + if (timedOut) return; + clearTimeout(timer); + resolve(data); + }); + stdinStream.on('error', () => { + clearTimeout(timer); + resolve(''); + }); + }); +} + +export function parseEventJson(raw) { + try { return JSON.parse(raw || '{}'); } catch { return {}; } +} + +/** Runtime directory: ~/.claude/runtime/ */ +export function runtimeDir() { + const dir = join(homedir(), '.claude', 'runtime'); + try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } + return dir; +} + +export function sentinelPath(name, sessionId) { + return join(runtimeDir(), `${name}-${sessionId || 'unknown'}.json`); +} + +export function writeSentinel(name, sessionId, data) { + try { + const p = sentinelPath(name, sessionId); + writeFileSync(p, JSON.stringify({ ...data, written_at: new Date().toISOString() }, null, 2)); + return p; + } catch { return null; } +} + +export function readSentinel(name, sessionId) { + try { + const p = sentinelPath(name, sessionId); + if (!existsSync(p)) return null; + return JSON.parse(readFileSync(p, 'utf-8')); + } catch { return null; } +} + +export function sentinelAgeSec(name, sessionId) { + const s = readSentinel(name, sessionId); + if (!s || !s.written_at) return null; + const ms = Date.now() - new Date(s.written_at).getTime(); + if (!Number.isFinite(ms)) return null; + return Math.floor(ms / 1000); +} + +export function readTranscript(transcriptPath) { + if (!transcriptPath || typeof transcriptPath !== 'string') return []; + if (!existsSync(transcriptPath)) return []; + try { + const raw = readFileSync(transcriptPath, 'utf-8'); + const lines = raw.split('\n').filter(Boolean); + const out = []; + for (const l of lines) { + try { out.push(JSON.parse(l)); } catch { /* skip */ } + } + return out; + } catch { return []; } +} + +export function lastTurnEntries(entries) { + if (!Array.isArray(entries) || entries.length === 0) return []; + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e && e.message && e.message.role === 'user') { + const c = e.message.content; + if (typeof c === 'string' && c.trim().length > 0) return entries.slice(i); + if (Array.isArray(c)) { + const hasToolResult = c.some((b) => b && b.type === 'tool_result'); + const hasText = c.some((b) => b && b.type === 'text'); + if (hasText && !hasToolResult) return entries.slice(i); + } + } + } + return entries; +} + +export function lastUserPromptText(entries) { + const turn = lastTurnEntries(entries); + if (!turn || turn.length === 0) return ''; + const e = turn[0]; + if (!e || !e.message) return ''; + const c = e.message.content; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n'); + } + return ''; +} + +export function lastAssistantText(entries) { + const turn = lastTurnEntries(entries); + let out = ''; + for (const e of turn) { + if (e && e.message && e.message.role === 'assistant') { + const c = e.message.content; + if (Array.isArray(c)) { + for (const b of c) { + if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n'; + } + } + } + } + return out; +} + +export function parseCoverageLine(text) { + if (typeof text !== 'string') return null; + const m = text.match(/coverage:\s*(skill|node|chain|hook|agent|direct)\s*:\s*([^\s\n<>]+)/i); + if (!m) return null; + return { channel: m[1].toLowerCase(), id: m[2] }; +} + +export function turnToolUses(entries) { + const turn = lastTurnEntries(entries); + const uses = []; + for (const e of turn) { + 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 turnToolResults(entries) { + const turn = lastTurnEntries(entries); + const results = []; + for (const e of turn) { + const c = e && e.message && e.message.content; + if (!Array.isArray(c)) continue; + for (const b of c) { + if (b && b.type === 'tool_result') { + const txt = typeof b.content === 'string' ? b.content + : Array.isArray(b.content) ? b.content.map((p) => (p && p.text) || '').join('\n') : ''; + results.push({ tool_use_id: b.tool_use_id, is_error: b.is_error === true, content: txt }); + } + } + } + return results; +} + +let _vocabCache = null; +export function loadOverrideVocab(path) { + if (_vocabCache) return _vocabCache; + try { + const p = path || join(__dirname, 'enforce-override-vocab.json'); + if (!existsSync(p)) return { phrases: [] }; + _vocabCache = JSON.parse(readFileSync(p, 'utf-8')); + return _vocabCache; + } catch { return { phrases: [] }; } +} + +export function _resetVocabCache() { _vocabCache = null; } + +export function findOverride(userPrompt, ruleKey, vocab) { + if (!userPrompt || typeof userPrompt !== 'string') return null; + const v = vocab || loadOverrideVocab(); + const lo = userPrompt.toLowerCase(); + for (const p of v.phrases || []) { + if (!p.phrase || !Array.isArray(p.suppresses)) continue; + if (!lo.includes(p.phrase.toLowerCase())) continue; + if (p.suppresses.includes(ruleKey)) return p; + } + return null; +} + +export function logOverride(ruleKey, phraseObj, sessionId) { + try { + const f = join(runtimeDir(), 'override-usage.jsonl'); + appendFileSync(f, JSON.stringify({ + ts: new Date().toISOString(), + session_id: sessionId || null, + rule: ruleKey, + phrase: phraseObj && phraseObj.phrase, + }) + '\n'); + } catch { /* ignore */ } +} + +/** + * Read current git branch via execFileSync with fixed args (no shell, no user + * input concatenation — safe by construction). Returns empty string on error. + */ +export function readGitBranch(cwd) { + try { + return execFileSync('git', ['branch', '--show-current'], { + cwd: cwd || process.cwd(), + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { return ''; } +} + +export function expectedBranchPath(sessionId) { + return join(runtimeDir(), `expected-branch-${sessionId || 'unknown'}`); +} + +export function getExpectedBranch(sessionId) { + try { + const p = expectedBranchPath(sessionId); + if (!existsSync(p)) return ''; + return readFileSync(p, 'utf-8').trim(); + } catch { return ''; } +} + +export function setExpectedBranch(sessionId, branch) { + try { + writeFileSync(expectedBranchPath(sessionId), String(branch || '').trim()); + return true; + } catch { return false; } +} + +export function appendRationalizationFlag(sessionId, kind, evidence) { + try { + const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`); + appendFileSync(f, JSON.stringify({ + ts: new Date().toISOString(), + kind, + evidence: typeof evidence === 'string' ? evidence.slice(0, 240) : evidence, + }) + '\n'); + } catch { /* ignore */ } +} + +export function readRationalizationFlags(sessionId) { + try { + const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`); + if (!existsSync(f)) return []; + return readFileSync(f, 'utf-8').split('\n').filter(Boolean).map((l) => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + } catch { return []; } +} + +export function readRouterState(sessionId) { + try { + const p = join(runtimeDir(), `router-state-${sessionId || 'unknown'}.json`); + if (!existsSync(p)) return null; + return JSON.parse(readFileSync(p, 'utf-8')); + } catch { return null; } +} + +export function exitDecision({ block, message } = {}) { + if (block) { + if (message) process.stderr.write(message + '\n'); + process.exit(2); + return; + } + try { process.stdout.write('{}'); } catch { /* ignore */ } + process.exit(0); +} + +export function isProductionCodePath(p) { + if (typeof p !== 'string') return false; + const n = p.replace(/\\/g, '/'); + if (/\.(test|spec)\.[a-z0-9]+$/i.test(n)) return false; + if (/(?:^|\/)tests?\//.test(n) || /(?:^|\/)spec\//.test(n)) return false; + if (/(?:^|\/)tools\/[^/]+\.mjs$/.test(n)) return true; + if (/(?:^|\/)app\/app\/.+\.php$/.test(n)) return true; + if (/(?:^|\/)resources\/js\/.+\.(vue|ts|tsx|js)$/.test(n)) return true; + return false; +} + +export function isMemoryPath(p) { + if (typeof p !== 'string') return false; + const n = p.replace(/\\/g, '/'); + if (/\/memory\/[^/]+\.md$/i.test(n)) return true; + if (/\/MEMORY\.md$/i.test(n)) return true; + return false; +} + +export function detectGitCommandKind(cmd) { + if (typeof cmd !== 'string') return null; + const c = cmd.trim(); + if (/(^|\s|;|&&|\|\|)git\s+push\b/i.test(c)) return 'push'; + if (/(^|\s|;|&&|\|\|)git\s+commit\b/i.test(c)) return 'commit'; + if (/(^|\s|;|&&|\|\|)git\s+cherry-pick\b/i.test(c)) return 'cherry-pick'; + if (/(^|\s|;|&&|\|\|)git\s+reset\s+--hard\b/i.test(c)) return 'reset-hard'; + if (/(^|\s|;|&&|\|\|)git\s+rebase\b/i.test(c)) return 'rebase'; + if (/(^|\s|;|&&|\|\|)git\s+branch\s+-[df]\b/i.test(c)) return 'branch-force'; + return null; +} + +export function detectFullTestRun(cmd) { + if (typeof cmd !== 'string') return null; + const c = cmd.toLowerCase(); + if (/\bvitest\s+run\b/.test(c) && !/\btools\/[^\s]+\.test\.mjs\b/.test(c)) return 'vitest-full'; + if (/\bnpm\s+run\s+test\b/.test(c)) return 'npm-test'; + if (/\bphp\s+artisan\s+test\b/.test(c) || /\bcomposer\s+test\b/.test(c)) return 'pest'; + if (/\bpest\b/.test(c)) return 'pest'; + return null; +} + +export function isVerificationFresh(sessionId, maxAgeSec = 1800) { + const s = readSentinel('verify-pass', sessionId); + if (!s || s.result !== 'pass') return false; + const age = sentinelAgeSec('verify-pass', sessionId); + return age !== null && age <= maxAgeSec; +} diff --git a/tools/enforce-hook-helpers.test.mjs b/tools/enforce-hook-helpers.test.mjs new file mode 100644 index 00000000..0b2eadc4 --- /dev/null +++ b/tools/enforce-hook-helpers.test.mjs @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + parseEventJson, + parseCoverageLine, + lastTurnEntries, + lastUserPromptText, + lastAssistantText, + turnToolUses, + turnToolResults, + loadOverrideVocab, + _resetVocabCache, + findOverride, + isProductionCodePath, + isMemoryPath, + detectGitCommandKind, + detectFullTestRun, +} from './enforce-hook-helpers.mjs'; + +describe('parseEventJson', () => { + it('parses well-formed JSON', () => { + expect(parseEventJson('{"a":1}')).toEqual({ a: 1 }); + }); + it('returns empty object on broken JSON', () => { + expect(parseEventJson('not-json')).toEqual({}); + }); + it('returns empty object on empty input', () => { + expect(parseEventJson('')).toEqual({}); + expect(parseEventJson(null)).toEqual({}); + }); +}); + +describe('parseCoverageLine', () => { + it('extracts skill coverage', () => { + const t = 'экономия: 100%\n\ncoverage: skill:superpowers:test-driven-development\n\nок поехали'; + expect(parseCoverageLine(t)).toEqual({ channel: 'skill', id: 'superpowers:test-driven-development' }); + }); + it('extracts direct coverage', () => { + expect(parseCoverageLine('coverage: direct:memory-sync')).toEqual({ channel: 'direct', id: 'memory-sync' }); + }); + it('extracts node coverage', () => { + expect(parseCoverageLine('coverage: node:#19')).toEqual({ channel: 'node', id: '#19' }); + }); + it('is case-insensitive on channel keyword', () => { + expect(parseCoverageLine('Coverage: Skill:foo')).toEqual({ channel: 'skill', id: 'foo' }); + }); + it('returns null when no coverage line present', () => { + expect(parseCoverageLine('just some text')).toBeNull(); + }); + it('returns null on non-string input', () => { + expect(parseCoverageLine(null)).toBeNull(); + expect(parseCoverageLine(42)).toBeNull(); + }); +}); + +describe('lastTurnEntries / lastUserPromptText / lastAssistantText / turnToolUses', () => { + const entries = [ + { message: { role: 'user', content: 'old prompt' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'old reply' }] } }, + { message: { role: 'user', content: 'new prompt' } }, + { message: { role: 'assistant', content: [ + { type: 'text', text: 'I will edit' }, + { type: 'tool_use', name: 'Edit', input: { file_path: 'a.mjs' } }, + ] } }, + { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'ok', is_error: false }] } }, + ]; + + it('lastTurnEntries starts from last real user prompt', () => { + const turn = lastTurnEntries(entries); + expect(turn).toHaveLength(3); // new prompt + assistant + tool_result + expect(turn[0].message.content).toBe('new prompt'); + }); + + it('lastUserPromptText returns last user prompt string', () => { + expect(lastUserPromptText(entries)).toBe('new prompt'); + }); + + it('lastAssistantText concatenates assistant text blocks of last turn only', () => { + expect(lastAssistantText(entries)).toContain('I will edit'); + expect(lastAssistantText(entries)).not.toContain('old reply'); + }); + + it('turnToolUses returns only tool_use blocks from last turn', () => { + const uses = turnToolUses(entries); + expect(uses).toHaveLength(1); + expect(uses[0].name).toBe('Edit'); + expect(uses[0].input.file_path).toBe('a.mjs'); + }); + + it('turnToolResults includes is_error flag and concatenated text', () => { + const results = turnToolResults(entries); + expect(results).toHaveLength(1); + expect(results[0].is_error).toBe(false); + expect(results[0].content).toBe('ok'); + }); + + it('handles array text content in user message', () => { + const eps = [ + { message: { role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } }, + ]; + expect(lastUserPromptText(eps)).toBe('hello\n world'); + }); +}); + +describe('loadOverrideVocab / findOverride', () => { + let tmp; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'vocab-')); + _resetVocabCache(); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + _resetVocabCache(); + }); + + it('loads vocab from explicit path', () => { + const p = join(tmp, 'vocab.json'); + writeFileSync(p, JSON.stringify({ + phrases: [ + { phrase: 'без скилов', suppresses: ['skill-required'] }, + ], + })); + const v = loadOverrideVocab(p); + expect(v.phrases).toHaveLength(1); + }); + + it('findOverride matches case-insensitively', () => { + const v = { phrases: [{ phrase: 'СРОЧНО', suppresses: ['verify-before-push'] }] }; + expect(findOverride('очень срочно нужно', 'verify-before-push', v)).toMatchObject({ phrase: 'СРОЧНО' }); + expect(findOverride('hello world', 'verify-before-push', v)).toBeNull(); + }); + + it('findOverride returns null if rule key not in suppresses', () => { + const v = { phrases: [{ phrase: 'без скилов', suppresses: ['skill-required'] }] }; + expect(findOverride('без скилов давай', 'tdd-gate', v)).toBeNull(); + expect(findOverride('без скилов давай', 'skill-required', v)).not.toBeNull(); + }); + + it('findOverride returns null on empty prompt / vocab', () => { + expect(findOverride('', 'x', { phrases: [] })).toBeNull(); + expect(findOverride(null, 'x', { phrases: [{ phrase: 'a', suppresses: ['x'] }] })).toBeNull(); + }); + + it('loads default vocab file when no path given (smoke)', () => { + _resetVocabCache(); + const v = loadOverrideVocab(); + expect(Array.isArray(v.phrases)).toBe(true); + expect(v.phrases.length).toBeGreaterThan(0); + }); +}); + +describe('isProductionCodePath', () => { + it('classifies tools/*.mjs as production', () => { + expect(isProductionCodePath('tools/router-classifier.mjs')).toBe(true); + expect(isProductionCodePath('c:/моя/проекты/портал crm/Документация/tools/foo.mjs')).toBe(true); + }); + it('excludes test files', () => { + expect(isProductionCodePath('tools/router-classifier.test.mjs')).toBe(false); + expect(isProductionCodePath('tools/foo.spec.mjs')).toBe(false); + }); + it('classifies app/app/**.php as production', () => { + expect(isProductionCodePath('app/app/Http/Controllers/X.php')).toBe(true); + }); + it('excludes app/tests/**', () => { + expect(isProductionCodePath('app/tests/Feature/X.php')).toBe(false); + }); + it('classifies resources/js/**.vue|ts|tsx|js as production', () => { + expect(isProductionCodePath('resources/js/views/Dashboard.vue')).toBe(true); + expect(isProductionCodePath('resources/js/api/admin.ts')).toBe(true); + }); + it('excludes *.spec.ts/*.test.ts', () => { + expect(isProductionCodePath('resources/js/views/Dashboard.spec.ts')).toBe(false); + expect(isProductionCodePath('resources/js/views/Dashboard.test.ts')).toBe(false); + }); + it('returns false for non-production paths', () => { + expect(isProductionCodePath('docs/x.md')).toBe(false); + expect(isProductionCodePath('CLAUDE.md')).toBe(false); + expect(isProductionCodePath('package.json')).toBe(false); + }); +}); + +describe('isMemoryPath', () => { + it('matches user-memory store .md files', () => { + expect(isMemoryPath('C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference.md')).toBe(true); + expect(isMemoryPath('/Users/x/.claude/projects/proj/memory/foo.md')).toBe(true); + }); + it('matches MEMORY.md regardless of folder', () => { + expect(isMemoryPath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\MEMORY.md')).toBe(true); + expect(isMemoryPath('/foo/MEMORY.md')).toBe(true); + }); + it('returns false for normal docs', () => { + expect(isMemoryPath('docs/x.md')).toBe(false); + expect(isMemoryPath('CLAUDE.md')).toBe(false); + }); +}); + +describe('detectGitCommandKind', () => { + it('detects push', () => { + expect(detectGitCommandKind('git push origin main')).toBe('push'); + expect(detectGitCommandKind('LEFTHOOK=0 git push')).toBe('push'); + }); + it('detects commit', () => { + expect(detectGitCommandKind('git commit -m "x"')).toBe('commit'); + }); + it('detects cherry-pick', () => { + expect(detectGitCommandKind('git cherry-pick abc123')).toBe('cherry-pick'); + }); + it('detects branch -f', () => { + expect(detectGitCommandKind('git branch -f main HEAD')).toBe('branch-force'); + expect(detectGitCommandKind('git branch -d feature')).toBe('branch-force'); + }); + it('detects rebase', () => { + expect(detectGitCommandKind('git rebase main')).toBe('rebase'); + }); + it('returns null for non-git commands', () => { + expect(detectGitCommandKind('ls -la')).toBeNull(); + expect(detectGitCommandKind('git status')).toBeNull(); + }); +}); + +describe('detectFullTestRun', () => { + it('detects vitest run as full when no specific path', () => { + expect(detectFullTestRun('npx vitest run')).toBe('vitest-full'); + expect(detectFullTestRun('npx vitest run --reporter=basic')).toBe('vitest-full'); + }); + it('returns null for narrow vitest with specific test path', () => { + expect(detectFullTestRun('npx vitest run tools/foo.test.mjs')).toBeNull(); + }); + it('detects pest / composer test', () => { + expect(detectFullTestRun('php artisan test')).toBe('pest'); + expect(detectFullTestRun('composer test')).toBe('pest'); + expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest'); + }); + it('returns null for non-test commands', () => { + expect(detectFullTestRun('git status')).toBeNull(); + }); +}); diff --git a/tools/enforce-override-vocab.json b/tools/enforce-override-vocab.json new file mode 100644 index 00000000..4f814c12 --- /dev/null +++ b/tools/enforce-override-vocab.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "comment": "Hard-coded override phrases. Substring-match (case-insensitive) against user's last prompt. Each phrase suppresses one or more rule categories for ONE prompt only.", + "phrases": [ + { + "phrase": "без скилов", + "suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"], + "description": "Skill discipline relaxed for this one prompt" + }, + { + "phrase": "direct ok", + "suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"], + "description": "Direct work allowed without skill invocation" + }, + { + "phrase": "срочно", + "suppresses": ["verify-before-commit", "verify-before-push", "tdd-gate"], + "description": "Urgency override: skip verification + TDD gate" + }, + { + "phrase": "быстрый коммит", + "suppresses": ["verify-before-commit", "tdd-gate", "writing-plans-required"], + "description": "Quick commit: skip TDD + verify + plans" + }, + { + "phrase": "recovery", + "suppresses": ["branch-switch", "git-recovery"], + "description": "Git recovery operation, branch-state mismatch ok" + }, + { + "phrase": "memory dump", + "suppresses": ["memory-sync-coverage", "skill-required"], + "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"], + "description": "Bypass all rules (full opt-out). Use only when literally fixing the enforce-infrastructure itself." + } + ] +}