opencode-workflow-guard 1.19.0 → 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.
package/src/lib/state.ts CHANGED
@@ -81,6 +81,10 @@ export function clearTodoSessionState(sessionID: string): void {
81
81
  // instance must never outrank the call's own context or the host fallback.
82
82
  const sessionWorkspaceBound = new Set<string>();
83
83
 
84
+ // Last directory the HOST named per session, so a repeated `session.updated`
85
+ // costs one string comparison instead of a `git rev-parse` child process.
86
+ const sessionHostDirectories = new Map<string, string>();
87
+
84
88
  export function setSessionWorkspace(sessionID: string, workspace: string): void {
85
89
  if (!sessionID || !workspace) return;
86
90
  const gitRoot = findGitRoot(workspace);
@@ -88,6 +92,18 @@ export function setSessionWorkspace(sessionID: string, workspace: string): void
88
92
  sessionWorkspaceBound.add(sessionID);
89
93
  }
90
94
 
95
+ /**
96
+ * Drop a deleted session's workspace binding and host-directory cache entry.
97
+ *
98
+ * Both are keyed by session id, so a deleted session can never read them again.
99
+ * Leaving them would grow both maps for the life of a long-lived server.
100
+ */
101
+ export function forgetSessionWorkspace(sessionID: string): void {
102
+ sessionWorkspaces.delete(sessionID);
103
+ sessionWorkspaceBound.delete(sessionID);
104
+ sessionHostDirectories.delete(sessionID);
105
+ }
106
+
91
107
  export function getSessionWorkspace(sessionID?: string): string | undefined {
92
108
  if (!sessionID) return undefined;
93
109
  return sessionWorkspaces.get(sessionID);
@@ -236,11 +252,74 @@ function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: s
236
252
  tool: "workspace.binding",
237
253
  decision: "allow",
238
254
  phase: "event",
239
- reason: "workspace_rebind_host_worktree",
255
+ reason: source === "session-move" ? "workspace_rebind_session_move" : "workspace_rebind_host_worktree",
240
256
  input: { from, to, source },
241
257
  });
242
258
  }
243
259
 
