diff --git a/tools/enforce-router-gate.mjs b/tools/enforce-router-gate.mjs index 399a58bd..4fcfdb45 100644 --- a/tools/enforce-router-gate.mjs +++ b/tools/enforce-router-gate.mjs @@ -95,6 +95,14 @@ const SAFE_EXACT = [ /^composer\s+(?:test|pint|stan|insights|rector)\b/, /^(?:\.\/)?vendor\/bin\/pest\b/, /^pest\b/, + // Narrow `cd app` (2026-05-31, owner-authorized) — enter the Laravel project dir + // so already-whitelisted commands (pest, php artisan test) run from app/. + // Scope deliberately limited to the literal `app` dir: `cd` into any other path + // (incl. protected .claude/runtime, memory/, transcripts) stays default-deny, so + // the cwd-shift read-bypass is contained. Mutations remain caught at the + // hard-blacklist + chain-mutating rule (both run before the whitelist), and each + // chain segment after `cd app &&` must still be independently whitelisted. + /^cd\s+app$/, ]; export function classifyWhitelist(segments) { diff --git a/tools/enforce-router-gate.test.mjs b/tools/enforce-router-gate.test.mjs index 0c9984ed..0207a70d 100644 --- a/tools/enforce-router-gate.test.mjs +++ b/tools/enforce-router-gate.test.mjs @@ -227,3 +227,46 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', 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'); + }); +});