diff --git a/tools/enforce-hook-helpers.mjs b/tools/enforce-hook-helpers.mjs index 3b2404fa..50ff6ce5 100644 --- a/tools/enforce-hook-helpers.mjs +++ b/tools/enforce-hook-helpers.mjs @@ -4,8 +4,13 @@ * 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. + * Design contract (M7 Фаза 0, правило 1 — уточнение helpers:7): + * - НАБЛЮДАТЕЛЬНЫЕ хуки (FAIL_QUIET_OBSERVATION_HOOKS) — fail-quiet (exit 0, {}). + * - ДИСЦИПЛИНАРНЫЕ/ЗАЩИТНЫЕ хуки (FAIL_CLOSE_DISCIPLINE_HOOKS) — fail-CLOSE: любая + * внутренняя ошибка → block (exit 2), НЕ тихий пропуск (иначе баг хука = тихий + * обход дисциплины, SE2). Используют disciplineOutcome / exitDisciplineDecision. + * Прецедент: enforce-normative-content-rules:201-205. + * Only deliberate violations (or discipline-hook internal errors) exit 2. * * Security note: this file uses child_process.execFileSync with FIXED arguments * (no user input concatenation) — pattern is safe by construction. No injection @@ -362,6 +367,47 @@ export function exitDecision({ block, message } = {}) { process.exit(0); } +/** + * Дисциплинарный исход (pure, тестируемо): выполнить decideFn; ЛЮБАЯ внутренняя + * ошибка → fail-CLOSE (block:true), НЕ тихий пропуск (M7 Фаза 0, правило 1). + * Наблюдательные хуки это НЕ используют — остаются fail-quiet. + */ +export async function disciplineOutcome(decideFn, { label = 'discipline' } = {}) { + try { + const o = await decideFn(); + return { block: !!(o && o.block), message: o && o.message }; + } catch { + return { block: true, message: `[${label}] внутренняя ошибка — fail-CLOSE` }; + } +} + +/** Thin-main: дисциплинарный exit. Ошибка decideFn → блок (fail-CLOSE). */ +export async function exitDisciplineDecision(decideFn, opts = {}) { + exitDecision(await disciplineOutcome(decideFn, opts)); +} + +/** + * P-7 (M7 Фаза 0): дисциплинарные/защитные хуки обязаны быть fail-CLOSE; наблюдательные — + * fail-quiet. Манифест-тест (Фаза 6 / P-6) сверит, что каждый зарегистрированный + * дисциплинарный хук числится здесь и использует fail-CLOSE-обёртку. + * NB: enforce-skill-journaler добавляется в Фазе 3; поглощённая дисциплина §4.2 — в Фазе 4. + */ +export const FAIL_CLOSE_DISCIPLINE_HOOKS = Object.freeze([ + 'enforce-floor', + 'enforce-supreme-gate', + 'enforce-judge-gate', + 'enforce-snapshot', + 'enforce-floor-escape-consume', + 'enforce-read-path-deny', + 'enforce-mcp-classification', + 'enforce-normative-content-rules', +]); + +export const FAIL_QUIET_OBSERVATION_HOOKS = Object.freeze([ + 'observer-stop-hook', + 'cost-stop-hook', +]); + export function isProductionCodePath(p) { if (typeof p !== 'string') return false; const n = p.replace(/\\/g, '/'); diff --git a/tools/enforce-hook-helpers.test.mjs b/tools/enforce-hook-helpers.test.mjs index 21d9a746..aa8e8120 100644 --- a/tools/enforce-hook-helpers.test.mjs +++ b/tools/enforce-hook-helpers.test.mjs @@ -27,6 +27,51 @@ import { readSafeBaselineActions, } from './enforce-hook-helpers.mjs'; +import { + disciplineOutcome as _disc, + FAIL_CLOSE_DISCIPLINE_HOOKS as _failClose, + FAIL_QUIET_OBSERVATION_HOOKS as _failQuiet, +} from './enforce-hook-helpers.mjs'; +const disciplineOutcome = _disc; +const FAIL_CLOSE_DISCIPLINE_HOOKS = _failClose; +const FAIL_QUIET_OBSERVATION_HOOKS = _failQuiet; + +describe('disciplineOutcome — fail-CLOSE (M7 Фаза 0, правило 1)', () => { + it('decideFn бросает → block:true (fail-CLOSE, не тихий пропуск)', async () => { + const r = await disciplineOutcome(() => { throw new Error('boom'); }, { label: 'floor' }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/fail-CLOSE/); + }); + it('decideFn → {block:false} → пропуск', async () => { + const r = await disciplineOutcome(() => ({ block: false })); + expect(r.block).toBe(false); + }); + it('async decideFn → {block:true,message} → блок с сообщением', async () => { + const r = await disciplineOutcome(async () => ({ block: true, message: 'нельзя' })); + expect(r.block).toBe(true); + expect(r.message).toBe('нельзя'); + }); + it('не-truthy исход (undefined) → block:false', async () => { + const r = await disciplineOutcome(() => undefined); + expect(r.block).toBe(false); + }); +}); + +describe('FAIL_CLOSE / FAIL_QUIET списки (P-7)', () => { + it('оба непусты', () => { + expect(FAIL_CLOSE_DISCIPLINE_HOOKS.length).toBeGreaterThan(0); + expect(FAIL_QUIET_OBSERVATION_HOOKS.length).toBeGreaterThan(0); + }); + it('дисциплина и наблюдение не пересекаются', () => { + const q = new Set(FAIL_QUIET_OBSERVATION_HOOKS); + for (const h of FAIL_CLOSE_DISCIPLINE_HOOKS) expect(q.has(h)).toBe(false); + }); + it('ядро защиты числится в fail-CLOSE', () => { + for (const h of ['enforce-floor', 'enforce-supreme-gate', 'enforce-snapshot']) + expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain(h); + }); +}); + describe('safe-baseline action log', () => { it('appends and reads action records', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-'));