diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index 2c357a87274..ecd80ca66f6 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -617,6 +617,105 @@ describe('splitCompoundCommand', () => { expect(splitCompoundCommand('echo a \\&& b')).toEqual(['echo a \\&', 'b']); }); + // Rows starting with a plain `'c\'` are split only by bash's reading, so + // they pin the ANSI-C tracking. + it.each([ + ["echo 'a\\' ; touch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']], + ["echo 'a\\' && touch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']], + ["echo 'a\\' | sh", ["echo 'a\\'", 'sh']], + ["echo 'a\\' & touch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']], + ["echo 'a\\'\ntouch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']], + ["echo $'a\\'' ; touch /tmp/x", ["echo $'a\\''", 'touch /tmp/x']], + [ + "echo 'c\\' $'a\\'' ; touch /tmp/x", + ["echo 'c\\' $'a\\''", 'touch /tmp/x'], + ], + [ + "echo 'c\\' $'a\\'' && touch /tmp/x", + ["echo 'c\\' $'a\\''", 'touch /tmp/x'], + ], + ["echo 'c\\' $'a\\'' | sh", ["echo 'c\\' $'a\\''", 'sh']], + [ + "echo 'c\\' $'a\\'' & touch /tmp/x", + ["echo 'c\\' $'a\\''", 'touch /tmp/x'], + ], + [ + "echo 'c\\' $'a\\''\ntouch /tmp/x", + ["echo 'c\\' $'a\\''", 'touch /tmp/x'], + ], + [ + "echo 'c\\' \\\\$'a\\'' ; touch /tmp/x", + ["echo 'c\\' \\\\$'a\\''", 'touch /tmp/x'], + ], + [ + "echo 'c\\' $\\\n'a\\'' ; touch /tmp/x", + ["echo 'c\\' $\\\n'a\\''", 'touch /tmp/x'], + ], + [ + "echo 'c\\' $\\\n'a\\'' & touch /tmp/x", + ["echo 'c\\' $\\\n'a\\''", 'touch /tmp/x'], + ], + [ + "echo 'c\\' $\\\n'a\\''\ntouch /tmp/x", + ["echo 'c\\' $\\\n'a\\''", 'touch /tmp/x'], + ], + ["echo \\$'a\\' ; touch /tmp/x", ["echo \\$'a\\'", 'touch /tmp/x']], + ["echo $$'a\\' ; touch /tmp/x", ["echo $$'a\\'", 'touch /tmp/x']], + // A `$` opens ANSI-C only when the quote follows it directly. + ["echo $x'a\\' ; touch /tmp/x", ["echo $x'a\\'", 'touch /tmp/x']], + ['echo "$"\'a\\\' ; touch /tmp/x', ['echo "$"\'a\\\'', 'touch /tmp/x']], + ])('splits after the quoted word in %s', async (command, parts) => { + expect(splitCompoundCommand(command)).toEqual(parts); + }); + + it('keeps an escaped quote inside double quotes and a line continuation', async () => { + expect(splitCompoundCommand('echo "a\\" ; touch /tmp/x"')).toEqual([ + 'echo "a\\" ; touch /tmp/x"', + ]); + expect(splitCompoundCommand('echo a\\\nb')).toEqual(['echo a\\\nb']); + }); + + // The `echo 'a\'' ; rm x'` row is one command to bash but stays split, as on + // main. + it.each([ + [ + "echo done # note 'a\\''\nrm -rf /tmp/x", + ["echo done # note 'a\\''", 'rm -rf /tmp/x'], + ], + [ + "echo `echo 'a\\''` ; rm -rf /tmp/x", + ["echo `echo 'a\\''`", 'rm -rf /tmp/x'], + ], + [ + "cat < { + expect(splitCompoundCommand(command)).toEqual(parts); + }); + + // The #11851 rows below put the target in a segment that ends at an operator; + // the last segment is trimmed separately. + it('keeps a redirection target bash does not treat as whitespace in the last segment', async () => { + expect(splitCompoundCommand('cat f & echo x >\u00a0')).toEqual([ + 'cat f', + 'echo x >\u00a0', + ]); + expect(splitCompoundCommand('cat f & echo x >\v')).toEqual([ + 'cat f', + 'echo x >\v', + ]); + }); + it('trims whitespace around sub-commands', async () => { expect(splitCompoundCommand(' git status && rm -rf / ')).toEqual([ 'git status', @@ -804,6 +903,33 @@ describe('splitCompoundCommandSegments', () => { { command: 'npm test', terminator: '&' }, ]); }); + + it('reports the terminator across a quote the two readings disagree on', async () => { + // Only the bash reading finds these operators, so they are reported with + // `terminatorAmbiguous` and shell-semantics keeps both cwds rather than + // reading `&` as backgrounded on their word alone. + expect( + splitCompoundCommandSegments("cd 'a\\' & echo {} > settings.json"), + ).toEqual([ + { command: "cd 'a\\'", terminator: '&', terminatorAmbiguous: true }, + { command: 'echo {} > settings.json', terminator: '' }, + ]); + expect( + splitCompoundCommandSegments("cd 'a\\' && echo {} > settings.json"), + ).toEqual([ + { command: "cd 'a\\'", terminator: '&&', terminatorAmbiguous: true }, + { command: 'echo {} > settings.json', terminator: '' }, + ]); + }); + + it('leaves a terminator both readings find unambiguous', async () => { + expect( + splitCompoundCommandSegments("cd 'a' & echo {} > settings.json"), + ).toEqual([ + { command: "cd 'a'", terminator: '&' }, + { command: 'echo {} > settings.json', terminator: '' }, + ]); + }); }); // ─── resolvePathPattern ────────────────────────────────────────────────────── @@ -2404,6 +2530,30 @@ describe('PermissionManager', () => { ).toBe('allow'); }); + it('deny survives a `cd` ended by one quote reading alone (#12246)', async () => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Bash(cd *)', 'Bash(echo *)'], + permissionsDeny: ['Write(.qwen/settings.json)'], + cwd: '/repo', + projectRoot: '/repo', + }), + ); + pm.initialize(); + // bash reads `'x\''` + `';echo '` as one word, so ` & ` backgrounds the + // second `cd` and the write lands in .qwen; the controls are the same + // command with the quoting that makes the operator plain. + for (const command of [ + `cd .qwen ; cd 'x\\'';echo ' & echo {} > settings.json`, + 'cd .qwen ; cd x & echo {} > settings.json', + 'cd .qwen ; echo {} > settings.json', + ]) { + expect( + await pm.evaluate({ toolName: 'run_shell_command', command }), + ).toBe('deny'); + } + }); + it('semicolon compound: deny in second → deny', async () => { pm = new PermissionManager( makeConfig({ @@ -2420,6 +2570,81 @@ describe('PermissionManager', () => { ).toBe('deny'); }); + it.each<[string, string[], string]>([ + ["echo 'a\\' ; rm -rf /tmp/x", [], 'ask'], + ["echo 'a\\' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'], + ["echo $'a\\'' ; rm -rf /tmp/x", [], 'ask'], + ["echo $'a\\'' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'], + ["echo 'c\\' $'a\\'' ; rm -rf /tmp/x", [], 'ask'], + ["echo 'c\\' $'a\\'' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'], + ["echo $\\\n'a\\'' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'], + ["echo done # note 'a\\''\nrm -rf /tmp/x", ['Bash(rm *)'], 'deny'], + [ + "cat < .qwen/settings.json", + ['Write(.qwen/settings.json)'], + 'deny', + ], + // bash backgrounds the `cd`, so the write lands in the cwd — the + // permissions file itself. main sees one segment and no write at all. + [ + "cd 'a\\' & echo {} > .qwen/settings.json", + ['Write(.qwen/settings.json)'], + 'deny', + ], + ["echo 'a\\'' ; rm x'", ['Bash(rm *)'], 'deny'], + ])('%j with deny %j is %s', async (command, deny, expected) => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Bash(echo *)', 'Bash(cat *)'], + permissionsDeny: deny, + cwd: '/repo', + projectRoot: '/repo', + }), + ); + pm.initialize(); + expect( + await pm.evaluate({ + toolName: 'run_shell_command', + command, + cwd: '/repo', + }), + ).toBe(expected); + }); + + // The declared tradeoff: bash runs one command here, since everything from + // `#` on is a comment, but the pre-fix reading splits inside it and a + // `Bash(rm *)` deny refuses a commit the user cannot see an `rm` in. Only + // shapes that bail out of #12096's comment fast path still reach it — a + // newline here, or `monitor` — while the single-line spelling stays one + // segment for `Bash(...)` rules and is allowed again. + it.each<[string, string, string]>([ + [ + 'run_shell_command', + "git commit -m 'x' # saved to 'C:\\'\nrm draft", + 'deny', + ], + ['monitor', "git commit -m 'x' # saved to 'C:\\' ; rm draft", 'deny'], + [ + 'run_shell_command', + "git commit -m 'x' # saved to 'C:\\' ; rm draft", + 'allow', + ], + ])('%s %j is %s', async (toolName, command, expected) => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Bash(git *)'], + permissionsDeny: ['Bash(rm *)'], + }), + ); + pm.initialize(); + expect(await pm.evaluate({ toolName, command })).toBe(expected); + }); + it('|| compound: all allowed → allow', async () => { pm = new PermissionManager( makeConfig({ @@ -2510,12 +2735,14 @@ describe('PermissionManager', () => { // cross-check latches a quote, never sees the `#`, and bails at the `;`. ['bash', `echo "don't" # c ; rm -rf /tmp/x`, 'allow'], ['bash', `echo 'a"b' # c ; rm -rf /tmp/x`, 'allow'], - // Characterization rows for #11815's measured table: these reach `allow` - // only because the unterminated quote masks the in-comment separator, so - // they are expected to go red when #11765 changes the splitter. - ['bash', "echo 'a\\' # note: use ; carefully", 'allow'], - ['bash', "echo 'a\\' # trailing && touch /tmp/x", 'allow'], - ['bash', "echo 'a\\' # trailing | touch /tmp/x", 'allow'], + // Characterization rows for #11815's measured table. Bash runs only the + // `echo`, but the `\` bails the fast path, and the splitter (which does + // not model comments) closes `'a\'` as bash does and splits at the + // in-comment separator: a fail-closed `ask` where `main` read the quote as + // unterminated and returned `allow`. + ['bash', "echo 'a\\' # note: use ; carefully", 'ask'], + ['bash', "echo 'a\\' # trailing && touch /tmp/x", 'ask'], + ['bash', "echo 'a\\' # trailing | touch /tmp/x", 'ask'], ] as const)( 'handles comments conservatively for %s: %s', async (shell, command, expected) => { diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index a6c4c85d140..c76d445f45d 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -965,6 +965,12 @@ export interface CompoundCommandSegment { * shell runs in a subshell (`&`). */ terminator: string; + /** + * Set when only one of the two backslash readings found this operator, so + * bash may not run it at all. Such a terminator must not decide on its own + * what the shell did — notably whether a `cd` ran in a subshell (#12246). + */ + terminatorAmbiguous?: boolean; } /** @@ -973,31 +979,129 @@ export interface CompoundCommandSegment { * * See {@link splitCompoundCommand} for the string-only form and for examples; * this is the same split, and that function is a projection of this one. + * + * Scanned twice and split wherever either scan finds an operator: comments, + * backtick bodies and heredocs are not modelled, and quotes inside them can + * fool bash's backslash reading where the pre-fix reading still splits. */ export function splitCompoundCommandSegments( command: string, ): CompoundCommandSegment[] { + // The two readings differ only at a backslash, so one scan is enough without. + const boundaries = command.includes('\\') + ? mergeOperatorBoundaries( + findOperatorBoundaries(command, 'bash'), + findOperatorBoundaries(command, 'escape-everywhere'), + ) + : findOperatorBoundaries(command, 'bash'); + const segments: CompoundCommandSegment[] = []; + let lastSplit = 0; + for (const { start, end, operator, ambiguous } of boundaries) { + if (start < lastSplit) { + continue; + } + // bash reads a CRLF's `\r` as part of the last word, but it is dropped here + // as a line ending unless it is the whole redirection target of the line. + // A lone `\r` is a bash word character and stays. + const raw = command.substring(lastSplit, start); + const dropsLineEndingCR = + operator === '\n' && !CR_IS_WHOLE_REDIRECT_TARGET.test(raw); + const segment = trimBashWordSeparators( + dropsLineEndingCR ? raw.replace(/\r$/, '') : raw, + ); + if (segment) { + segments.push({ + command: segment, + terminator: operator, + ...(ambiguous ? { terminatorAmbiguous: true } : {}), + }); + } + lastSplit = end; + } + + // Add the last segment + const lastSegment = trimBashWordSeparators(command.substring(lastSplit)); + if (lastSegment) { + segments.push({ command: lastSegment, terminator: '' }); + } + + return segments; +} + +interface OperatorBoundary { + start: number; + end: number; + operator: string; + /** Only one of the two backslash readings found this operator. */ + ambiguous?: boolean; +} + +/** + * Order the boundaries of both readings and flag the ones only one of them + * found. A boundary both readings agree on is one bash certainly runs; the + * rest are split on all the same (a missed boundary hides a command), but + * their operator is not evidence of what the shell did. + */ +function mergeOperatorBoundaries( + bash: OperatorBoundary[], + escapeEverywhere: OperatorBoundary[], +): OperatorBoundary[] { + const key = (b: OperatorBoundary) => `${b.start}:${b.operator}`; + const bashKeys = new Set(bash.map(key)); + const escapeKeys = new Set(escapeEverywhere.map(key)); + return [...bash, ...escapeEverywhere] + .sort((a, b) => a.start - b.start) + .map((b) => ({ + ...b, + ...(bashKeys.has(key(b)) && escapeKeys.has(key(b)) + ? {} + : { ambiguous: true }), + })); +} + +type BackslashReading = 'bash' | 'escape-everywhere'; + +function findOperatorBoundaries( + command: string, + reading: BackslashReading, +): OperatorBoundary[] { + const boundaries: OperatorBoundary[] = []; let inSingle = false; let inDouble = false; + let inAnsiC = false; + let dollarPending = false; let escaped = false; - let lastSplit = 0; // Nesting depth of `$(( … ))` / `(( … ))`. Inside arithmetic a bare `&` is // bitwise AND, not the async operator, so `$(( FLAGS & MASK ))` is one word. let arithmeticDepth = 0; for (let i = 0; i < command.length; i++) { const ch = command[i]!; + const ansiCIntroducer: boolean = dollarPending; + dollarPending = false; if (escaped) { escaped = false; continue; } - if (ch === '\\') { + // In bash a backslash is literal inside a plain `'…'` (so `'a\'` closes) + // but escapes inside ANSI-C `$'…'` (so `$'a\''` closes at the third quote). + if ( + ch === '\\' && + (reading === 'escape-everywhere' || !(inSingle && !inAnsiC)) + ) { + // `$\⏎'…'` is still ANSI-C, so the pending `$` survives a continuation. + if (command[i + 1] === '\n') { + dollarPending = ansiCIntroducer; + i++; + continue; + } escaped = true; continue; } if (ch === "'" && !inDouble) { + inAnsiC = inSingle ? false : ansiCIntroducer; inSingle = !inSingle; continue; } @@ -1008,6 +1112,11 @@ export function splitCompoundCommandSegments( if (inSingle || inDouble) { continue; } + if (ch === '$') { + // The second `$` of `$$` (the PID) cannot open `$'…'`. + dollarPending = !ansiCIntroducer; + continue; + } if (ch === '(' && command[i + 1] === '(') { arithmeticDepth++; @@ -1031,31 +1140,13 @@ export function splitCompoundCommandSegments( if (op === '&' && (arithmeticDepth > 0 || !isAsyncOperator(command, i))) { continue; } - // A CRLF pair ends a line, so the `\r` in front of a `\n` terminator is - // dropped with it; a lone `\r` is a bash word character and stays. The - // exception is a `\r` that *is* the whole redirection target of the line. - const raw = command.substring(lastSplit, i); - const dropsLineEndingCR = - op === '\n' && !CR_IS_WHOLE_REDIRECT_TARGET.test(raw); - const segment = trimBashWordSeparators( - dropsLineEndingCR ? raw.replace(/\r$/, '') : raw, - ); - if (segment) { - segments.push({ command: segment, terminator: op }); - } - lastSplit = i + op.length; - i = lastSplit - 1; // -1 because the loop will i++ + boundaries.push({ start: i, end: i + op.length, operator: op }); + i += op.length - 1; // -1 because the loop will i++ break; } } - // Add the last segment - const lastSegment = trimBashWordSeparators(command.substring(lastSplit)); - if (lastSegment) { - segments.push({ command: lastSegment, terminator: '' }); - } - - return segments; + return boundaries; } /** diff --git a/packages/core/src/permissions/shell-semantics.test.ts b/packages/core/src/permissions/shell-semantics.test.ts index 2274772fd4e..d0a5f6786e3 100644 --- a/packages/core/src/permissions/shell-semantics.test.ts +++ b/packages/core/src/permissions/shell-semantics.test.ts @@ -755,6 +755,23 @@ describe('extractShellOperationsAcrossCommand', () => { ]); }); + // Only the escape-everywhere reading sees a `;` between the two quote + // fragments; bash concatenates them into one word, so the ` & ` is the real + // terminator and the backgrounded `cd` never moves the parent shell. Neither + // reading owns that decision, so the write is attributed to both cwds and the + // protected path stays covered (#12246). + it('keeps both cwds when one reading alone ends the `cd`', () => { + expect( + extractShellOperationsAcrossCommand( + `cd .qwen ; cd 'x\\'';echo ' & echo {} > settings.json`, + '/repo', + ), + ).toEqual([ + { virtualTool: 'write_file', filePath: '/repo/.qwen/settings.json' }, + { virtualTool: 'write_file', filePath: '/repo/.qwen/x/settings.json' }, + ]); + }); + it('does not mark later paths uncertain for a backgrounded dynamic `cd`', () => { // The foreground form below cannot know where it landed; the backgrounded // one can, because it did not move the cwd at all. diff --git a/packages/core/src/permissions/shell-semantics.ts b/packages/core/src/permissions/shell-semantics.ts index cfbedf4010d..8f5f4ac2ed5 100644 --- a/packages/core/src/permissions/shell-semantics.ts +++ b/packages/core/src/permissions/shell-semantics.ts @@ -2121,7 +2121,10 @@ function resolveCdTargetCwd( * - Shell wrappers are unwrapped after the outer command is split, so * wrapper suffixes remain visible while inner compound operators * (`&&`, `;`, `|`) are still recursively discovered. - * - Operation order is preserved across segments. + * - Operation order is preserved across segments. A `cd` ended by an + * operator only one backslash reading found leaves both the moved and + * the unmoved cwd open, so the segments after it report their paths + * under each (#12246). * * Single source of truth for compound shell analysis: both the * PermissionManager (matching `Edit/Write` rules against shell writes) and @@ -2257,6 +2260,47 @@ function getHeredocDelimiters(line: string): string[] { return delimiters; } +/** + * A directory the segments after a `cd` may run in. The walk carries more than + * one when a `cd`'s effect on the cwd is not decided: the operator that ended + * it was seen by only one backslash reading, so bash either ran it in the + * foreground or in a subshell, and the rules must hold under both (#12246). + */ +interface CwdCandidate { + cwd: string; + cwdUnknown: boolean; +} + +/** + * Candidates double at every undecided `cd`, so cap the walk. Past the cap the + * remaining paths are reported as cwd-unknown instead of branching further. + */ +const MAX_CWD_CANDIDATES = 8; + +function dedupeCwdCandidates(candidates: CwdCandidate[]): CwdCandidate[] { + const seen = new Set(); + const unique = candidates.filter((candidate) => { + const key = `${candidate.cwd}\u0000${candidate.cwdUnknown}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (unique.length <= MAX_CWD_CANDIDATES) return unique; + return unique + .slice(0, MAX_CWD_CANDIDATES) + .map((candidate) => ({ ...candidate, cwdUnknown: true })); +} + +function dedupeOps(ops: ShellOperation[]): ShellOperation[] { + const seen = new Set(); + return ops.filter((op) => { + const key = JSON.stringify(op); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + function walkCompoundCommand( command: string, cwd: string, @@ -2266,10 +2310,10 @@ function walkCompoundCommand( const subCommands = splitCompoundCommandSegments(stripHeredocBodies(command)); const ops: ShellOperation[] = []; - let effectiveCwd = cwd; - let cwdUnknown = initialCwdUnknown; + let candidates: CwdCandidate[] = [{ cwd, cwdUnknown: initialCwdUnknown }]; + let branched = false; - for (const { command: sub, terminator } of subCommands) { + for (const { command: sub, terminator, terminatorAmbiguous } of subCommands) { // `cd x & …` runs the `cd` in a background subshell, so it does not move // the cwd the following segments run in. Treating it as a foreground `cd` // would attribute their relative writes to the wrong directory — for @@ -2277,51 +2321,60 @@ function walkCompoundCommand( // cwd, which is exactly where a protected settings file would be. const backgrounded = terminator === '&'; - const cdTarget = resolveCdTargetCwd(sub, effectiveCwd, cwdUnknown); - if (cdTarget.kind === 'static') { - if (!backgrounded) { - effectiveCwd = cdTarget.cwd; - cwdUnknown = cdTarget.cwdUnknown; - } - continue; - } - if (cdTarget.kind === 'dynamic') { - if (!backgrounded) { - cwdUnknown = true; + // `cd`-ness does not depend on the cwd, only the target it resolves to. + const resolved = candidates.map((candidate) => + resolveCdTargetCwd(sub, candidate.cwd, candidate.cwdUnknown), + ); + if (resolved[0]!.kind !== 'not-cd') { + if (backgrounded && !terminatorAmbiguous) { + continue; } + const moved = resolved.map((target, i) => + target.kind === 'static' + ? { cwd: target.cwd, cwdUnknown: target.cwdUnknown } + : { cwd: candidates[i]!.cwd, cwdUnknown: true }, + ); + // An operator only one reading sees cannot decide whether this `cd` ran + // in the foreground, so both outcomes stay open. + branched ||= Boolean(terminatorAmbiguous); + candidates = dedupeCwdCandidates( + terminatorAmbiguous ? [...candidates, ...moved] : moved, + ); continue; } // Unwrap per segment, after the outer split, so wrapper suffixes like // `bash -lc 'safe' && echo > file` are not discarded. - if (depth < MAX_SHELL_UNWRAP_DEPTH) { - const subUnwrapped = stripShellWrapper(sub); - if (subUnwrapped !== sub) { + const unwrappable = depth < MAX_SHELL_UNWRAP_DEPTH; + const subUnwrapped = stripShellWrapper(sub); + if (!unwrappable && subUnwrapped !== sub) { + shellSemanticsDebugLogger.warn( + `Shell wrapper unwrap depth limit reached (${MAX_SHELL_UNWRAP_DEPTH}); analysing remaining command as-is.`, + ); + } + + for (const { cwd: segmentCwd, cwdUnknown } of candidates) { + if (unwrappable && subUnwrapped !== sub) { ops.push( ...walkCompoundCommand( subUnwrapped, - effectiveCwd, + segmentCwd, depth + 1, cwdUnknown, ), ); continue; } - } else if (stripShellWrapper(sub) !== sub) { - shellSemanticsDebugLogger.warn( - `Shell wrapper unwrap depth limit reached (${MAX_SHELL_UNWRAP_DEPTH}); analysing remaining command as-is.`, - ); - } - - const subOps = extractShellOperations(sub, effectiveCwd); - if (cwdUnknown) { - ops.push(...markCwdUnknownOps(subOps, sub, effectiveCwd)); - } else { - ops.push(...subOps); + const subOps = extractShellOperations(sub, segmentCwd); + if (cwdUnknown) { + ops.push(...markCwdUnknownOps(subOps, sub, segmentCwd)); + } else { + ops.push(...subOps); + } } } - return ops; + return branched ? dedupeOps(ops) : ops; } function hasAbsolutePathTokenForOperation(