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:
Дмитрий
2026-06-07 11:42:58 +03:00
parent 22b84fbb2e
commit b6d06ede87
8 changed files with 383 additions and 5 deletions
+39
View File
@@ -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();
+37
View File
@@ -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);
});
});
+5 -1
View File
@@ -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/,
+6 -4
View File
@@ -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');
});
+40
View File
@@ -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);
});
});
+8
View File
@@ -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 };
}
+119
View File
@@ -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: запись в обычный файл' };
}
+129
View File
@@ -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);
});
});