feat(enforce): T1 — shared hook helpers + override vocab

This commit is contained in:
Дмитрий
2026-05-25 18:14:34 +03:00
parent 6f70cca90e
commit 76cb825331
3 changed files with 619 additions and 0 deletions
+339
View File
@@ -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;
}
+239
View File
@@ -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();
});
});
+41
View File
@@ -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."
}
]
}