diff --git a/tools/shell-content-rules.mjs b/tools/shell-content-rules.mjs index 4dfaef54..b1e79dd1 100644 --- a/tools/shell-content-rules.mjs +++ b/tools/shell-content-rules.mjs @@ -59,7 +59,34 @@ export function matchAny(patterns, str) { export function extractPathArgs(tokens) { if (!Array.isArray(tokens)) return []; - return tokens.slice(1).filter((t) => typeof t === 'string' && !t.startsWith('-') && t !== '>' && t !== '>>'); + const out = []; + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (typeof t !== 'string') continue; + if (t === '>' || t === '>>' || t === '<' || t === '|') continue; + // --flag=VALUE form + if (t.startsWith('-')) { + const eq = t.indexOf('='); + if (eq > 0) { + const v = t.slice(eq + 1); + if (v && !looksLikeUrl(v)) out.push(v); + } + continue; + } + // key=value form (dd-style) + const kv = t.match(/^([a-zA-Z_][\w-]*)=(.+)$/); + if (kv) { + const v = kv[2]; + if (v && !looksLikeUrl(v)) out.push(v); + continue; + } + if (!looksLikeUrl(t)) out.push(t); + } + return out; +} + +function looksLikeUrl(s) { + return /^https?:\/\//i.test(s) || /^ftp:\/\//i.test(s) || /^ssh:\/\//i.test(s); } export function pathDenyOverlay({ diff --git a/tools/shell-content-rules.test.mjs b/tools/shell-content-rules.test.mjs index 5fc3d1b3..ecb7e658 100644 --- a/tools/shell-content-rules.test.mjs +++ b/tools/shell-content-rules.test.mjs @@ -59,6 +59,30 @@ describe('extractPathArgs', () => { }); }); +describe('extractPathArgs edge cases (Stream H Task 2)', () => { + it('extracts path from --output=PATH form', () => { + expect(extractPathArgs(['curl', '--output=~/.claude/projects/secret.jsonl', 'http://x'])).toContain('~/.claude/projects/secret.jsonl'); + }); + it('extracts path from --output PATH form (separate token)', () => { + expect(extractPathArgs(['curl', '--output', '~/.claude/projects/secret.jsonl', 'http://x'])).toContain('~/.claude/projects/secret.jsonl'); + }); + it('extracts path from dd of=PATH form', () => { + expect(extractPathArgs(['dd', 'if=/dev/zero', 'of=~/.claude/projects/x.jsonl'])).toContain('~/.claude/projects/x.jsonl'); + }); + it('extracts path from tee PATH (second positional)', () => { + expect(extractPathArgs(['tee', '~/.claude/projects/x.jsonl'])).toContain('~/.claude/projects/x.jsonl'); + }); + it('extracts path from cp SRC DST (both positionals)', () => { + const got = extractPathArgs(['cp', '/tmp/x', '~/.claude/projects/x.jsonl']); + expect(got).toContain('~/.claude/projects/x.jsonl'); + }); + it('does not include URL as path (heuristic)', () => { + const got = extractPathArgs(['curl', '--output', '/tmp/x', 'https://example.com/y']); + expect(got).toContain('/tmp/x'); + expect(got).not.toContain('https://example.com/y'); + }); +}); + describe('pathDenyOverlay', () => { it('blocks when a candidate path is protected', () => { const r = pathDenyOverlay({ candidatePaths: ['~/.claude/runtime/x.json'] });