260
+ /**
261
+ * Rebind a session to the workspace the HOST says it now occupies.
262
+ *
263
+ * `session_move` (and the equivalent host move command) relocates a session
264
+ * between checkouts. Without this the binding stayed on the old checkout, so
265
+ * every later boundary, branch, and PR decision judged the tree the session had
266
+ * left: the move looked like it had no effect. A host move is an explicit
267
+ * boundary statement from the host, in the same class as a per-call worktree,
268
+ * so it may widen an already-bound session. It is journaled for that reason.
269
+ *
270
+ * The hard part is telling a move from a routine update. `session.updated` is a
271
+ * hot event, emitted for title and cost changes, and it names the session's
272
+ * creation directory. A session bound to a LINKED WORKTREE by its own tool-call
273
+ * `workdir` legitimately differs from that directory, so treating every update
274
+ * as a move pulled the session back to the checkout it had deliberately left.
275
+ * Three signals therefore count, and nothing else does:
276
+ *
277
+ * - The host announced the relocation itself (`moved: true`, from
278
+ * `session.moved`).
279
+ * - The directory the host names now differs from the one it named last for
280
+ * this session.
281
+ * - The named repository is a DIFFERENT repository from the bound workspace. A
282
+ * linked worktree is the same repository, so this catches the cross-repository
283
+ * move that a first observation would otherwise miss on OpenCode 1.x, which
284
+ * has no move event.
285
+ *
286
+ * The last directory is remembered so a repeated update costs one string
287
+ * comparison instead of a `git rev-parse` child process. The cache stores a
288
+ * directory that actually resolved to a repository root: an unresolved
289
+ * directory is not cached, so a later event for the same path retries instead
290
+ * of being suppressed forever.
291
+ */
292
+ export function rebindSessionWorkspaceFromHost(
293
+ sessionID: string | undefined,
294
+ directory: string | undefined,
295
+ options: { moved?: boolean } = {},
296
+ ): void {
297
+ if (!sessionID || !directory) return;
298
+ const gitRoot = findGitRoot(directory);
299
+ if (!gitRoot) return;
300
+ const previousHostDirectory = sessionHostDirectories.get(sessionID);
301
+ sessionHostDirectories.set(sessionID, directory);
302
+ const previous = sessionWorkspaces.get(sessionID);
303
+ const moved =
304
+ options.moved === true
305
+ || (previousHostDirectory !== undefined && previousHostDirectory !== directory)
306
+ || (previous !== undefined && gitRoot !== previous && !isSameGitRepo(gitRoot, previous));
307
+ if (!moved) {
308
+ if (sessionWorkspaceBound.has(sessionID)) return;
309
+ // A bootstrap entry that was never bound still needs binding: the host
310
+ // just named the session's real directory.
311
+ sessionWorkspaces.set(sessionID, gitRoot);
312
+ sessionWorkspaceBound.add(sessionID);
313
+ return;
314
+ }
315
+ if (previous === gitRoot) {
316
+ sessionWorkspaceBound.add(sessionID);
317
+ return;
318
+ }
319
+ setSessionWorkspace(sessionID, gitRoot);
320
+ auditWorkspaceRebind(sessionID, previous, gitRoot, "session-move");
321
+ }
322
+
244
323
  /**
245
324
  * Durable record of a refused widening. The refusal itself is the boundary
246
325
  * policy's decision; this entry exists so an operator can tell whether a
package/src/lib/types.ts CHANGED
@@ -247,6 +247,13 @@ export interface AuditEntry {
247
247
  export interface GitInvocation {
248
248
  repoDir: string;
249
249
  rest: string;
250
+ /**
251
+ * True when the command text itself names the repository (`-C`,
252
+ * `--git-dir`, `--work-tree`, `GIT_DIR=`). False means `repoDir` is only
253
+ * the invocation's assumed base directory, which is the shell's working
254
+ * directory and not a fact the command string carries.
255
+ */
256
+ explicitRepoDir?: boolean;
250
257
  }
251
258
 
252
259
  export interface ShellMutation {
@@ -28,6 +28,8 @@ import {
28
28
  setWorkspaceRoot,
29
29
  setSdkClient,
30
30
  getSessionBoundWorkspace,
31
+ rebindSessionWorkspaceFromHost,
32
+ forgetSessionWorkspace,
31
33
  registerSessionWorkspaceFromToolCall,
32
34
  runWithRuntimeState,
33
35
  getSessionMutationCount,
@@ -841,6 +843,23 @@ async function handleV2Event(
841
843
  return;
842
844
  }
843
845
 
846
+ if (event.type === "session.moved") {
847
+ // `session_move` relocates a session between checkouts, and the host
848
+ // announces it. On the pinned 2.2.0 SDK the V2 union carries
849
+ // `session.moved` and no `session.updated`, and the move event itself
850
+ // names the destination: `data.sessionID` plus
851
+ // `data.location.directory`. Without this rebind the session kept
852
+ // judging the tree it left.
853
+ const data = event.data as {
854
+ sessionID?: unknown;
855
+ location?: { directory?: unknown };
856
+ } | undefined;
857
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
858
+ const directory = typeof data?.location?.directory === "string" ? data.location.directory : undefined;
859
+ rebindSessionWorkspaceFromHost(sessionID, directory, { moved: true });
860
+ return;
861
+ }
862
+
844
863
  if (event.type === "session.idle") {
845
864
  const data = event.data as { sessionID?: unknown } | undefined;
846
865
  const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
@@ -870,6 +889,7 @@ async function handleV2Event(
870
889
  clearTodoSessionState(sessionID);
871
890
  toolLifecycle.takeRecoveryRun(sessionID);
872
891
  await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
892
+ forgetSessionWorkspace(sessionID);
873
893
  }
874
894
  return;
875
895
  }
@@ -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);