feat(m5): Пакет 2 — несущий пол (floor-decide + enforce-floor + дверь Δ1 + Δ7 + C4)
Блок 1 Машины 5: вето-до-плана на необратимое, независимо от членства в плане. - tools/floor-decide.mjs — чистое ядро: Bash floor (classifyDestructive whole-string + посегментно tokenizeBash — кавычки/chaining нейтрализованы) + tool-agnostic запись (P10-a: .env/ключ/cert + ~/.claude/runtime, fail-CLOSED на normalize-throw). - Дверь владельца Δ1 — read-only approve_git_operation (exact+5мин окно, НЕ consume). - tools/enforce-floor.mjs — обёртка matcher '*' (регистрация — шаг владельца, ОТДЕЛЬНО от стены М2), loadApprovedGitOps read-only, fail-CLOSED, НЕ импортирует plan-lock. - C4: migrate:fresh/refresh/reset убраны из router-gate whitelist → default-deny даже без floor-хука (SPOF-защита); bare migrate + migrate:rollback остаются. - Δ7: enforce-supreme-gate.decide на allow-пути зовёт classifyDestructive(...).floor — разрушительный in-plan шаг НЕ продвигает указатель (стена не благословляет снос). - Атака-линза: закрыт P10-a-пробел (MCP-writer в .env floor бы пропустил). Audit-context вскрыл расхождения план↔код (задокументированы в floor-decide JSDoc): writer approval НЕ подписывает (интегрити = protected-path side-channel, не HMAC); F5-гонка мнимая (loadApprovedGitOps read-only+window, не consume); force-push доп-блок shell-content GIT_HARD (дверь для него мут — защита-в-глубину). Дверь = шов под М6. Регрессия tools-only: 2649 passed + 2 skip (+41). Residual: node-whitelist hole для записи в runtime (Пакет 4 сужает); base64-обфускация floor (~0.5%, М6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-floor (Машина 5, Блок 1) — обёртка несущего пола. Matcher '*'
|
||||
* (регистрация в settings.json — шаг ВЛАДЕЛЬЦА, ОТДЕЛЬНО от верховной стены М2:
|
||||
* снятие стены не снимает пол; чтобы пробить необратимое — нужно снять оба).
|
||||
*
|
||||
* Зовёт floor-decide ПЕРВЫМ (до seed/observe/членства в плане). Дверь владельца —
|
||||
* read-only approve_git_operation через shell-content::loadApprovedGitOps (тот же
|
||||
* window+read-only механизм, что у router-gate; floor НЕ потребляет — F5 гонки нет).
|
||||
*
|
||||
* НЕ импортирует plan-lock (Δ9: пол первее плана). fail-CLOSED: любая ошибка → block.
|
||||
*/
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { floorDecide } from './floor-decide.mjs';
|
||||
import { loadApprovedGitOps } from './shell-content-rules.mjs';
|
||||
|
||||
/** Чистое решение: делегирует floor-decide. approvedGitOps/now/normalizeImpl инъектируемы. */
|
||||
export function decide({ event, approvedGitOps = [], now = Date.now(), normalizeImpl }) {
|
||||
const toolUse = { name: event && event.tool_name, input: (event && event.tool_input) || {} };
|
||||
const args = { toolUse, approvedGitOps, now };
|
||||
if (normalizeImpl) args.normalizeImpl = normalizeImpl;
|
||||
return floorDecide(args);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const sess = (event && event.session_id) || 'unknown';
|
||||
const approvedGitOps = loadApprovedGitOps(sess); // read-only, window-filtered
|
||||
const r = decide({ event, approvedGitOps });
|
||||
exitDecision({ block: r.block, message: r.block ? `[floor] ${r.reason}` : undefined });
|
||||
} catch {
|
||||
exitDecision({ block: true, message: '[floor] внутренняя ошибка — fail-CLOSED' });
|
||||
}
|
||||
}
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { decide } from './enforce-floor.mjs';
|
||||
|
||||
// enforce-floor — тонкая обёртка floor-decide. decide() чистая (approvedGitOps инъект).
|
||||
const ev = (tool_name, tool_input) => ({ tool_name, tool_input, session_id: 's1' });
|
||||
|
||||
describe('enforce-floor.decide — делегирует floor-decide', () => {
|
||||
it('необратимая Bash без одобрения → block', () => {
|
||||
const r = decide({ event: ev('Bash', { command: 'php artisan migrate:fresh' }), approvedGitOps: [] });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('обычная Bash → не block', () => {
|
||||
const r = decide({ event: ev('Bash', { command: 'git status' }), approvedGitOps: [] });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('Read → не block', () => {
|
||||
const r = decide({ event: ev('Read', { file_path: '/home/u/.env' }), approvedGitOps: [], normalizeImpl: (s) => s });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('дверь владельца: свежее одобрение точной команды → не block', () => {
|
||||
const now = 1_000_000;
|
||||
const r = decide({ event: ev('Bash', { command: 'php artisan db:wipe' }), approvedGitOps: [{ command: 'php artisan db:wipe', ts: now - 1000 }], now });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-floor — пол первее плана (не импортирует plan-lock)', () => {
|
||||
it('исходник enforce-floor.mjs не ИМПОРТИРУЕТ plan-lock (Δ9: floor до плана)', () => {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(dir, 'enforce-floor.mjs'), 'utf8');
|
||||
// таргетим именно import-стейтмент, не упоминание в комментарии
|
||||
expect(/(?:import|require)\b[^\n]*['"][^'"]*plan-lock/.test(src)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,11 @@ const SAFE_EXACT = [
|
||||
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
|
||||
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
|
||||
// `migrate(?=\s|$)` lookahead prevents `migrate:install` / `migrate:<unknown>` from matching bare `migrate`.
|
||||
/^php\s+artisan\s+(?:test|migrate:fresh|migrate:rollback|migrate:refresh|migrate:reset|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/,
|
||||
// Машина 5 Пакет 2.4 (C4): migrate:fresh/refresh/reset УБРАНЫ из whitelist — это floor-набор
|
||||
// (необратимый дроп БД, classify-destructive.mjs). Теперь они → default-deny router-gate'ом
|
||||
// даже при незарегистрированном enforce-floor (защита-в-глубину SPOF). Остаются bare migrate
|
||||
// (миграции вперёд) + migrate:rollback (обратимо).
|
||||
/^php\s+artisan\s+(?:test|migrate:rollback|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/,
|
||||
/^composer\s+(?:test|pint|stan|insights|rector)\b/,
|
||||
/^(?:\.\/)?vendor\/bin\/pest\b/,
|
||||
/^pest\b/,
|
||||
|
||||
@@ -168,10 +168,7 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
|
||||
'php artisan test',
|
||||
'php artisan test --filter=Auth',
|
||||
'php artisan migrate',
|
||||
'php artisan migrate:fresh',
|
||||
'php artisan migrate:rollback',
|
||||
'php artisan migrate:refresh',
|
||||
'php artisan migrate:reset',
|
||||
'php artisan db:seed',
|
||||
'php artisan cache:clear',
|
||||
'php artisan config:clear',
|
||||
@@ -191,7 +188,9 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
|
||||
});
|
||||
|
||||
// Critical: REPL and composer mutations remain hard-blocked
|
||||
// Critical: REPL and composer mutations remain hard-blocked.
|
||||
// Машина 5 Пакет 2.4: migrate:fresh/refresh/reset убраны из whitelist (floor-territory,
|
||||
// см. classify-destructive.mjs floor-набор) → router-gate default-deny даже без floor-хука.
|
||||
it.each([
|
||||
['php artisan tinker', 'REPL = arbitrary PHP exec risk'],
|
||||
['php artisan tinker --execute="exit"', 'tinker variant'],
|
||||
@@ -200,6 +199,9 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
|
||||
['composer update', 'hard-blacklist'],
|
||||
['composer remove foo/bar', 'hard-blacklist'],
|
||||
['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'],
|
||||
['php artisan migrate:fresh', 'floor-territory — default-deny после Пакет 2.4'],
|
||||
['php artisan migrate:refresh', 'floor-territory — default-deny после Пакет 2.4'],
|
||||
['php artisan migrate:reset', 'floor-territory — default-deny после Пакет 2.4'],
|
||||
])('still blocks %s (%s)', (cmd) => {
|
||||
expect(classifyBashCommand(cmd, {}).result).toBe('block');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-supreme-gate.mjs';
|
||||
import { classifyDestructive } from './classify-destructive.mjs';
|
||||
|
||||
// Δ7 (Машина 5 Пакет 2.5): defense-in-depth М2. Даже если разрушительное действие
|
||||
// СОВПАДАЕТ с шагом замороженного плана, стена НЕ продвигает указатель — пол
|
||||
// (classify-destructive.mjs) требует двери владельца. При незарегистрированном
|
||||
// floor-хуке стена всё равно не благословляет снос.
|
||||
|
||||
const key = 'k';
|
||||
const args = (object) => ({
|
||||
toolUse: { name: 'Bash', input: { command: object } },
|
||||
frozenPlan: { plan_id: 'p1', steps: [{ n: 1, op: 'Bash', object }] },
|
||||
frozenArtifact: null,
|
||||
stepPtr: 0,
|
||||
key,
|
||||
verifyImpl: () => true,
|
||||
verifyArtifactImpl: () => true,
|
||||
normalize: (s) => s,
|
||||
});
|
||||
|
||||
describe('decide — Δ7: разрушительное in-plan не благословляется', () => {
|
||||
const DESTRUCTIVE = ['rm -rf build', 'php artisan migrate:fresh', 'git push --force', 'git reset --hard HEAD~1'];
|
||||
for (const cmd of DESTRUCTIVE) {
|
||||
it(`разрушительный in-plan шаг → block, указатель не двигается: ${cmd}`, () => {
|
||||
expect(classifyDestructive(cmd).floor).toBe(true); // пол считает это необратимым
|
||||
const r = decide(args(cmd));
|
||||
expect(r.decision).toBe('block');
|
||||
expect(r.advanceTo).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
it('обычный (не-floor, не-observe) in-plan шаг → allow + advanceTo', () => {
|
||||
// php artisan migrate: не observe-only (не readonly) и не floor (N1) → доходит до allow-пути
|
||||
expect(classifyDestructive('php artisan migrate').floor).toBe(false);
|
||||
const r = decide(args('php artisan migrate'));
|
||||
expect(r.decision).toBe('allow');
|
||||
expect(r.advanceTo).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { classifyBashCommand, READING_CMDS } from './enforce-router-gate.mjs';
|
||||
import { tokenizeBash } from './bash-tokenizer.mjs';
|
||||
import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
|
||||
import { assertSafeSessionId } from './action-journal.mjs';
|
||||
import { classifyDestructive } from './classify-destructive.mjs';
|
||||
|
||||
// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
|
||||
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
|
||||
@@ -149,6 +150,13 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0
|
||||
if (!refResolves(step, frozenArtifact))
|
||||
return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} на опечатанное решение не резолвится — вернись в разговор и пере-печатай (закрытая дверь C-5)` };
|
||||
}
|
||||
// Δ7 (Машина 5 Пакет 2.5): defense-in-depth М2. Даже валидный шаг плана, который
|
||||
// САМ ПО СЕБЕ необратим (force-push/migrate:fresh/reset --hard/rm -rf — classify-destructive
|
||||
// floor-набор), стена НЕ благословляет: указатель не двигается, нужна дверь владельца
|
||||
// (floor-хук). При незарегистрированном floor стена всё равно не двигает указатель на снос.
|
||||
if (classifyDestructive(actionOf(toolUse).object).floor) {
|
||||
return { decision: 'block', reason: `шаг ${step.n}: разрушительное in-plan действие — пол требует двери владельца (стена не двигает указатель, Δ7)` };
|
||||
}
|
||||
return { decision: 'allow', reason: `шаг ${step.n} плана`, advanceTo: stepPtr + 1 };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* floor-decide (Машина 5, Блок 1) — чистое ядро НЕСУЩЕГО ПОЛА: вето-до-плана на
|
||||
* необратимое. Срабатывает ПЕРВЫМ (обёртка enforce-floor зовёт его до seed/observe/
|
||||
* проверки членства в плане). Валидная печать плана для floor-набора нерелевантна.
|
||||
*
|
||||
* Два инспектируемых канала (§2-точность):
|
||||
* - Bash: classifyDestructive(...).floor — whole-string (ловит chaining + quote-hardened
|
||||
* FLOOR_RE из Пакета 1) И посегментно через tokenizeBash (unquoted-токены нейтрализуют
|
||||
* кавычки; ловит floor-сегмент за читающим: `cat x && git push --force`).
|
||||
* - Write/Edit/NotebookEdit: путь записи через resolving pathNormalize → запись в
|
||||
* ~/.claude/runtime ИЛИ секрет (.env/ключ/cert) → block. fail-CLOSED (normalize
|
||||
* бросил → block; в отличие от lenient enforce-runtime-write-deny — пол строже).
|
||||
*
|
||||
* Дверь владельца Δ1 (узкая): необратимая Bash-операция проходит ТОЛЬКО при наличии
|
||||
* свежего (≤5 мин) одобрения ТОЧНОЙ команды в approve_git_operation. Читается READ-ONLY
|
||||
* (F5: floor НЕ потребляет — реальный потребитель shell-content::loadApprovedGitOps тоже
|
||||
* read-only+window, гонки нет). Контроллер этот канал не пишет (protected ~/.claude/runtime,
|
||||
* пишет PostToolUse-хук на реальный AskUser). Полный escape — в М6.
|
||||
*
|
||||
* NB (audit-context 2026-06-07): writer approval-записей сейчас НЕ подписывает (HMAC
|
||||
* signApprovalRecord существует, но не подключён) → интегрити двери держится protected-path
|
||||
* side-channel, не подписью. force-push дополнительно жёстко блокируется shell-content
|
||||
* GIT_HARD_PATTERNS даже approved (дверь для force-push мута — защита-в-глубину; дверь
|
||||
* реально значима для floor-only migrate:fresh/reset/refresh/db:wipe). Подпись подключится
|
||||
* в writer'е позже (P10-c) — тогда дверь усилится без смены контракта.
|
||||
*
|
||||
* Посессионная независимость: floor-список зашит в КОДЕ (classify-destructive), не из
|
||||
* per-session файла; unknown-сессия → пустой approvedGitOps → дверь закрыта → block.
|
||||
* fail-CLOSED на свою ошибку — на уровне обёртки enforce-floor.
|
||||
*/
|
||||
import { classifyDestructive } from './classify-destructive.mjs';
|
||||
import { tokenizeBash } from './bash-tokenizer.mjs';
|
||||
import { pathNormalize } from './path-normalization.mjs';
|
||||
|
||||
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
|
||||
const SECRET_PATH_RE = [
|
||||
/(^|\/)\.env(\.[\w-]+)?$/i, // .env / .env.local / .env.production
|
||||
/\.(pem|key|p12|pfx)$/i, // приватные ключи / сертификаты
|
||||
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)/i, // ssh-ключи
|
||||
];
|
||||
|
||||
const OBSERVE_TOOLS = new Set(['Read', 'Grep', 'Glob']); // только смотрят — floor записи не касается
|
||||
// B4-выравнивание: писатели несут путь под разными именами полей (как extractPath/actionOf).
|
||||
const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file', 'filename', 'destination', 'dest', 'output_path', 'uri'];
|
||||
const APPROVE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function extractWritePath(input) {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
for (const f of PATH_FIELDS) {
|
||||
if (typeof input[f] === 'string' && input[f]) return input[f];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normCmd(c) {
|
||||
return String(c || '').split(/\s+/).filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/** Bash → floor? whole-string (chaining/quotes) ИЛИ любой floor-сегмент (посегментно). */
|
||||
export function bashIsFloor(command) {
|
||||
const raw = String(command || '');
|
||||
if (classifyDestructive(raw).floor) return true;
|
||||
const tok = tokenizeBash(raw);
|
||||
if (tok && tok.ok && Array.isArray(tok.segments)) {
|
||||
for (const s of tok.segments) {
|
||||
if (classifyDestructive((s.tokens || []).join(' ')).floor) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Дверь владельца: свежее (≤window) одобрение ТОЧНОЙ команды. read-only. */
|
||||
export function approvalOpen(command, approvedGitOps, now) {
|
||||
if (!Array.isArray(approvedGitOps) || approvedGitOps.length === 0) return false;
|
||||
const target = normCmd(command);
|
||||
if (!target) return false;
|
||||
return approvedGitOps.some(
|
||||
(op) => op && normCmd(op.command) === target && typeof op.ts === 'number' && now - op.ts <= APPROVE_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение пола. block=true → необратимое без двери / запись в секрет-runtime / fail-close.
|
||||
* @param {object} p
|
||||
* @param {{name:string,input:object}} p.toolUse
|
||||
* @param {Array<{command:string,ts:number}>} [p.approvedGitOps] - read-only approve_git_operation
|
||||
* @param {number} [p.now]
|
||||
* @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism)
|
||||
* @returns {{block:boolean, reason:string}}
|
||||
*/
|
||||
export function floorDecide({ toolUse, approvedGitOps = [], now = Date.now(), normalizeImpl = pathNormalize }) {
|
||||
if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' };
|
||||
const name = toolUse.name;
|
||||
const input = toolUse.input || {};
|
||||
|
||||
if (name === 'Bash') {
|
||||
if (bashIsFloor(input.command || '')) {
|
||||
if (approvalOpen(input.command || '', approvedGitOps, now)) {
|
||||
return { block: false, reason: 'floor: необратимое разрешено дверью владельца (свежее approve_git_operation)' };
|
||||
}
|
||||
return { block: true, reason: 'floor: необратимая команда без двери владельца — заблокировано (вето-до-плана)' };
|
||||
}
|
||||
return { block: false, reason: 'floor: Bash не необратимо' };
|
||||
}
|
||||
|
||||
if (OBSERVE_TOOLS.has(name)) return { block: false, reason: 'floor: observe-only вне scope записи' };
|
||||
|
||||
// P10-a (атака-линза): путь записи проверяется tool-agnostic (как enforce-runtime-write-deny),
|
||||
// не только для именованных Write/Edit — ловит MCP-writer'ы (.env/runtime под чужим tool-name).
|
||||
const fp = extractWritePath(input);
|
||||
if (!fp) return { block: false, reason: 'floor: нет пути записи' };
|
||||
let norm;
|
||||
try { norm = String(normalizeImpl(fp) || ''); } catch { return { block: true, reason: 'floor: путь записи не резолвится — fail-CLOSED' }; }
|
||||
const slashed = norm.split('\\').join('/');
|
||||
if (RUNTIME_RE.test(slashed)) return { block: true, reason: 'floor: запись в ~/.claude/runtime запрещена (side-channel)' };
|
||||
if (SECRET_PATH_RE.some((re) => re.test(slashed))) return { block: true, reason: 'floor: запись в секрет (.env/ключ/cert) запрещена' };
|
||||
return { block: false, reason: 'floor: запись в обычный файл' };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { floorDecide } from './floor-decide.mjs';
|
||||
import { classifyDestructive } from './classify-destructive.mjs';
|
||||
|
||||
// for-of + it() (пол tdd-real-test-verifier не распознаёт it.each). floorDecide —
|
||||
// чистое ядро вето-до-плана: блокирует необратимое НЕЗАВИСИМО от плана. Дверь
|
||||
// владельца — read-only approve_git_operation (exact+window, НЕ consume).
|
||||
const id = (s) => s; // identity normalize для детерминизма path-тестов
|
||||
const bash = (command) => ({ name: 'Bash', input: { command } });
|
||||
const write = (file_path) => ({ name: 'Write', input: { file_path } });
|
||||
|
||||
describe('floorDecide — вето на необратимое (независимо от плана)', () => {
|
||||
const BLOCK_BASH = [
|
||||
'git push --force',
|
||||
'git push --force-with-lease origin main',
|
||||
'git push "--force"', // кавычки — нейтрализованы посегментно
|
||||
'cat x && git push --force', // chaining — whole-string fallback
|
||||
'php artisan migrate:fresh',
|
||||
'php artisan migrate:reset',
|
||||
'php artisan db:wipe',
|
||||
'rm -rf build',
|
||||
'git reset --hard HEAD~3',
|
||||
];
|
||||
for (const command of BLOCK_BASH) {
|
||||
it(`block для необратимой Bash: ${command}`, () => {
|
||||
const r = floorDecide({ toolUse: bash(command), normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
const ALLOW_BASH = [
|
||||
'php artisan migrate', // N1 — обычная миграция не floor
|
||||
'php artisan migrate:rollback',
|
||||
'git status',
|
||||
'git push origin main', // обычный push — не force
|
||||
'npm run build',
|
||||
];
|
||||
for (const command of ALLOW_BASH) {
|
||||
it(`allow для не-floor Bash: ${command}`, () => {
|
||||
const r = floorDecide({ toolUse: bash(command), normalizeImpl: id });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it('floor согласован с classifyDestructive.floor для одиночной команды', () => {
|
||||
const cmd = 'php artisan migrate:fresh';
|
||||
expect(classifyDestructive(cmd).floor).toBe(true);
|
||||
expect(floorDecide({ toolUse: bash(cmd), normalizeImpl: id }).block).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('floorDecide — запись в секрет/runtime (fail-CLOSED)', () => {
|
||||
const BLOCK_WRITE = [
|
||||
'/home/u/app/.env',
|
||||
'/home/u/app/.env.production',
|
||||
'/home/u/.ssh/id_rsa',
|
||||
'/home/u/app/cert.pem',
|
||||
'/home/u/.claude/runtime/askuser-decisions-x.jsonl',
|
||||
];
|
||||
for (const fp of BLOCK_WRITE) {
|
||||
it(`block записи в секрет/runtime: ${fp}`, () => {
|
||||
const r = floorDecide({ toolUse: write(fp), normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('allow записи в обычный файл', () => {
|
||||
const r = floorDecide({ toolUse: write('/home/u/app/tools/foo.mjs'), normalizeImpl: id });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('normalize бросил → fail-CLOSED (block)', () => {
|
||||
const boom = () => { throw new Error('cannot resolve'); };
|
||||
const r = floorDecide({ toolUse: write('/whatever'), normalizeImpl: boom });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('floorDecide — дверь владельца Δ1 (read-only approval, exact+window)', () => {
|
||||
const now = 1_000_000;
|
||||
it('migrate:fresh с валидным свежим одобрением точной команды → allow (дверь)', () => {
|
||||
const cmd = 'php artisan migrate:fresh';
|
||||
const approvedGitOps = [{ command: cmd, ts: now - 1000 }];
|
||||
const r = floorDecide({ toolUse: bash(cmd), approvedGitOps, now, normalizeImpl: id });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('одобрение ЧУЖОЙ команды → block (дверь не открывается)', () => {
|
||||
const approvedGitOps = [{ command: 'php artisan migrate', ts: now - 1000 }];
|
||||
const r = floorDecide({ toolUse: bash('php artisan migrate:fresh'), approvedGitOps, now, normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('просроченное одобрение (>5 мин) → block', () => {
|
||||
const cmd = 'php artisan db:wipe';
|
||||
const approvedGitOps = [{ command: cmd, ts: now - 6 * 60 * 1000 }];
|
||||
const r = floorDecide({ toolUse: bash(cmd), approvedGitOps, now, normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('нет одобрений (unknown-сессия → пустой список) → block', () => {
|
||||
const r = floorDecide({ toolUse: bash('php artisan migrate:fresh'), approvedGitOps: [], now, normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('floorDecide — observe-only / прочее не блокируется', () => {
|
||||
it('Read не блокируется', () => {
|
||||
expect(floorDecide({ toolUse: { name: 'Read', input: { file_path: '/home/u/.env' } }, normalizeImpl: id }).block).toBe(false);
|
||||
});
|
||||
it('Grep/Glob не блокируются', () => {
|
||||
expect(floorDecide({ toolUse: { name: 'Grep', input: { path: '/home/u/.env' } }, normalizeImpl: id }).block).toBe(false);
|
||||
expect(floorDecide({ toolUse: { name: 'Glob', input: { path: '/home/u/.ssh/id_rsa' } }, normalizeImpl: id }).block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// floor-decide.mjs P10-a — путь записи проверяется tool-agnostic (как enforce-runtime-write-deny),
|
||||
// не только для именованных Write/Edit: MCP-writer в .env/runtime тоже ловится (атака-линза).
|
||||
describe('floorDecide — P10-a: запись через MCP-writer (tool-agnostic путь)', () => {
|
||||
it('MCP-writer в .env → block', () => {
|
||||
const r = floorDecide({ toolUse: { name: 'mcp__fs__write_file', input: { path: '/home/u/app/.env' } }, normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('MCP-writer в ~/.claude/runtime → block', () => {
|
||||
const r = floorDecide({ toolUse: { name: 'mcp__fs__write_file', input: { destination: '/home/u/.claude/runtime/x.jsonl' } }, normalizeImpl: id });
|
||||
expect(r.block).toBe(true);
|
||||
});
|
||||
it('MCP-writer в обычный файл → не block', () => {
|
||||
const r = floorDecide({ toolUse: { name: 'mcp__fs__write_file', input: { path: '/home/u/app/tools/foo.mjs' } }, normalizeImpl: id });
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user