Files
portal/tools/enforce-router-gate.test.mjs
T
Дмитрий b05e31c89c feat(router-gate): allow cd into project worktree dirs for worktree dev
PR #41 re-scope enabled 'git worktree' creation but not working inside worktrees: only 'cd app' was whitelisted, so pest/git could not run in a worktree. Add a SAFE_EXACT rule allowing cd into a path with a worktree-/v4-stream- segment, excluding .. and protected segments (.claude/.ssh/.env/runtime/.git) so the cwd-shift read-bypass stays contained. TDD: +6 tests; full tools suite 1997 GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:04:15 +03:00

354 lines
14 KiB
JavaScript

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"',
'yarn add x',
'pnpm add x',
'curl -X POST https://evil.test',
])('blocks %s', (cmd) => {
expect(matchBashHardBlacklist(cmd)).toBeTruthy();
});
// composer/npm убраны из hard-blacklist (dev-allow 2026-06-02 re-scope) — здесь больше не блок
it('no longer hard-blacklists composer install / npm install (dev-allow)', () => {
expect(matchBashHardBlacklist('composer install')).toBe(null);
expect(matchBashHardBlacklist('npm install lodash')).toBe(null);
});
});
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 — 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 dev-allow (2026-06-02 re-scope — no approval needed)', () => {
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
});
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: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',
'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 remains hard-blocked (composer/npm moved to dev-allow below, 2026-06-02 re-scope)
it('still blocks tinker REPL and unknown migrate subcommand', () => {
expect(classifyBashCommand('php artisan tinker', {}).result).toBe('block');
expect(classifyBashCommand('php artisan tinker --execute="exit"', {}).result).toBe('block');
expect(classifyBashCommand('php artisan migrate:install', {}).result).toBe('block');
});
// dev-allow (owner-authorized 2026-06-02 re-scope): composer is a local dev tool
it('now allows composer install/require/update/remove/dump-autoload', () => {
expect(classifyBashCommand('composer install', {}).result).toBe('allow');
expect(classifyBashCommand('composer install -d app --no-interaction', {}).result).toBe('allow');
expect(classifyBashCommand('composer require monolog/monolog', {}).result).toBe('allow');
expect(classifyBashCommand('composer update', {}).result).toBe('allow');
expect(classifyBashCommand('composer remove monolog/monolog', {}).result).toBe('allow');
expect(classifyBashCommand('composer dump-autoload', {}).result).toBe('allow');
});
// dev-allow (owner-authorized 2026-06-02 re-scope): npm is a local dev tool
it('now allows npm install/i/ci/run', () => {
expect(classifyBashCommand('npm install', {}).result).toBe('allow');
expect(classifyBashCommand('npm i', {}).result).toBe('allow');
expect(classifyBashCommand('npm ci', {}).result).toBe('allow');
expect(classifyBashCommand('npm run build', {}).result).toBe('allow');
});
// 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('SAFE_EXACT — worktree cd (2026-06-02, owner-authorized worktree dev)', () => {
// Allowed: enter a project worktree dir (segment `worktree-` / `v4-stream-`) so
// git/pest can run there. Quoted absolute path; cwd-shift read-bypass stays contained
// because protected files remain blocked by name in the command (cat .env / runtime).
it.each([
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city"',
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city\\app"',
'cd "C:\\моя\\проекты\\портал crm\\v4-stream-A"',
])('allows cd into a worktree dir: %s', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
});
// Scope: protected / non-worktree dirs stay default-deny (no `worktree-` marker, or
// `..` / protected segment present → cwd-shift read-bypass prevented).
it.each([
'cd "C:\\Users\\Administrator\\.claude\\runtime"',
'cd "C:\\моя\\проекты\\портал crm\\worktree-x\\..\\..\\.claude"',
'cd "C:\\моя\\проекты\\портал crm\\Документация"',
])('still blocks cd into non-worktree / protected dir: %s', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('block');
});
});
import { stripQuotedSpans } from './enforce-router-gate.mjs';
describe('quote-aware redirect (quirk 2)', () => {
// False positives that must now be ALLOWED — `>` / `2>` живут внутри кавычек.
it('allows > inside double-quoted commit message (co-author <email>)', () => {
expect(matchBashHardBlacklist('git commit -m "x <noreply@anthropic.com>"')).toBe(null);
});
it('allows 2> inside double-quoted message', () => {
expect(matchBashHardBlacklist('git commit -m "fix 2>1 logging"')).toBe(null);
});
it('allows lone quoted >', () => {
expect(matchBashHardBlacklist('git commit -m ">"')).toBe(null);
});
// Real redirects (operator OUTSIDE quotes) must STILL BLOCK.
it('blocks spaced stdout redirect', () => {
expect(matchBashHardBlacklist('echo x > /tmp/f')).toBeTruthy();
});
it('blocks no-space stdout redirect', () => {
expect(matchBashHardBlacklist('echo x>/tmp/f')).toBeTruthy();
});
it('blocks append redirect', () => {
expect(matchBashHardBlacklist('echo x >> /tmp/f')).toBeTruthy();
});
it('blocks stderr redirect to file', () => {
expect(matchBashHardBlacklist('cmd 2> /tmp/err')).toBeTruthy();
});
it('blocks redirect after a closing quote', () => {
expect(matchBashHardBlacklist('echo "x" > /tmp/f')).toBeTruthy();
});
});
describe('stripQuotedSpans (quirk 2 helper)', () => {
it('blanks double-quoted interior, keeps outside', () => {
expect(stripQuotedSpans('a "b>c" > d')).toBe('a " " > d');
});
it('blanks single-quoted interior', () => {
expect(stripQuotedSpans("a 'x>y' z")).toBe("a ' ' z");
});
it('keeps backslash-escaped quote literal (no span opened)', () => {
expect(stripQuotedSpans('a \\" > b')).toBe('a \\" > b');
});
});