106 lines
7.0 KiB
JavaScript
106 lines
7.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* floor-manifest-check (Машина 5, Блок 5, Δ8) — манифест-самопроверка регистрации (SessionStart).
|
||
*
|
||
* Проблема черепах: хук НЕ может гарантировать, что ДРУГИЕ хуки (пол + защитные) реально
|
||
* зарегистрированы в settings.json. Достижимый максимум — на старте сессии прочитать
|
||
* settings.json и, если пол-хук (enforce-floor) или защитные хуки не прописаны, ГРОМКО
|
||
* предупредить владельца. Это **сигнал, не гарантия** и **НЕ блок** (созвучно judgeHealth →
|
||
* cry:true): нельзя объявить «пол стоит» без подтверждения регистрации, но и блокировать
|
||
* сессию из-за нерегистрации нельзя (сам этот хук тоже мог быть не зарегистрирован).
|
||
*
|
||
* Чистое ядро (settings инъектируется) + тонкая I/O-обёртка main() (читает файл, fail-quiet, exit 0).
|
||
*/
|
||
import fsDefault from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
// Полный список событий-хуков Claude Code — обходим каждое (структура одинакова:
|
||
// settings.hooks[event] = [{ matcher?, hooks: [{ type:'command', command }] }]).
|
||
const HOOK_EVENTS = [
|
||
'PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop', 'SessionStart',
|
||
'SessionEnd', 'UserPromptSubmit', 'PreCompact', 'Notification',
|
||
];
|
||
|
||
// Security-load-bearing набор для «пол подтверждён» (F-3, аудит 2026-06-07): не один пол, а
|
||
// весь защитный контур — несущий пол (вето-до-плана) + верховная стена (вне-плана) + стражи
|
||
// нормативки/read-exfil/egress-exfil. Раньше проверялся только enforce-floor → owner мог
|
||
// забыть стену/стражей и получить зелёный «protected». Живая обёртка/владелец может передать
|
||
// иной набор; контракт checkManifest — по requiredHooks (DEFAULT — фолбэк main()). WARN-only.
|
||
// SE-B (М7 Фаза 6): расширено до ПОЛНОГО пост-переездного набора М1–М6 + М1-журналер — раньше
|
||
// проверялись только пол+стена+3 exfil-стража, без М4 (judge-gate) и М6 (snapshot/escape), и доска
|
||
// рапортовала «protected» при незарегистрированных М4/М6. Теперь «ПОСТ ПОЛНЫЙ» = весь контур.
|
||
const DEFAULT_REQUIRED_HOOKS = [
|
||
'enforce-floor.mjs', // М5 несущий пол: вето-до-плана на необратимое
|
||
'enforce-supreme-gate.mjs', // М2 верховная стена: default-deny вне плана (нужны ОБА)
|
||
'enforce-normative-content-rules.mjs', // М1/М5 защита нормативки/памяти от подмены
|
||
'enforce-read-path-deny.mjs', // М5 read-exfil (transcript/runtime/secrets)
|
||
'enforce-mcp-classification.mjs', // М5 egress-exfil (исходящий MCP-payload)
|
||
'enforce-judge-gate.mjs', // М4 судья: приёмка ТЗ/плана/результата + надзор
|
||
'enforce-snapshot.mjs', // М6 снимок: точка отката перед разрушительным
|
||
'enforce-floor-escape-consume.mjs', // М6 escape владельца: единственная законная дверь
|
||
'enforce-skill-journaler.mjs', // М1 журналер навыков: неподделываемый канал K2
|
||
'enforce-verify-gate.mjs', // G1 М5-family: подписанный verify-receipt перед commit/push
|
||
'enforce-criterion-gate.mjs', // Level B М5-family: по-критерийный mutation-proven GREEN
|
||
'enforce-coverage-verify.mjs', // G2 М7 Фаза 8: журнал-K2 coverage (Фаза 4a) — поглощённая дисциплина
|
||
'enforce-todowrite-skill-verifier.mjs',// G2 М7 Фаза 8: журнал-K2 todowrite (Фаза 4a) — поглощённая дисциплина
|
||
];
|
||
|
||
/** Собрать все command-строки хуков из settings (битые/пустые секции игнорируются, не бросает). */
|
||
export function collectHookCommands(settings) {
|
||
const out = [];
|
||
const hooks = settings && settings.hooks;
|
||
if (!hooks || typeof hooks !== 'object') return out;
|
||
for (const ev of HOOK_EVENTS) {
|
||
const arr = hooks[ev];
|
||
if (!Array.isArray(arr)) continue;
|
||
for (const entry of arr) {
|
||
const inner = entry && entry.hooks;
|
||
if (!Array.isArray(inner)) continue;
|
||
for (const h of inner) {
|
||
if (h && typeof h.command === 'string') out.push(h.command);
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Проверить регистрацию требуемых хуков. requiredHooks — basenames (подстрока в command).
|
||
* Возвращает СИГНАЛ (без поля block): {ok, registered, missing, cry, mode}. cry = !ok (громкий
|
||
* WARN), mode = ok ? 'protected' : 'floor-unverified'. Никогда не блокирует (Δ8).
|
||
*/
|
||
export function checkManifest({ settings = {}, requiredHooks = DEFAULT_REQUIRED_HOOKS } = {}) {
|
||
const commands = collectHookCommands(settings);
|
||
const req = requiredHooks || [];
|
||
const registered = req.filter((h) => commands.some((c) => c.includes(h)));
|
||
const missing = req.filter((h) => !commands.some((c) => c.includes(h)));
|
||
const ok = missing.length === 0;
|
||
return { ok, registered, missing, cry: !ok, mode: ok ? 'protected' : 'floor-unverified' };
|
||
}
|
||
|
||
function readSettings(path, fsImpl) {
|
||
try { return JSON.parse(fsImpl.readFileSync(path, 'utf8')); }
|
||
catch { return {}; }
|
||
}
|
||
|
||
// I/O-обёртка SessionStart: читает .claude/settings.json относительно cwd; cry → WARN в stderr;
|
||
// НИКОГДА не блокирует (exit 0). Fail-quiet: ошибка чтения — тоже WARN-уровень, не падение.
|
||
async function main() {
|
||
try {
|
||
const settings = readSettings('.claude/settings.json', fsDefault);
|
||
const r = checkManifest({ settings });
|
||
if (r.cry) {
|
||
process.stderr.write(
|
||
`[floor-manifest] ⚠️ пол НЕ подтверждён — не зарегистрированы хуки: ${r.missing.join(', ')}. ` +
|
||
'«Пол стоит» — сигнал, не гарантия (Δ8); зарегистрируйте их в .claude/settings.json.\n',
|
||
);
|
||
}
|
||
} catch { /* fail-quiet: манифест — сигнал, не блок */ }
|
||
process.exit(0);
|
||
}
|
||
|
||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||
if (isCli) main();
|
||
|
||
export const _internals = { readSettings, DEFAULT_REQUIRED_HOOKS, HOOK_EVENTS };
|