feat(m5): classifyDestructive двухуровневый + rewire a2CaseSelect/detectHighRisk (§4, N1)
Пакет 1 Машины 5 (роутер-наставник, пол). Единый источник разрушительности classify-destructive.mjs: floor (точный необратимый набор, hard-block) + suspicious (грубый набор для голосов судьи), инвариант floor => suspicious. - N1: голый migrate/migrate:rollback/migrate --force => suspicious, НЕ floor (деплой не ломается). - rewire a2CaseSelect (M4) и detectHighRisk (M3) на classifyDestructive.suspicious; оба локальных DESTRUCTIVE_RE удалены (Δ9-б — единственный источник). - Δ9(б) seed CI-инвариант m5-floor-invariants.test.mjs (positive-control, не вакуумный). - sharp-edges (Step 1.9): floor force-push выровнен с каноном shell-content — закрыт обход кавычками git push "--force" (длинные флаги без обязательного \s; -f/+ с \s). - parity к двум прежним regex сохранён (format/db:wipe/force-push-литерал). Регрессия tools-only: 2608 passed + 2 skip (+48). Residuals (chaining/reset-quote) переданы Пакету 2 (tokenizeBash посегментно). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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('чувствительная зона (инъецированный флаг)');
|
||||
|
||||
@@ -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' },
|
||||
] };
|
||||
|
||||
Reference in New Issue
Block a user