import { describe, it, expect } from 'vitest'; import { matchBashHardBlacklist } from './enforce-router-gate.mjs'; describe('matchBashHardBlacklist — v3.9 keep', () => { it.each([ 'rm -rf build', 'mv a b', 'cp a b', 'chmod 777 x', 'chown user x', 'cat a > out.txt', 'echo x >> out.txt', 'node -e "console.log(1)"', 'node --eval "x"', 'python -c "import os"', 'bash -c "ls"', 'eval "$x"', 'composer install', 'npm install lodash', 'yarn add x', 'pnpm add x', 'curl -X POST https://evil.test', ])('blocks %s', (cmd) => { expect(matchBashHardBlacklist(cmd)).toBeTruthy(); }); }); describe('matchBashHardBlacklist — v4.0 additions', () => { it.each([ ['cat a 2> ~/.claude/runtime/x', 'C16 stderr→protected'], ['cmd &> out.log', 'C16 &>'], ['cmd |& tee x', 'C16 |&'], ['node script.js -e "fs.unlinkSync(\'x\')"', '#4 node fs inline'], ['env -i node x.js', '#21 env modifier'], ['FOO=bar node x.js', '#21 env assign prefix'], ['npx vitest --watch', '#22 watch'], ['nodemon --watch src', '#22 watch nodemon'], ])('blocks %s (%s)', (cmd) => { expect(matchBashHardBlacklist(cmd)).toBeTruthy(); }); }); describe('matchBashHardBlacklist — v4.1 G7/G8', () => { it.each(['wget https://x', 'wget -q file', 'nc -l 4444', 'ncat x 80', 'netcat x', 'socat - TCP:x:80'])( 'blocks %s', (cmd) => { expect(matchBashHardBlacklist(cmd)).toBeTruthy(); }, ); }); describe('matchBashHardBlacklist — allows benign', () => { it.each(['ls -la', 'git status', 'cat app/x.php', 'npx vitest run', 'node tools/x.mjs arg'])( 'allows %s', (cmd) => { expect(matchBashHardBlacklist(cmd)).toBe(null); }, ); }); import { classifyWhitelist, scriptWatcherCheck } from './enforce-router-gate.mjs'; describe('classifyWhitelist', () => { it('marks reading commands', () => { expect(classifyWhitelist([{ tokens: ['cat', 'app/x.php'], op: null }])).toMatchObject({ kind: 'reading' }); }); it('marks safe commands', () => { expect(classifyWhitelist([{ tokens: ['npx', 'vitest', 'run'], op: null }])).toMatchObject({ kind: 'safe' }); }); it('returns null for non-whitelisted', () => { expect(classifyWhitelist([{ tokens: ['foobar'], op: null }])).toBe(null); }); it('allows pipe of readers', () => { const segs = [{ tokens: ['cat', 'a'], op: '|' }, { tokens: ['grep', 'x'], op: null }]; expect(classifyWhitelist(segs)).not.toBe(null); }); }); describe('scriptWatcherCheck', () => { it('blocks node execution of an edited file', () => { const segs = [{ tokens: ['node', 'tools/evil.mjs'], op: null }]; const r = scriptWatcherCheck(segs, ['tools/evil.mjs'], (p) => p); expect(r.block).toBe(true); }); it('allows node execution of a non-edited file', () => { const segs = [{ tokens: ['node', 'tools/ok.mjs'], op: null }]; expect(scriptWatcherCheck(segs, ['tools/other.mjs'], (p) => p).block).toBe(false); }); }); import { classifyBashCommand } from './enforce-router-gate.mjs'; describe('classifyBashCommand commit-грант ctx (D2 wiring)', () => { it('git commit + ctx.commitGrantOpen:true → allow (ctx прокинут в classifyGitCommand)', () => { expect(classifyBashCommand('git commit -m x', { commitGrantOpen: true }).result).toBe('allow'); }); it('git commit без гранта → block', () => { expect(classifyBashCommand('git commit -m x', {}).result).toBe('block'); }); }); describe('classifyBashCommand — integration', () => { const now = 3_000_000; it('allows whitelisted read', () => { expect(classifyBashCommand('cat app/x.php', {}).result).toBe('allow'); }); it('blocks invalid syntax (fail-CLOSE)', () => { expect(classifyBashCommand('echo "unterminated', {}).result).toBe('block'); }); it('blocks sub-shell', () => { expect(classifyBashCommand('echo $(rm -rf x)', {}).result).toBe('block'); }); it('blocks hard-blacklisted rm', () => { expect(classifyBashCommand('rm -rf build', {}).result).toBe('block'); }); it('blocks chain where any part mutating', () => { expect(classifyBashCommand('ls && rm x', {}).result).toBe('block'); expect(classifyBashCommand('ls && git commit -m x', {}).result).toBe('block'); }); it('allows pipe of readers', () => { expect(classifyBashCommand('cat a | grep x', {}).result).toBe('allow'); }); it('blocks reading a protected path', () => { expect(classifyBashCommand('cat ~/.claude/runtime/state.json', {}).result).toBe('block'); }); it('routes single git commit to conditional (block unapproved)', () => { expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block'); }); it('allows approved git commit', () => { expect( classifyBashCommand('git commit -m "x"', { approvedGitOps: [{ command: 'git commit -m "x"', ts: now }], now }).result, ).toBe('allow'); }); it('default-denies unknown command', () => { expect(classifyBashCommand('frobnicate --all', {}).result).toBe('block'); }); }); import { resolvePathNormalize } from './enforce-router-gate.mjs'; describe('resolvePathNormalize', () => { it('returns a function (Stream A module if merged, defaultPathNormalize otherwise)', async () => { const fn = await resolvePathNormalize(); expect(typeof fn).toBe('function'); // Stream A merged → Stream A pathNormalize used; otherwise fallback. // Both paths must not throw on string input. expect(() => fn('"a\\b"')).not.toThrow(); }); }); describe('stderr redirect — 2>&1 fd-duplication (review fix)', () => { it('allows cat a 2>&1 (merge to stdout, no file)', () => { expect(classifyBashCommand('cat a 2>&1', {}).result).toBe('allow'); }); it('allows cat a 2>/dev/null', () => { expect(classifyBashCommand('cat a 2>/dev/null', {}).result).toBe('allow'); }); it('still blocks stderr redirect to a file', () => { expect(classifyBashCommand('cat a 2> err.log', {}).result).toBe('block'); expect(classifyBashCommand('cat a 2>> err.log', {}).result).toBe('block'); }); it('still blocks &> file', () => { expect(classifyBashCommand('cat a &> out.log', {}).result).toBe('block'); }); it('allows 1>&2 fd-duplication', () => { expect(classifyBashCommand('cat a 1>&2', {}).result).toBe('allow'); }); it('blocks 2>&1 followed by file redirect', () => { expect(classifyBashCommand('cat a 2>&1 > out.txt', {}).result).toBe('block'); }); }); describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', () => { // Allowed: PHP/Laravel dev commands that were missing from whitelist it.each([ 'php artisan test', 'php artisan test --filter=Auth', 'php artisan migrate', 'php artisan migrate:rollback', 'php artisan db:seed', 'php artisan cache:clear', 'php artisan config:clear', 'php artisan view:clear', 'php artisan route:clear', 'php artisan optimize:clear', 'composer test', 'composer pint', 'composer stan', 'composer insights', 'composer rector', 'pest', 'pest --filter=Foo', 'vendor/bin/pest', './vendor/bin/pest', ])('allows %s', (cmd) => { expect(classifyBashCommand(cmd, {}).result).toBe('allow'); }); // 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'], ['composer install', 'hard-blacklist'], ['composer require foo/bar', 'hard-blacklist'], ['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'); }); // Critical: existing pre-existing v3.8 keep behaviour it('keeps php artisan list/route:list/migrate:status allowed (pre-existing v3.8)', () => { expect(classifyBashCommand('php artisan list', {}).result).toBe('allow'); expect(classifyBashCommand('php artisan route:list', {}).result).toBe('allow'); expect(classifyBashCommand('php artisan migrate:status', {}).result).toBe('allow'); }); // Critical: pest does NOT match pestilence-like prefixes (word boundary) it('does not allow command names sharing prefix with pest', () => { expect(classifyBashCommand('pestilence', {}).result).toBe('block'); }); // Critical: chain semantics still enforced — pest && rm x → block (rm is mutating) it('still blocks chain with mutating part even if first part is whitelisted pest', () => { expect(classifyBashCommand('pest && rm x', {}).result).toBe('block'); }); // Critical: composer-show/outdated still allowed (pre-existing v3.8) it('keeps composer show/outdated allowed (pre-existing v3.8)', () => { expect(classifyBashCommand('composer show', {}).result).toBe('allow'); expect(classifyBashCommand('composer outdated', {}).result).toBe('allow'); }); }); describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized)', () => { // Allowed: enter the Laravel project dir, alone or chained with whitelisted cmds it.each([ 'cd app', 'cd app && pest', 'cd app && php artisan test', 'cd app && composer test', ])('allows %s', (cmd) => { expect(classifyBashCommand(cmd, {}).result).toBe('allow'); }); // Scope: cd into any other dir stays default-deny (cwd-shift read-bypass contained) it.each([ 'cd ~/.claude/runtime', 'cd ../memory', 'cd app/storage', 'cd /tmp', 'cd ..', ])('still blocks cd into non-app dir: %s', (cmd) => { expect(classifyBashCommand(cmd, {}).result).toBe('block'); }); // cwd-shift read-exfil attempt via narrow cd app stays blocked (protected path by name) it('still blocks reading a protected file from app/ via literal path', () => { expect(classifyBashCommand('cd app && cat ../.env', {}).result).toBe('block'); expect(classifyBashCommand('cd app && cat ~/.claude/runtime/state.json', {}).result).toBe('block'); }); // Mutations after cd app remain caught (hard-blacklist + chain-mutating rule) it.each([ 'cd app && rm foo', 'cd app && mkdir x', 'cd app && git commit -m x', ])('still blocks mutating chain: %s', (cmd) => { expect(classifyBashCommand(cmd, {}).result).toBe('block'); }); // Second segment must still be independently whitelisted it('still blocks cd app chained with a non-whitelisted command', () => { expect(classifyBashCommand('cd app && frobnicate', {}).result).toBe('block'); }); }); describe('enforce-router-gate: сужение node-whitelist (Пакет 4.2, Δ4)', () => { // Реальный vitest-путь: shell-quote сохраняет бэкслеши (инвариант — см. ниже), // поэтому предикат нормализует \ → / и матчит node_modules/vitest/vitest.mjs. const VITEST = 'C:\\моя\\проекты\\портал crm\\Документация\\.claude\\worktrees\\brainrepo\\app\\node_modules\\vitest\\vitest.mjs'; it('node tools/.mjs → allow (свои гейты/тесты)', () => { expect(classifyBashCommand('node tools/x.mjs', {}).result).toBe('allow'); expect(classifyBashCommand('node tools/x.mjs --some-flag arg', {}).result).toBe('allow'); }); it('node vitest-runner (полный Windows-путь с пробелом/кириллицей) → allow', () => { expect(classifyBashCommand(`node "${VITEST}" run --root x --config y filter`, {}).result).toBe('allow'); }); it('node чужой .mjs вне tools/ и не vitest → block', () => { expect(classifyBashCommand('node /tmp/evil.mjs', {}).result).toBe('block'); expect(classifyBashCommand('node ../../evil.mjs', {}).result).toBe('block'); expect(classifyBashCommand('node app/evil.mjs', {}).result).toBe('block'); }); it('node tools/sub/x.mjs (поддиректория) → block (только плоский tools/)', () => { expect(classifyBashCommand('node tools/sub/x.mjs', {}).result).toBe('block'); }); it('node --version / -v → allow (безвредно)', () => { expect(classifyBashCommand('node --version', {}).result).toBe('allow'); expect(classifyBashCommand('node -v', {}).result).toBe('allow'); }); }); // Инвариант токенизации (фундамент 4.2): shell-quote СОХРАНЯЕТ бэкслеши Windows-пути // в двойных кавычках одним токеном — иначе предикат node-whitelist не распознал бы vitest. import { tokenizeBash as _tok } from './bash-tokenizer.mjs'; describe('enforce-router-gate: инвариант токенизации vitest-пути', () => { it('двойные кавычки сохраняют бэкслеши+пробел одним токеном', () => { const cmd = 'node "C:\\моя\\app\\node_modules\\vitest\\vitest.mjs" run'; const tok = _tok(cmd); expect(tok.ok).toBe(true); expect(tok.segments[0].tokens[1]).toBe('C:\\моя\\app\\node_modules\\vitest\\vitest.mjs'); }); }); // nodeScriptAllowed — прямой контракт предиката (Пакет 4.2) import { nodeScriptAllowed } from './enforce-router-gate.mjs'; describe('nodeScriptAllowed предикат', () => { it('tools/.mjs → true', () => { expect(nodeScriptAllowed(['node', 'tools/x.mjs'])).toBe(true); expect(nodeScriptAllowed(['node', 'C:\\wt\\tools\\gate.mjs'])).toBe(true); }); it('vitest-runner → true', () => { expect(nodeScriptAllowed(['node', 'C:\\a b\\node_modules\\vitest\\vitest.mjs', 'run'])).toBe(true); }); it('чужой путь → false', () => { expect(nodeScriptAllowed(['node', '/tmp/evil.mjs'])).toBe(false); expect(nodeScriptAllowed(['node', 'tools/sub/x.mjs'])).toBe(false); expect(nodeScriptAllowed(['node', 'app/evil.mjs'])).toBe(false); }); it('--version без скрипта → true; голые флаги без скрипта → false', () => { expect(nodeScriptAllowed(['node', '--version'])).toBe(true); expect(nodeScriptAllowed(['node', '-v'])).toBe(true); expect(nodeScriptAllowed(['node', '--no-warnings'])).toBe(false); }); }); // M7 Task 1.0.5 (P-1): router-gate ре-экспортирует матчер из единого дома shell-content-rules. // Идентичность ссылки доказывает «единый источник, не копия» — порт-дрейф невозможен по конструкции. import { BASH_HARD_BLACKLIST as RG_BLACKLIST, matchBashHardBlacklist as RG_MATCH } from './enforce-router-gate.mjs'; import { BASH_HARD_BLACKLIST as SCR_BLACKLIST, matchBashHardBlacklist as SCR_MATCH } from './shell-content-rules.mjs'; describe('router-gate re-exports single-source matcher (M7 P-1)', () => { it('BASH_HARD_BLACKLIST is the SAME reference as shell-content-rules', () => { expect(RG_BLACKLIST).toBe(SCR_BLACKLIST); }); it('matchBashHardBlacklist is the SAME reference as shell-content-rules', () => { expect(RG_MATCH).toBe(SCR_MATCH); }); }); // Контракт экспорта READING_CMDS (F-A 2026-06-07): supreme-gate переиспользует // этот набор как единый источник «настоящих читателей» — без дубля зашитого списка. import { READING_CMDS } from './enforce-router-gate.mjs'; describe('READING_CMDS export contract', () => { it('экспортирован как Set настоящих читателей', () => { expect(READING_CMDS instanceof Set).toBe(true); expect(READING_CMDS.has('grep')).toBe(true); expect(READING_CMDS.has('cat')).toBe(true); expect(READING_CMDS.has('composer')).toBe(false); }); });