diff --git a/tools/classify-destructive.mjs b/tools/classify-destructive.mjs new file mode 100644 index 00000000..91319d49 --- /dev/null +++ b/tools/classify-destructive.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * classify-destructive (§4, N1) — единый двухуровневый классификатор разрушительности + * по СУТИ команды. floor — точный необратимый набор (hard-block пола). suspicious — + * грубый набор для голосов судьи. Инвариант: floor ⇒ suspicious. Один источник правды + * (Δ9-б: другого regex разрушительных команд в tools/ быть не должно). + * + * Parity (Step 1.0 audit-context): suspicious — надмножество двух прежних DESTRUCTIVE_RE + * (judge-orchestrator:84 + router-engine:12). Источник 1 давал голый `migrate`+`drop table`, + * источник 2 — `format`+`force-push`-литерал. Оба покрыты ниже. Standalone `-rf`-литерал + * (over-broad FP источника 1, ловил `tar -rf`) намеренно НЕ перенесён — `rm -rf` покрыт + * словом `rm` + rmIsFloor. + */ + +// F2: rm разрушителен (floor) только если есть И рекурсия (-r/-R/--recursive), И force +// (-f/--force) — слитно (-rf), раздельно, короткими ИЛИ длинными флагами. Длинно-флаговый +// `rm --recursive --force` так же необратим → floor (старый regex его терял). +function rmIsFloor(cmd) { + if (!/\brm\b/i.test(cmd)) return false; + const rec = /-[a-z]*r/i.test(cmd) || /--recursive\b/i.test(cmd); + const force = /-[a-z]*f/i.test(cmd) || /--force\b/i.test(cmd); + return rec && force; +} + +// Точный необратимый набор — то, что пол блокирует НАГЛУХО (rm — через rmIsFloor). +const FLOOR_RE = [ + // force-push/перепись. NB (sharp-edges Step 1.9): длинные флаги --force/--force-with-lease + // БЕЗ обязательного \s перед ними — иначе `git push "--force"` (кавычки) обходил floor, + // а shell кавычки снимает → реальный force-push. Выровнено с каноном shell-content-rules. + // Короткий -f и refspec + требуют \s (чтобы не ловить подстроки/-rf). + /\bgit\s+push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b|\s\+\S)/i, + /\breset\s+--hard\b/i, + /\bartisan\s+migrate:(?:fresh|reset|refresh)\b/i, + /\bartisan\s+db:wipe\b/i, +]; + +// Грубый набор для судьи (надмножество floor) — лишний голос не вредит. +// F1: +format (его содержал detectHighRisk М3 — иначе rewire потеряет триггер форматирования диска). +// +force-push-литерал: источник 2 (router-engine:12) ловил его как отдельную альтернативу — +// сохраняем строгую parity (синтетический токен, реальный `git push --force` покрыт `--force`). +const SUSPICIOUS_RE = [ + /\b(?:rm|rmdir|drop|delete|truncate|migrate|format)\b/i, + /--force\b/i, + /\bforce-push\b/i, + /\breset\s+--hard\b/i, + /\bartisan\s+migrate:(?:fresh|reset|refresh)\b/i, + /\bartisan\s+db:wipe\b/i, +]; + +export function classifyDestructive(command) { + const cmd = String(command || ''); + const floor = rmIsFloor(cmd) || FLOOR_RE.some((re) => re.test(cmd)); + const suspicious = floor || SUSPICIOUS_RE.some((re) => re.test(cmd)); + const reason = floor ? 'необратимая команда (floor)' : suspicious ? 'подозрительная команда (suspicious)' : 'не разрушительная'; + return { floor, suspicious, reason }; +} diff --git a/tools/classify-destructive.test.mjs b/tools/classify-destructive.test.mjs new file mode 100644 index 00000000..66f75542 --- /dev/null +++ b/tools/classify-destructive.test.mjs @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { classifyDestructive } from './classify-destructive.mjs'; + +// NB: используем for-of + it() вместо it.each — активный пол tdd-real-test-verifier +// (regex /\b(test|it)\s*\(/) не распознаёт it.each( как тест-блок. Поведение идентично. + +describe('classifyDestructive — floor-уровень (точный необратимый набор)', () => { + const FLOOR_CASES = [ + 'git push --force', + 'git push --force-with-lease origin main', + 'git push -f', + 'git reset --hard HEAD~3', + 'php artisan migrate:fresh', + 'php artisan migrate:reset', + 'php artisan migrate:refresh', + 'php artisan db:wipe', + 'rm -rf build', + 'rm -fr node_modules', + 'rm --recursive --force build', + 'rm --force --recursive /tmp/x', + ]; + for (const cmd of FLOOR_CASES) { + it(`floor:true для ${cmd}`, () => { + const r = classifyDestructive(cmd); + expect(r.floor).toBe(true); + expect(r.suspicious).toBe(true); // инвариант floor ⇒ suspicious + }); + } +}); + +describe('classifyDestructive — suspicious без floor (N1: пол не ломает деплой)', () => { + const MIGRATE_CASES = [ + 'php artisan migrate', + 'php artisan migrate:rollback', + 'php artisan migrate --force', + ]; + for (const cmd of MIGRATE_CASES) { + it(`suspicious:true, floor:false для ${cmd}`, () => { + const r = classifyDestructive(cmd); + expect(r.suspicious).toBe(true); + expect(r.floor).toBe(false); // КРИТИЧНО: пол НЕ блокирует обычную миграцию + }); + } + + const SUSPICIOUS_ONLY = ['rm file.txt', 'rm -r dir', 'DROP TABLE x', 'truncate logs', 'format D:', 'npm run format']; + for (const cmd of SUSPICIOUS_ONLY) { + it(`suspicious:true, floor:false для ${cmd}`, () => { + const r = classifyDestructive(cmd); + expect(r.suspicious).toBe(true); + expect(r.floor).toBe(false); + }); + } + + it('не разрушительная команда → оба false', () => { + const r = classifyDestructive('git status'); + expect(r.floor).toBe(false); + expect(r.suspicious).toBe(false); + }); +}); + +describe('classifyDestructive — sharp-edges: обфускация force-push (Step 1.9)', () => { + // Scoundrel: кавычки вокруг флага. Shell снимает кавычки → реальный force-push. + // Floor обязан ловить (выровнено с каноном shell-content-rules:177). + const FORCE_FLOOR = [ + 'git push --force', + 'git push --force', // двойной пробел + 'git push origin main --force', // флаг в конце после аргументов + 'git push "--force"', // кавычки (RED до фикса) + "git push '--force'", // одинарные кавычки (RED до фикса) + 'git push "--force-with-lease"', // кавычки + lease (RED до фикса) + 'git push -f', + 'git push origin +main', // refspec-force + ]; + for (const cmd of FORCE_FLOOR) { + it(`floor:true для force-push ${cmd}`, () => { + expect(classifyDestructive(cmd).floor).toBe(true); + }); + } + + // Контроль: НЕ должно ложно срабатывать на не-force push / похожих словах. + const NOT_FLOOR = [ + 'git push origin main', // обычный push — не floor + 'git pushed --forcefully nothing', // не команда push, не флаг --force + ]; + for (const cmd of NOT_FLOOR) { + it(`floor:false (контроль FP) для ${cmd}`, () => { + expect(classifyDestructive(cmd).floor).toBe(false); + }); + } +}); diff --git a/tools/judge-orchestrator.mjs b/tools/judge-orchestrator.mjs index f171489a..535eb1b2 100644 --- a/tools/judge-orchestrator.mjs +++ b/tools/judge-orchestrator.mjs @@ -11,6 +11,7 @@ * - logVerdict (J8): каждый вердикт append-only в журнал (инъекция). * Сам вызов модели и persist — в 4-D (мок) и хук-обёртках 4-G; здесь чистая логика. */ +import { classifyDestructive } from './classify-destructive.mjs'; /** A0: ворота срабатывают от состояния (готово И не запечатано), не «по желанию». */ export function gateFires({ ready, alreadySealed }) { @@ -79,13 +80,12 @@ export function logVerdict({ verdict, nowMs, journal }) { } // #4 (§3.3): A2 просыпается на двух поводах — детерминированный разбор по СУТИ команды. -// Разрушительное (rm/DROP/DELETE/TRUNCATE/migrate/force-push/reset --hard) → destructive -// (несколько голосов: радиус/обратимость + атакующий); иначе → divergence (1 голос). -const DESTRUCTIVE_RE = /\b(rm|rmdir|drop\s+table|drop|delete|truncate|migrate)\b|--force|-rf|reset\s+--hard|push\s+--force/i; - +// Разрушительное → destructive (несколько голосов: радиус/обратимость + атакующий); +// иначе → divergence (1 голос). Единый источник — classifyDestructive (§4, Δ9-б): +// локальный DESTRUCTIVE_RE удалён, suspicious-набор берётся из классификатора. export function a2CaseSelect(action) { const cmd = String((action && (action.object || action.command)) || ''); - return DESTRUCTIVE_RE.test(cmd) ? 'destructive' : 'divergence'; + return classifyDestructive(cmd).suspicious ? 'destructive' : 'divergence'; } /** diff --git a/tools/judge-orchestrator.test.mjs b/tools/judge-orchestrator.test.mjs index 0cb1d7d6..eaa8963b 100644 --- a/tools/judge-orchestrator.test.mjs +++ b/tools/judge-orchestrator.test.mjs @@ -111,6 +111,7 @@ describe('logVerdict (J8: каждый вердикт append-only в журна }); import { a2CaseSelect, runGateFunction } from './judge-orchestrator.mjs'; +import { classifyDestructive } from './classify-destructive.mjs'; describe('a2CaseSelect (#4 — A2 различает расхождение vs разрушительную команду)', () => { it('разрушительные команды → destructive', () => { @@ -125,6 +126,27 @@ describe('a2CaseSelect (#4 — A2 различает расхождение vs }); }); +describe('a2CaseSelect — parity/consolidation на classifyDestructive (Step 1.6)', () => { + // a2CaseSelect делегирует classifyDestructive.suspicious. former-destructive стабильны; + // new: format/db:wipe старый локальный DESTRUCTIVE_RE терял → теперь destructive. + const CASES = [ + 'rm -rf build', + 'psql -c "DROP TABLE deals"', + 'git push --force origin main', + 'php artisan migrate', + 'php artisan db:wipe', + 'format D:', + 'git status', + 'tools/foo.mjs', + ]; + for (const cmd of CASES) { + it(`a2CaseSelect согласован с classifyDestructive.suspicious для ${cmd}`, () => { + const expected = classifyDestructive(cmd).suspicious ? 'destructive' : 'divergence'; + expect(a2CaseSelect({ op: 'Bash', object: cmd })).toBe(expected); + }); + } +}); + describe('runGateFunction (#4 — упаковка: пол ($0) → судья)', () => { it('весь пол ок + судья GO → GO на стадии judge', () => { const r = runGateFunction({ diff --git a/tools/m5-floor-invariants.test.mjs b/tools/m5-floor-invariants.test.mjs new file mode 100644 index 00000000..1cc69013 --- /dev/null +++ b/tools/m5-floor-invariants.test.mjs @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +// Сквозные CI-инварианты Машины 5 (N5/Δ9). Файл создаётся в Пакете 1 (Δ9-б seed), +// наполняется далее в Пакете 6 (N5 строгая проверка пола, Δ9-а floor-суперсет, +// escape≠protection). Все потребители разрушительности — единый источник +// classify-destructive.mjs (classifyDestructive). + +const TOOLS_DIR = dirname(fileURLToPath(import.meta.url)); +// Конструкт-маркер прежних дублей: оба удалённых классификатора объявлялись как +// `const DESTRUCTIVE_RE = /.../`. Токен-grep (migrate:fresh/format) НЕ годится — +// migrate:fresh легитимно есть в whitelist router-gate/supreme-gate (Пакет 2 переносит), +// а `format` — обычное слово. Таргетим само объявление символа. +const DESTRUCTIVE_DECL_RE = /DESTRUCTIVE_RE\s*=/; + +describe('Δ9(б) — единственный источник классификатора разрушительности (Step 1.8)', () => { + it('positive control: детектор ловит объявление, но не комментарий (не вакуумный)', () => { + expect(DESTRUCTIVE_DECL_RE.test('const DESTRUCTIVE_RE = /rm/;')).toBe(true); + expect(DESTRUCTIVE_DECL_RE.test('// локальный DESTRUCTIVE_RE удалён')).toBe(false); + }); + + it('ни один tools/*.mjs (кроме classify-destructive.mjs) не объявляет DESTRUCTIVE_RE', () => { + const files = readdirSync(TOOLS_DIR).filter( + (f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && f !== 'classify-destructive.mjs', + ); + const offenders = []; + for (const f of files) { + const src = readFileSync(join(TOOLS_DIR, f), 'utf8'); + if (DESTRUCTIVE_DECL_RE.test(src)) offenders.push(f); + } + expect(offenders).toEqual([]); + }); +}); diff --git a/tools/router-engine.mjs b/tools/router-engine.mjs index 94eb83c7..57f43a0a 100644 --- a/tools/router-engine.mjs +++ b/tools/router-engine.mjs @@ -7,9 +7,9 @@ * НЕ трогает (см. журнал вопросов: только после Машины 4). */ import { resolveNode } from './node-graph.mjs'; - -// Универсальные разрушительные глаголы (портативно — не project-хардкод путей). -const DESTRUCTIVE_RE = /\b(rm|rmdir|drop|delete|truncate|format)\b|--force|force-push|reset\s+--hard|migrate:fresh|push\s+--force/i; +import { classifyDestructive } from './classify-destructive.mjs'; +// Разрушительные глаголы — единый источник classify-destructive.mjs (§4, Δ9-б): +// локальный DESTRUCTIVE_RE удалён, риск-проверка берёт .suspicious из классификатора. /** * 6.1 — ДЕТЕРМИНИРОВАННЫЙ высокий риск (по операции/прод-выкату/многошаговости/ @@ -18,7 +18,7 @@ const DESTRUCTIVE_RE = /\b(rm|rmdir|drop|delete|truncate|format)\b|--force|force */ export function detectHighRisk({ op, command = '', prodDeploy = false, stepCount = 0, sensitive = false } = {}, { stepThreshold = 7 } = {}) { const reasons = []; - if (DESTRUCTIVE_RE.test(command)) reasons.push('разрушительная операция'); + if (classifyDestructive(command).suspicious) reasons.push('разрушительная операция'); if (prodDeploy) reasons.push('прод-выкат'); if (stepCount >= stepThreshold) reasons.push(`многошаговость (${stepCount}≥${stepThreshold})`); if (sensitive) reasons.push('чувствительная зона (инъецированный флаг)'); diff --git a/tools/router-engine.test.mjs b/tools/router-engine.test.mjs index ff807200..3df37ced 100644 --- a/tools/router-engine.test.mjs +++ b/tools/router-engine.test.mjs @@ -5,12 +5,25 @@ import { buildRouterPrompt, parseRouterResponse, runRouter, } from './router-engine.mjs'; import { buildNodeGraph } from './node-graph.mjs'; +import { classifyDestructive } from './classify-destructive.mjs'; const GRAPH = buildNodeGraph({ nodes: [ { id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' }, { id: '#55', slug: 'discovery-interview', name: 'discovery-interview', status: 'active' }, ], chains: {} }); +describe('detectHighRisk — parity/consolidation на classifyDestructive (Step 1.7)', () => { + // командная-проверка detectHighRisk делегирует classifyDestructive.suspicious. + // format сохранён; новые: голый migrate / db:wipe (старый локальный DESTRUCTIVE_RE их терял). + const CASES = ['rm -rf x', 'git push --force', 'format D:', 'php artisan migrate', 'php artisan db:wipe', 'git status']; + for (const command of CASES) { + it(`detectHighRisk.high (только command) согласован с classifyDestructive.suspicious для ${command}`, () => { + const expected = classifyDestructive(command).suspicious; + expect(detectHighRisk({ op: 'Bash', command }).high).toBe(expected); + }); + } +}); + const CATALOG = { nodes: [ { id: '#19', slug: 'writing-plans', name: 'writing-plans', capabilities: 'plans', status: 'active' }, ] };