fix(router-gate-v4): Stream H Task 2 — extractPathArgs handles --flag=PATH, key=VAL, multi-positional

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
This commit is contained in:
Дмитрий
2026-05-30 10:25:15 +03:00
parent cebd6bcebb
commit fc3c85bb6e
2 changed files with 52 additions and 1 deletions
+28 -1
View File
@@ -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({
+24
View File
@@ -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'] });