opencode-workflow-guard 1.18.1 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,16 @@ import { realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import type { ShellMutation } from "../lib/types.ts";
5
+ import type { ShellSegmentPlan } from "../lib/shell.ts";
5
6
  import {
6
7
  getWorkspaceRoot,
7
8
  recordMutation,
8
9
  } from "../lib/state.ts";
9
- import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
10
+ import { isLeadingEvalSegment, maskDataHeredocBodies, prepareRedirectResidue, planShellSegments, shellWords, unwrapShellCommand } from "../lib/shell.ts";
10
11
  import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
11
12
  import { isSecretPath, secretIn } from "./secrets.ts";
12
- import { onProtectedBranch, branchGuardReason } from "./git.ts";
13
+ import { isGitTreeWriteText, branchGuardReason, onProtectedBranch, parseGitInvocation } from "./git.ts";
14
+ import { isSameGitRepo } from "../lib/project-config.ts";
13
15
  import {
14
16
  effectiveTodos,
15
17
  effectiveTodoOwnerSessionID,
@@ -372,8 +374,265 @@ function stripQuotedSpans(segment: string): string {
372
374
  return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
373
375
  }
374
376
 
377
+ /**
378
+ * A segment that changes a tree this session may not judge: a mutation or git
379
+ * write whose directory could not be resolved (`cd $DIR && git commit`) or
380
+ * whose directory is a foreign repository (`cd /other-repo && rm -rf dist`).
381
+ *
382
+ * Relative targets in such a segment would be validated against the session's
383
+ * own checkout while the command changes another tree. That is the defect a
384
+ * shell-level `cd` used to hide, so it is refused rather than judged.
385
+ */
386
+ export function foreignSegmentMutationReason(
387
+ segments: ShellSegmentPlan[],
388
+ workspaceRoot: string,
389
+ ): string | undefined {
390
+ for (const { segment, root } of segments) {
391
+ const mutation = detectShellMutation(segment);
392
+ const gitWrite = isGitTreeWrite(segment, root ?? workspaceRoot);
393
+ if (!mutation && !gitWrite) continue;
394
+ if (root === undefined) {
395
+ return `Blocked: the command changes directory to a target that cannot be determined without executing shell expansion, then runs a mutation (${mutation?.what ?? "git write"}). Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
396
+ }
397
+ if (!isPathOutsideWorkspace(root, workspaceRoot) || isSameGitRepo(root, workspaceRoot)) continue;
398
+ return `Blocked: the command changes into '${root}' and mutates it (${mutation?.what ?? "git write"}), but that directory is outside workspace root (${workspaceRoot}) and is not a linked worktree of this repository. Relative targets there cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`;
399
+ }
400
+ return undefined;
401
+ }
402
+
403
+ function isGitTreeWrite(segment: string, baseDir: string): boolean {
404
+ const invocation = parseGitInvocation(segment.trim(), baseDir);
405
+ if (!invocation) return false;
406
+ return isGitTreeWriteText(`git ${invocation.rest}`);
407
+ }
408
+
409
+ /**
410
+ * The working directory an inline interpreter payload names for the commands it
411
+ * runs.
412
+ *
413
+ * `execSync('git commit', {cwd: '<dir>'})` carries the tree directly instead of
414
+ * changing directory first, so the payload walk alone cannot see it. The match
415
+ * is anchored to a spawn-family call or `process.chdir`, because a bare `cwd:`
416
+ * key in an unrelated object literal does not choose a working directory.
417
+ *
418
+ * A named directory that is not a literal (`{cwd: process.env.OTHER}`) is
419
+ * reported as indeterminate rather than ignored, matching the shell rule that
420
+ * an unresolvable destination fails closed for a mutation.
421
+ */
422
+ export type PayloadWorkingRoot =
423
+ | { status: "none" }
424
+ | { status: "resolved"; root: string }
425
+ | { status: "indeterminate"; raw: string };
426
+
427
+ // Only a call site that actually spawns a process names a working directory.
428
+ // `execSync`, `spawn`, and friends are unambiguous. A bare `exec` is guarded by
429
+ // its preceding token so `regex.exec(str, {cwd: …})` is not a spawn, while
430
+ // `child_process.exec(...)` still is. A bare `cwd:` key in an unrelated object
431
+ // literal names no working directory at all.
432
+ const SPAWN_CALLEE = String.raw`(?:(?:child_process|cp)\.)?(?:execSync|execFileSync|spawnSync|execFile|spawn|fork)|(?:child_process|cp)\.exec|(?<![.\w$])exec`;
433
+ const PROCESS_CHDIR = /\bprocess\s*\.\s*chdir\s*\(\s*([^)]+?)\s*\)/;
434
+
435
+ /**
436
+ * The argument text of every spawn-family call, found with a balanced scan.
437
+ *
438
+ * A flat `[^)]*` stopped at the first closing paren, so
439
+ * `execSync(require('path').join('a','b'), {cwd: '/other'})` matched nothing and
440
+ * its foreign `cwd` went unchecked.
441
+ */
442
+ function spawnCallArguments(payload: string): string[] {
443
+ const spans: string[] = [];
444
+ const callee = new RegExp(String.raw`(?:${SPAWN_CALLEE})\s*\(`, "g");
445
+ for (let match = callee.exec(payload); match; match = callee.exec(payload)) {
446
+ let depth = 0;
447
+ let quote: string | undefined;
448
+ const start = match.index + match[0].length - 1;
449
+ for (let i = start; i < payload.length; i += 1) {
450
+ const char = payload[i]!;
451
+ if (quote) {
452
+ if (char === quote && payload[i - 1] !== "\\") quote = undefined;
453
+ continue;
454
+ }
455
+ if (char === "'" || char === '"' || char === "`") {
456
+ quote = char;
457
+ continue;
458
+ }
459
+ if (char === "(") depth += 1;
460
+ else if (char === ")") {
461
+ depth -= 1;
462
+ if (depth === 0) {
463
+ spans.push(payload.slice(start + 1, i));
464
+ break;
465
+ }
466
+ }
467
+ }
468
+ callee.lastIndex = Math.max(callee.lastIndex, start + 1);
469
+ }
470
+ return spans;
471
+ }
472
+
473
+ /**
474
+ * The `cwd` of the call's own options object, ignoring a nested one.
475
+ *
476
+ * The argument text is split at top-level commas, and only an argument that is
477
+ * itself an object literal contributes. Without that, `execSync(fn({cwd: '<a>'}),
478
+ * {cwd: '<b>'})` read the nested object's key first and reported a workspace
479
+ * directory for a call that actually ran in `<b>`.
480
+ */
481
+ function callOwnCwd(args: string): string | undefined {
482
+ let depth = 0;
483
+ let quote: string | undefined;
484
+ const topLevel: string[] = [];
485
+ let current = "";
486
+ for (let i = 0; i < args.length; i += 1) {
487
+ const char = args[i]!;
488
+ if (quote) {
489
+ if (char === quote && args[i - 1] !== "\\") quote = undefined;
490
+ current += char;
491
+ continue;
492
+ }
493
+ if (char === "'" || char === '"' || char === "`") {
494
+ quote = char;
495
+ current += char;
496
+ continue;
497
+ }
498
+ if (char === "{" || char === "[" || char === "(") depth += 1;
499
+ else if (char === "}" || char === "]" || char === ")") depth -= 1;
500
+ if (char === "," && depth === 0) {
501
+ topLevel.push(current);
502
+ current = "";
503
+ continue;
504
+ }
505
+ current += char;
506
+ }
507
+ topLevel.push(current);
508
+ for (const argument of topLevel) {
509
+ const trimmed = argument.trim();
510
+ if (!trimmed.startsWith("{")) continue;
511
+ const match = /\bcwd\s*:\s*([^,}]+)/.exec(trimmed);
512
+ if (match?.[1]) return match[1].trim();
513
+ }
514
+ return undefined;
515
+ }
516
+
517
+ export function payloadWorkingRoot(payload: string): PayloadWorkingRoot {
518
+ const cwdValues = spawnCallArguments(payload)
519
+ .map(callOwnCwd)
520
+ .filter((value): value is string => value !== undefined);
521
+ const chdir = PROCESS_CHDIR.exec(payload)?.[1]?.trim();
522
+ if (chdir) cwdValues.push(chdir);
523
+ // Every call's directory has to agree. Two different trees, or one that is
524
+ // not a literal, means the payload's real directory is not determinable, and
525
+ // a mutation then fails closed rather than trusting the first one found.
526
+ const roots = new Set<string>();
527
+ for (const raw of cwdValues) {
528
+ const literal = /^(["'`])([^"'`]*)\1$/.exec(raw);
529
+ if (!literal?.[2]) return { status: "indeterminate", raw };
530
+ roots.add(literal[2]);
531
+ }
532
+ if (roots.size === 0) return { status: "none" };
533
+ if (roots.size > 1) return { status: "indeterminate", raw: [...roots].join(", ") };
534
+ return { status: "resolved", root: [...roots][0]! };
535
+ }
536
+
537
+ function payloadMutates(payload: string): boolean {
538
+ if (detectShellMutation(payload)) return true;
539
+ return isGitTreeWriteText(payload);
540
+ }
541
+
542
+ /**
543
+ * A segment-level git invocation whose repository is outside the workspace.
544
+ *
545
+ * The dispatcher applies this check to top-level commands. A payload was never
546
+ * routed through it, so `bash -c 'git -C <other> commit'` reached a foreign
547
+ * repository: the `-C` form carries no `cd`, and `isGitTreeWriteText` reads the
548
+ * `git <verb>` text without resolving the flag.
549
+ */
550
+ function foreignGitRepositoryIn(segment: string, baseDir: string, workspaceRoot: string): string | undefined {
551
+ for (const raw of segment.split(/[\n|;&]+/)) {
552
+ const invocation = parseGitInvocation(raw.trim(), baseDir);
553
+ if (!invocation) continue;
554
+ if (!isGitTreeWriteText(`git ${invocation.rest}`)) continue;
555
+ const repoDir = invocation.repoDir;
556
+ const foreign = hasUnresolvableVariable(repoDir)
557
+ || (isPathOutsideWorkspace(repoDir, workspaceRoot) && !isSameGitRepo(repoDir, workspaceRoot));
558
+ if (foreign) return repoDir;
559
+ }
560
+ return undefined;
561
+ }
562
+
563
+ /**
564
+ * The tree a nested interpreter payload changes, when that tree is one this
565
+ * session may not judge.
566
+ *
567
+ * A payload never reaches the top-level segment walk: `bash -c 'cd <other> &&
568
+ * git commit'` is one shell word to the outer shell, so its nested `cd` and
569
+ * mutation were invisible. `extractPayloads` and `writesIn` are injected so
570
+ * this policy does not import the interpreter scanner back.
571
+ */
572
+ export function nestedPayloadMutationReason(
573
+ extractPayloads: (segment: string) => string[],
574
+ writesIn: (payload: string) => string[],
575
+ command: string,
576
+ baseDir: string,
577
+ workspaceRoot: string,
578
+ depth = 0,
579
+ ): string | undefined {
580
+ // Nesting deeper than this is not modelled. A launcher chain that deep is
581
+ // refused rather than skipped, so the cap cannot become a way through.
582
+ if (depth > 8) {
583
+ return `Blocked: inline interpreter scripts are nested deeper than the ${8} levels this check models, so the tree they mutate cannot be determined. Run the command from a single script file inside the workspace instead.`;
584
+ }
585
+ for (const { segment, root } of planShellSegments(command, baseDir, (s) => {
586
+ // `eval` runs in the current shell, so its body's `cd` moves later
587
+ // segments. Only an `eval` body counts here; another launcher's payload
588
+ // runs in a child process and cannot move this shell.
589
+ return isLeadingEvalSegment(s) ? extractPayloads(s)[0] : undefined;
590
+ })) {
591
+ const payloads = extractPayloads(segment);
592
+ if (payloads.length === 0) continue;
593
+ // A launcher whose directory is indeterminate must not be assumed to be
594
+ // the workspace root: `cd $DIR && node -e "writeFileSync('x')"` would then
595
+ // read as a workspace write. The payload's own relative targets cannot be
596
+ // validated against a tree this walk could not resolve, so a mutation
597
+ // fails closed, matching the shell rule for the same shape.
598
+ const launcherRoot = root;
599
+ if (launcherRoot === undefined) {
600
+ const mutates = payloads.some((payload) => writesIn(payload).length > 0 || payloadMutates(payload));
601
+ if (mutates) {
602
+ return `Blocked: the command changes directory to a target that cannot be determined without executing shell expansion, then runs an inline interpreter script that mutates. Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
603
+ }
604
+ continue;
605
+ }
606
+ for (const payload of payloads) {
607
+ const foreignRepo = foreignGitRepositoryIn(payload, launcherRoot, workspaceRoot);
608
+ if (foreignRepo) {
609
+ return `Blocked: the inline interpreter script runs a git write against '${foreignRepo}', outside workspace root (${workspaceRoot}) and not a linked worktree of this repository. Run the command from inside the workspace instead.`;
610
+ }
611
+ const nested = foreignSegmentMutationReason(planShellSegments(payload, launcherRoot), workspaceRoot);
612
+ if (nested) return nested;
613
+ // A payload can launch another interpreter: `bash -c "bash -c 'cd
614
+ // <other> && git commit'"` only extracts its own quoted body. Re-plan
615
+ // each payload until the nesting bottoms out.
616
+ const inner = nestedPayloadMutationReason(extractPayloads, writesIn, payload, launcherRoot, workspaceRoot, depth + 1);
617
+ if (inner) return inner;
618
+ const declared = payloadWorkingRoot(payload);
619
+ if (declared.status === "none") continue;
620
+ if (writesIn(payload).length === 0 && !payloadMutates(payload)) continue;
621
+ if (declared.status === "indeterminate") {
622
+ return `Blocked: the inline interpreter script runs a mutating command in a working directory that cannot be determined without executing the script (${declared.raw}). Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
623
+ }
624
+ const targetRoot = resolve(launcherRoot, declared.root);
625
+ if (!isPathOutsideWorkspace(targetRoot, workspaceRoot) || isSameGitRepo(targetRoot, workspaceRoot)) continue;
626
+ return `Blocked: the inline interpreter script runs a mutating command in '${targetRoot}', outside workspace root (${workspaceRoot}) and not a linked worktree of this repository. Relative targets there cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`;
627
+ }
628
+ }
629
+ return undefined;
630
+ }
631
+
375
632
  export function detectShellMutation(command: string): ShellMutation | undefined {
376
- for (const rawSegment of command.split(/[\n|;&]+/)) {
633
+ // A heredoc body no command executes is prose. Splitting it on its newlines
634
+ // read a documented command shape as a real one.
635
+ for (const rawSegment of maskDataHeredocBodies(command).split(/[\n|;&]+/)) {
377
636
  const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
378
637
  const simpleMutations = simpleFilesystemMutations(segment);
379
638
  if (simpleMutations.length > 0) return simpleMutations[0];
@@ -395,15 +654,39 @@ export async function guardShellMutation(
395
654
  command: string,
396
655
  sessionID: string | undefined,
397
656
  record = true,
657
+ shellRoot?: string,
658
+ evalBody?: (segment: string) => string | undefined,
398
659
  ): Promise<string | undefined> {
399
- const root = getWorkspaceRoot();
660
+ // Judge the tree the command RUNS in. A linked worktree of this repository
661
+ // is a legitimate workdir, and its branch or relative targets are not the
662
+ // session checkout's: validating against that checkout blocked a mutation
663
+ // in a feature worktree as if it happened on the primary `main`.
664
+ const workspaceRoot = getWorkspaceRoot();
665
+ const root = shellRoot ?? workspaceRoot;
666
+ const plan = planShellSegments(command, root, evalBody);
667
+ // The foreign-tree gate runs here too, so this function is safe when called
668
+ // on its own: an indeterminate or foreign `cd` is refused instead of silently
669
+ // falling back to judging relative targets against the wrong tree.
670
+ const foreignReason = foreignSegmentMutationReason(plan, workspaceRoot);
671
+ if (foreignReason) return foreignReason;
400
672
  let hasMutation = false;
401
- for (const rawSegment of command.split(/[\n|;&]+/)) {
673
+ for (const { segment, root: segmentRoot } of plan) {
674
+ // `cd` earlier in the command moves this segment, so its relative targets
675
+ // resolve against the tree it actually changes. An indeterminate `cd` was
676
+ // already refused above.
677
+ const runRoot = segmentRoot ?? root;
678
+ // Both trees are this workspace: the checkout the command runs in and the
679
+ // session's own checkout. Absolute paths into either are legitimate, so a
680
+ // target is outside only when it leaves both.
681
+ const outsideWorkspace = (target: string): boolean =>
682
+ isPathOutsideWorkspace(target, runRoot) && isPathOutsideWorkspace(target, workspaceRoot);
402
683
  // Same residue analysis as detectShellMutation: quoted arguments of
403
684
  // gh/glab/az PR/issue commands are command data, not shell redirects,
404
- // while unquoted redirects still get full validation below.
405
- const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
406
- const secretSource = secretSourceInFilesystemCommand(segment);
685
+ // while unquoted redirects still get full validation below. A heredoc body
686
+ // that no command executes is prose, so its lines are not judged either.
687
+ const masked = maskDataHeredocBodies(segment);
688
+ const cleanSegment = isCollaborationInvocation(masked) ? stripQuotedSpans(masked) : masked;
689
+ const secretSource = secretSourceInFilesystemCommand(cleanSegment);
407
690
  if (secretSource) {
408
691
  return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
409
692
  }
@@ -411,36 +694,36 @@ export async function guardShellMutation(
411
694
  // sources must respect the same boundaries as mutation targets: no
412
695
  // moving files in from outside the workspace, no moving protected
413
696
  // (settings/plugin) files to innocuous names.
414
- const transfer = filesystemTransferInfo(segment);
415
- if (transfer && shellWords(unwrapShellCommand(segment))[0] === "mv") {
697
+ const transfer = filesystemTransferInfo(cleanSegment);
698
+ if (transfer && shellWords(unwrapShellCommand(cleanSegment))[0] === "mv") {
416
699
  for (const source of transfer.sources) {
417
700
  if (isProtectedPath(source)) {
418
701
  return PROTECTED_PATH_REASON;
419
702
  }
420
- if (isPathOutsideWorkspace(source, root)) {
703
+ if (outsideWorkspace(source)) {
421
704
  if (hasUnresolvableVariable(source)) {
422
- return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
705
+ return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal workspace-relative path.`;
423
706
  }
424
- return `Blocked: mv would remove source '${source}' from outside the workspace root (${root}). File mutations must stay within the workspace.`;
707
+ return `Blocked: mv would remove source '${source}' from outside the workspace root (${workspaceRoot}). File mutations must stay within the workspace.`;
425
708
  }
426
709
  }
427
710
  }
428
- const simpleMutations = simpleFilesystemMutations(segment);
429
- const teeTargets = teeTargetsIn(segment);
711
+ const simpleMutations = simpleFilesystemMutations(cleanSegment);
712
+ const teeTargets = teeTargetsIn(cleanSegment);
430
713
  const teeMutations: ShellMutation[] = teeTargets.map((target) => ({
431
714
  kind: "command" as const,
432
715
  target,
433
716
  what: `tee to '${target}'`,
434
717
  }));
435
- const redirectMutations = redirectMutationsIn(segment.trim());
718
+ const redirectMutations = redirectMutationsIn(cleanSegment.trim());
436
719
  const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
437
720
  if (mutations.length === 0) {
438
- const fallbackMutation = shellMutationIn(segment.trim());
721
+ const fallbackMutation = shellMutationIn(cleanSegment.trim());
439
722
  if (fallbackMutation) mutations.push(fallbackMutation);
440
723
  }
441
724
  for (const mutation of mutations) {
442
725
  hasMutation = true;
443
- const secret = secretIn(segment);
726
+ const secret = secretIn(cleanSegment);
444
727
  if (secret) {
445
728
  return `Blocked: shell file mutation payload appears to contain a ${secret}. Secrets must not be written to disk from agent commands.`;
446
729
  }
@@ -448,19 +731,19 @@ export async function guardShellMutation(
448
731
  if (target && isProtectedPath(target)) {
449
732
  return PROTECTED_PATH_REASON;
450
733
  }
451
- if (target && isPathOutsideWorkspace(target, root)) {
734
+ if (target && outsideWorkspace(target)) {
452
735
  // The workspace boundary has no override: a write outside the
453
736
  // workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
454
737
  // (that override covers live-system commands, not the boundary).
455
738
  if (hasUnresolvableVariable(target)) {
456
- return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
739
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal workspace-relative path.`;
457
740
  }
458
- if (isEnvironmentScratchTarget(target, root)) {
459
- return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${root}) instead (for example .tmp-notes.md).`;
741
+ if (isEnvironmentScratchTarget(target, runRoot)) {
742
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${workspaceRoot}) instead (for example .tmp-notes.md).`;
460
743
  }
461
- return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${root}). All changes must stay within the workspace.`;
744
+ return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
462
745
  }
463
- if (onProtectedBranch(root)) {
746
+ if (onProtectedBranch(runRoot)) {
464
747
  return branchGuardReason();
465
748
  }
466
749
  const todos = await effectiveTodos(sessionID);
@@ -9,6 +9,8 @@ import {
9
9
  import {
10
10
  unwrapShellWords,
11
11
  splitShellSegments,
12
+ shellPrefixAssignments,
13
+ resolveGitRepoSelector,
12
14
  } from "../lib/shell.ts";
13
15
 
14
16
  export const PROTECTED_BRANCHES = new Set(["main", "master"]);
@@ -45,6 +47,10 @@ const GIT_DIR_OPTION_TAKES_VALUE = new Set([
45
47
  ]);
46
48
  const GIT_DIR_OPTION_PREFIXED =
47
49
  /^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
50
+ // Git also accepts the attached short forms (`-C/path`, `-C~`). An unparsed
51
+ // `-C` leaves the repository at the assumed base, which judges the write in the
52
+ // wrong checkout, so the attached form is read as a claim like the spaced one.
53
+ const GIT_DIR_SHORT_ATTACHED = /^-C(.+)$/;
48
54
  const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
49
55
  "--version",
50
56
  "--help",
@@ -60,44 +66,88 @@ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
60
66
  "--exec-path",
61
67
  ]);
62
68
 
63
- const GIT_ENV_DIR_RE =
64
- /(?:^|\s)(?:env\s+(?:-\S+\s+)*)?(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(?:"([^"]*)"|'([^']*)'|(\S+))/;
69
+ // Repository selection from the environment. It is read from the assignments
70
+ // that precede the command word, never from the rest of the command text. A
71
+ // bare `GIT_DIR=` used to be matched anywhere in the string, so an unquoted
72
+ // commit message such as `git commit -m GIT_DIR=/repo` claimed an explicit
73
+ // repository. That was harmless while the value only picked a base directory,
74
+ // but `explicitRepoDir` is now a fact the permission seat trusts, so the claim
75
+ // must be real. A `-c` config override is deliberately not a repository
76
+ // selector: `GIT_DIR` is an environment variable, not a git config key.
77
+ const GIT_REPO_ENV_ASSIGNMENT = /^(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(.*)$/s;
65
78
 
66
- export function parseGitInvocation(command: string): GitInvocation | undefined {
67
- const root = getWorkspaceRoot();
79
+ /**
80
+ * The repository an environment assignment selects, or undefined.
81
+ *
82
+ * Only assignments that the shell unwrapper actually consumed before the
83
+ * command word count. A value that merely appears inside an argument cannot be
84
+ * mistaken for one.
85
+ */
86
+ function repoEnvironmentOverride(command: string): string | undefined {
87
+ for (const assignment of shellPrefixAssignments(command)) {
88
+ const match = GIT_REPO_ENV_ASSIGNMENT.exec(assignment);
89
+ if (match?.[1]) return match[1];
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * Parse one git invocation.
96
+ *
97
+ * `baseDir` is the directory the command actually runs in: a shell call's
98
+ * `workdir`, resolved against the call's workspace by the caller. It defaults
99
+ * to the ambient workspace root for callers that only classify command text.
100
+ * Without it, `git push` inside a linked worktree was resolved against the
101
+ * session-bound checkout, so every branch-state check judged the wrong tree.
102
+ *
103
+ * `explicitRepoDir` records whether the command text named the repository
104
+ * itself. A caller that cannot see `workdir` uses that flag to tell a fact
105
+ * from an assumption.
106
+ */
107
+ export function parseGitInvocation(command: string, baseDir?: string): GitInvocation | undefined {
108
+ const root = baseDir ?? getWorkspaceRoot();
68
109
  const tokens = unwrapShellWords(command);
69
110
  if (tokens[0] !== "git") return undefined;
70
111
  let i = 1;
71
112
  let repoDir = root;
72
113
  let sawDirOption = false;
73
114
 
74
- const envDirMatch = command.match(GIT_ENV_DIR_RE);
75
- if (envDirMatch) {
76
- const envVal = envDirMatch[1] ?? envDirMatch[2] ?? envDirMatch[3];
77
- if (envVal) {
78
- repoDir = resolve(root, envVal);
79
- sawDirOption = true;
80
- }
115
+ const envDirValue = repoEnvironmentOverride(command);
116
+ if (envDirValue) {
117
+ repoDir = resolveGitRepoSelector(root, envDirValue);
118
+ sawDirOption = true;
81
119
  }
82
120
 
83
121
  while (i < tokens.length) {
84
122
  const tok = tokens[i]!;
85
123
  if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
86
124
  const value = tokens[i + 1];
87
- if (value === undefined) return { repoDir, rest: "" };
88
- if (tok === "-C" || tok === "--git-dir" || tok === "--work-tree") {
89
- repoDir = resolve(root, value);
125
+ const namesRepository = tok === "-C" || tok === "--git-dir" || tok === "--work-tree";
126
+ // A trailing `-C` with no value names no repository, but the flag is
127
+ // still present in the text. Report it as a claim so both seats agree
128
+ // the command is unclassifiable rather than one judging it and the
129
+ // other deferring it. `-c` and `--config-env` name no repository.
130
+ if (value === undefined) return { repoDir, rest: "", explicitRepoDir: sawDirOption || namesRepository };
131
+ if (namesRepository) {
132
+ repoDir = resolveGitRepoSelector(root, value);
90
133
  sawDirOption = true;
91
134
  }
92
135
  i += 2;
93
136
  continue;
94
137
  }
138
+ const attached = GIT_DIR_SHORT_ATTACHED.exec(tok);
139
+ if (attached) {
140
+ repoDir = resolveGitRepoSelector(root, attached[1]!);
141
+ sawDirOption = true;
142
+ i += 1;
143
+ continue;
144
+ }
95
145
  if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
96
146
  if (tok.startsWith("--git-dir=")) {
97
- repoDir = resolve(root, tok.slice("--git-dir=".length));
147
+ repoDir = resolveGitRepoSelector(root, tok.slice("--git-dir=".length));
98
148
  sawDirOption = true;
99
149
  } else if (tok.startsWith("--work-tree=")) {
100
- repoDir = resolve(root, tok.slice("--work-tree=".length));
150
+ repoDir = resolveGitRepoSelector(root, tok.slice("--work-tree=".length));
101
151
  sawDirOption = true;
102
152
  }
103
153
  i += 1;
@@ -109,8 +159,7 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
109
159
  }
110
160
  break;
111
161
  }
112
- if (!sawDirOption) repoDir = root;
113
- return { repoDir, rest: tokens.slice(i).join(" ") };
162
+ return { repoDir, rest: tokens.slice(i).join(" "), explicitRepoDir: sawDirOption };
114
163
  }
115
164
 
116
165
  export function normalizeGitCommands(command: string): string {
@@ -125,6 +174,17 @@ export function normalizeGitCommands(command: string): string {
125
174
  export const GIT_WRITE_RE =
126
175
  /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
127
176
 
177
+ /**
178
+ * True when `text` invokes git in a way that changes a tree's refs or content.
179
+ *
180
+ * One predicate for every caller: the shell segment walk, the nested payload
181
+ * check, and the foreign-workdir gate. Keeping copies is how the payload check
182
+ * drifted wider than the segment check once already.
183
+ */
184
+ export function isGitTreeWriteText(text: string): boolean {
185
+ return GIT_WRITE_RE.test(text) || GIT_BRANCH_CREATE_RE.test(text) || /\bgit\s+push\b/.test(text);
186
+ }
187
+
128
188
  export function currentGitBranch(root: string): string | undefined {
129
189
  const result = spawnSync("git", ["branch", "--show-current"], {
130
190
  cwd: root,
@@ -304,14 +364,76 @@ export function isBranchAlreadyMergedOrClosed(
304
364
  return { merged: false };
305
365
  }
306
366
 
307
- export function checkMergeConflicts(root: string): {
367
+ /**
368
+ * The source branch a PR-creation command names, if any.
369
+ *
370
+ * `gh` names it with `--head`/`-H`, and Azure DevOps with `--source-branch`.
371
+ * Fork forms carry an `owner:` prefix; a ref cannot contain `:`, so the last
372
+ * segment is the local branch name the caller looks up. Callers fall back to
373
+ * the invoking checkout when the branch is not checked out anywhere locally.
374
+ */
375
+ export function prSourceBranch(command: string): string | undefined {
376
+ const words = unwrapShellWords(command);
377
+ let source: string | undefined;
378
+ for (let i = 0; i < words.length; i++) {
379
+ const word = words[i]!;
380
+ let raw: string | undefined;
381
+ if (word === "--head" || word === "-H" || word === "--source-branch") {
382
+ raw = words[i + 1];
383
+ } else if (/^--(?:head|source-branch)=/.test(word)) {
384
+ raw = word.slice(word.indexOf("=") + 1);
385
+ } else if (/^-H\S/.test(word)) {
386
+ raw = word.slice(2);
387
+ }
388
+ if (raw === undefined) continue;
389
+ const branch = raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw;
390
+ if (branch) source = branch;
391
+ }
392
+ return source;
393
+ }
394
+
395
+ /**
396
+ * Which checkout of this repository has `branch` checked out, if any.
397
+ *
398
+ * A PR is evaluated against the tree the branch actually lives in, not against
399
+ * whatever the caller's checkout happens to hold. Linked worktrees are the
400
+ * normal case: a session may sit in the primary checkout while the reviewed
401
+ * branch is checked out in an isolated worktree.
402
+ */
403
+ export function findWorktreeForBranch(root: string, branch: string): string | undefined {
404
+ const target = branch.replace(/^refs\/heads\//, "").trim();
405
+ if (!target) return undefined;
406
+ const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
407
+ cwd: root,
408
+ encoding: "utf8",
409
+ timeout: 10_000,
410
+ });
411
+ if (res.status !== 0) return undefined;
412
+ let current: string | undefined;
413
+ for (const line of res.stdout.split("\n")) {
414
+ if (line.startsWith("worktree ")) {
415
+ current = line.slice("worktree ".length).trim();
416
+ continue;
417
+ }
418
+ if (line.startsWith("branch ") && current) {
419
+ if (line.slice("branch ".length).trim().replace(/^refs\/heads\//, "") === target) return current;
420
+ }
421
+ }
422
+ return undefined;
423
+ }
424
+
425
+ export function checkMergeConflicts(root: string, ref?: string): {
308
426
  hasConflicts: boolean;
309
427
  baseBranch?: string;
310
428
  reason?: string;
311
429
  } {
430
+ // A branch that is not checked out anywhere still has to be judged; the
431
+ // caller passes its ref, and HEAD stands in only when no ref was named.
432
+ const subject = typeof ref === "string" && ref.trim() ? ref.trim() : "HEAD";
433
+ const subjectLabel = subject === "HEAD" ? "" : `Branch '${subject}'`;
312
434
  const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
313
435
  for (const base of candidates) {
314
- const mergeBaseRes = spawnSync("git", ["merge-base", "HEAD", base], {
436
+ const mergeBaseRes = spawnSync("git", ["merge-base", subject, base], {
315
437
  cwd: root,
316
438
  encoding: "utf8",
317
439
  timeout: 5_000,
@@ -319,7 +441,7 @@ export function checkMergeConflicts(root: string): {
319
441
  if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
320
442
  const mergeBase = mergeBaseRes.stdout.trim();
321
443
 
322
- const treeRes = spawnSync("git", ["merge-tree", mergeBase, "HEAD", base], {
444
+ const treeRes = spawnSync("git", ["merge-tree", mergeBase, subject, base], {
323
445
  cwd: root,
324
446
  encoding: "utf8",
325
447
  timeout: 10_000,
@@ -328,7 +450,7 @@ export function checkMergeConflicts(root: string): {
328
450
  return {
329
451
  hasConflicts: true,
330
452
  baseBranch: base,
331
- reason: `Branch has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
453
+ reason: `${subjectLabel || "Branch"} has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
332
454
  };
333
455
  }
334
456
  }