Files
portal/tools/floor-manifest-check.mjs
T

106 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 };