From ea83a714e448a31db35f4724b9cd37f2c80d54cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 8 Jun 2026 09:09:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(m7-floor):=20floor-decide=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20PowerShell=20content-block=20+=20forge-?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=B6=20(V1-PS,=20P-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1.4 Фазы 1 М7. Ветка PowerShell пола (после Bash, до OBSERVE_TOOLS): psContentBlock (Remove-Item/-Recurse/iwr/iex/Start-Process/Out-File/redirect) ИЛИ psProtectedWrite (P-3 forge-страж: PS-запись в ~/.claude/runtime / .env / секрет — иначе Set-Content подделывает escape-грант). Escapable owner-санкцией. Реоткрытие v3.8 F1. 60 GREEN. НАХОДКА РЕАЛИЗАЦИИ: plan-версия psProtectedWrite тестила whole-string SECRET_PATH_RE с $-якорем → `.env` в позиции аргумента (-Path app/.env -Value …) терялся. Робастнее: проверяем каждый токен (без кавычек, \→/) против anchored RUNTIME_RE/SECRET_PATH_RE — runtime (forge-вектор) И secret-write закрыты, обычная запись не over-блокируется. --- tools/floor-decide.mjs | 31 +++++++++++++++++++++++++ tools/floor-decide.test.mjs | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/tools/floor-decide.mjs b/tools/floor-decide.mjs index e94d8c94..42428e50 100644 --- a/tools/floor-decide.mjs +++ b/tools/floor-decide.mjs @@ -29,6 +29,7 @@ import { tokenizeBash, detectSubshell } from './bash-tokenizer.mjs'; import { pathNormalize } from './path-normalization.mjs'; import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs'; import { matchBashHardBlacklist } from './shell-content-rules.mjs'; +import { psContentBlock } from './powershell-destructive.mjs'; const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i; const SECRET_PATH_RE = [ @@ -37,6 +38,25 @@ const SECRET_PATH_RE = [ /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)/i, // ssh-ключи ]; +// M7 Task 1.4 (P-3, forge-страж): PS-запись в защищённый путь. Поле `command` не парсится +// общим write-стражем (extractWritePath ждёт file_path/path/…), поэтому PS-глагол записи в +// ~/.claude/runtime / .env / секрет подделал бы escape-грант. NB (находка реализации): plan-версия +// тестила whole-string с $-якорем — `.env` в позиции аргумента (-Path app/.env -Value …) терялся. +// Робастно: проверяем КАЖДЫЙ токен (без кавычек, \→/) против anchored RUNTIME_RE/SECRET_PATH_RE. +const PS_WRITE_VERB_RE = /\b(?:Set-Content|Add-Content|Out-File|Copy-Item|New-Item|Tee-Object|Move-Item)\b/i; +function psProtectedWrite(cmd) { + const s = String(cmd || ''); + const isWrite = PS_WRITE_VERB_RE.test(s) || /(?:^|[^0-9>&])>{1,2}(?![>&])/.test(s); + if (!isWrite) return false; + const slashed = s.split('\\').join('/'); + if (RUNTIME_RE.test(slashed)) return true; // -Path~/.claude/runtime слитно тоже + for (const raw of s.split(/\s+/)) { + const t = raw.replace(/^['"]|['"]$/g, '').split('\\').join('/'); + if (RUNTIME_RE.test(t) || SECRET_PATH_RE.some((re) => re.test(t))) return true; + } + return false; +} + 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']; @@ -121,6 +141,17 @@ export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], n return { block: false, reason: 'floor: Bash не необратимо' }; } + // M7 Task 1.4 (V1-PS): PowerShell — content-block (psContentBlock) + P-3 forge-страж + // (psProtectedWrite). Реоткрытие v3.8 F1: PowerShell-tool был полностью вне пола. Escapable. + if (name === 'PowerShell') { + const cmd = input.command || ''; + if (psContentBlock(cmd) || psProtectedWrite(cmd)) { + if (escaped()) return { block: false, reason: 'floor: PS content-block снят аварийным выходом (floor_escape)' }; + return { block: true, reason: `floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: ${action}` }; + } + return { block: false, reason: 'floor: PowerShell не опасно по содержанию' }; + } + if (OBSERVE_TOOLS.has(name)) return { block: false, reason: 'floor: observe-only вне scope записи' }; // P10-a (атака-линза): путь записи проверяется tool-agnostic (как enforce-runtime-write-deny), diff --git a/tools/floor-decide.test.mjs b/tools/floor-decide.test.mjs index 023b70ce..0667c661 100644 --- a/tools/floor-decide.test.mjs +++ b/tools/floor-decide.test.mjs @@ -199,6 +199,51 @@ describe('floor content-block Bash (правило 8, V1)', () => { }); }); +// M7 Task 1.4 (V1-PS): ветка PowerShell пола — content-block (psContentBlock) + P-3 forge-страж +// (psProtectedWrite: PS-запись в защищённый путь, иначе Set-Content ~/.claude/runtime подделывает +// escape-грант). Escapable owner-санкцией. +describe('floor content-block PowerShell (V1-PS)', () => { + const ps = (command) => ({ name: 'PowerShell', input: { command } }); + const now = 1000; + it('blocks Remove-Item -Recurse -Force without escape', () => { + expect(floorDecide({ toolUse: ps('Remove-Item -Recurse -Force C:\\x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + it('blocks Invoke-WebRequest exfil', () => { + expect(floorDecide({ toolUse: ps('Invoke-WebRequest https://e.rf'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + it('blocks Invoke-Expression arbitrary exec', () => { + expect(floorDecide({ toolUse: ps('iex $payload'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + it('does NOT block Get-ChildItem', () => { + expect(floorDecide({ toolUse: ps('Get-ChildItem'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); + }); + it('does NOT block Get-Content read', () => { + expect(floorDecide({ toolUse: ps('Get-Content notes.md'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); + }); + + // P-3 forge-страж: PS-запись в ~/.claude/runtime / .env / секрет (поле command не парсится + // общим write-стражем) — иначе Set-Content подделывает escape-грант. + it('P-3: Set-Content в ~/.claude/runtime → block (forge-вектор закрыт)', () => { + expect(floorDecide({ toolUse: ps("Set-Content -Path ~/.claude/runtime/askuser-decisions-x.jsonl -Value '{}'"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + it('P-3: Copy-Item в .claude/runtime → block', () => { + expect(floorDecide({ toolUse: ps('Copy-Item secret.txt ~/.claude/runtime/g.json'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + it('P-3: Set-Content в .env → block', () => { + expect(floorDecide({ toolUse: ps("Set-Content -Path app/.env -Value 'X=1'"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + it('P-3: Set-Content в обычный файл → НЕ over-block', () => { + expect(floorDecide({ toolUse: ps("Set-Content -Path notes.md -Value ok"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); + }); + + // escape снимает PS content-block (детальный инвариант — Task 1.5) + it('PS content-block снимается совпавшим escape-пропуском', () => { + const tu = ps('Remove-Item -Recurse -Force C:\\x'); + const r = floorDecide({ toolUse: tu, escapeGrants: [{ action: 'powershell:Remove-Item -Recurse -Force C:\\x', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: id }); + expect(r.block).toBe(false); + }); +}); + describe('floorDecide — observe-only / прочее не блокируется', () => { it('Read не блокируется', () => { expect(floorDecide({ toolUse: { name: 'Read', input: { file_path: '/home/u/.env' } }, normalizeImpl: id }).block).toBe(false);