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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user