From fc3c85bb6e55216edc034af95715e2f3a6eded50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 30 May 2026 10:25:15 +0300 Subject: [PATCH] =?UTF-8?q?fix(router-gate-v4):=20Stream=20H=20Task=202=20?= =?UTF-8?q?=E2=80=94=20extractPathArgs=20handles=20--flag=3DPATH,=20key=3D?= =?UTF-8?q?VAL,=20multi-positional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found during Smoke 5 trace (recovery-procedures.md Section 5 fabrication #4): extractPathArgs was missing protected paths when they appeared as a flag value (--output=PATH or --output PATH) or as the second positional argument (dd of=, tee, cp DST). The path-deny overlay correctly checks each candidate path, but the candidate list was incomplete. Fix: rewrite extractPathArgs to scan all tokens past index 0: - recognize --flag=VALUE inline form (extract VALUE) - recognize key=value (dd-style: if=, of=) - skip URL-looking tokens (https://, ftp://, ssh://) as low-FP heuristic - preserve existing behavior for plain positionals and skip redirect tokens Regression: vitest tools 1726/1726 GREEN (was 1720; +6 path edge-case tests under "extractPathArgs edge cases (Stream H Task 2)"). Stream H Task 2 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md --- tools/shell-content-rules.mjs | 29 ++++++++++++++++++++++++++++- tools/shell-content-rules.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) 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'] });