feat(m7-floor): floor-decide ветка PowerShell content-block + forge-страж (V1-PS, P-3)

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-блокируется.
This commit is contained in:
Дмитрий
2026-06-08 09:09:18 +03:00
parent 7277584eaf
commit ea83a714e4
2 changed files with 76 additions and 0 deletions
+31
View File
@@ -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),
+45
View File
@@ -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);