opencode-workflow-guard 1.15.1 → 1.15.3

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
@@ -1,5 +1,5 @@
1
- import { realpathSync, existsSync, unlinkSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, unlinkSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
5
  import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
@@ -13,36 +13,21 @@ import type {
13
13
  } from "./types.ts";
14
14
 
15
15
  export let workspaceRoot = process.cwd();
16
- export let workspaceRootReal = workspaceRoot;
17
16
  interface RuntimeState {
18
17
  workspaceRoot: string;
19
- workspaceRootReal: string;
20
18
  sdkClient: TodoSdkClient | undefined;
21
19
  projectConfig: ProjectConfig;
22
20
  }
23
21
  const runtimeState = new AsyncLocalStorage<RuntimeState>();
24
22
 
25
- try {
26
- workspaceRootReal = realpathSync(workspaceRoot);
27
- } catch {}
28
-
29
23
  export function setWorkspaceRoot(root: string): void {
30
24
  workspaceRoot = root;
31
- try {
32
- workspaceRootReal = realpathSync(root);
33
- } catch {
34
- workspaceRootReal = root;
35
- }
36
25
  }
37
26
 
38
27
  export function getWorkspaceRoot(): string {
39
28
  return runtimeState.getStore()?.workspaceRoot ?? workspaceRoot;
40
29
  }
41
30
 
42
- export function getWorkspaceRootReal(): string {
43
- return runtimeState.getStore()?.workspaceRootReal ?? workspaceRootReal;
44
- }
45
-
46
31
  export let sdkClient: TodoSdkClient | undefined;
47
32
 
48
33
  export function setSdkClient(client: unknown): void {
@@ -54,14 +39,9 @@ export function getSdkClient(): TodoSdkClient | undefined {
54
39
  }
55
40
 
56
41
  export function runWithRuntimeState<T>(root: string, client: unknown, fn: () => T): T {
57
- let realRoot = root;
58
- try {
59
- realRoot = realpathSync(root);
60
- } catch {}
61
42
  return runtimeState.run(
62
43
  {
63
44
  workspaceRoot: root,
64
- workspaceRootReal: realRoot,
65
45
  sdkClient: client as TodoSdkClient | undefined,
66
46
  projectConfig: loadProjectConfig(root),
67
47
  },
@@ -76,11 +56,18 @@ const workspaceMutationCounts = new Map<string, number>();
76
56
  export const sessionMutationTimestamps = new Map<string, number>();
77
57
  export const sessionMutationCounts = new Map<string, number>();
78
58
  export const sessionWorkspaces = new Map<string, string>();
59
+ // Sessions whose workspace was registered from their OWN context (tool-call
60
+ // arguments or a host-provided per-call worktree). A session registered only
61
+ // from the plugin-instance default is deliberately left unmarked: concurrent
62
+ // loops share this process, and a value derived from the last-active plugin
63
+ // instance must never outrank the call's own context or the host fallback.
64
+ const sessionWorkspaceBound = new Set<string>();
79
65
 
80
66
  export function setSessionWorkspace(sessionID: string, workspace: string): void {
81
67
  if (!sessionID || !workspace) return;
82
68
  const gitRoot = findGitRoot(workspace);
83
69
  sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(workspace));
70
+ sessionWorkspaceBound.add(sessionID);
84
71
  }
85
72
 
86
73
  export function getSessionWorkspace(sessionID?: string): string | undefined {
@@ -88,6 +75,75 @@ export function getSessionWorkspace(sessionID?: string): string | undefined {
88
75
  return sessionWorkspaces.get(sessionID);
89
76
  }
90
77
 
78
+ /**
79
+ * The session's workspace only when it was registered from the session's own
80
+ * context (tool-call arguments or a host-provided per-call worktree), never a
81
+ * plugin-instance-default bootstrap. Policy decisions that bind evidence or
82
+ * confine boundaries must consult this variant: two concurrent loops share one
83
+ * guard process, and an unbound bootstrap entry may have been derived from
84
+ * another session's plugin instance.
85
+ */
86
+ export function getSessionBoundWorkspace(sessionID?: string): string | undefined {
87
+ if (!sessionID) return undefined;
88
+ return sessionWorkspaceBound.has(sessionID) ? sessionWorkspaces.get(sessionID) : undefined;
89
+ }
90
+
91
+ /**
92
+ * Register a session's workspace from a tool call's own context. Candidates in
93
+ * priority order: tool-argument paths (workdir/filePath — the call's own
94
+ * target), then a host-provided per-call worktree/directory. A session that is
95
+ * already registered is never re-anchored from the plugin-instance fallback:
96
+ * under concurrent loops that fallback can belong to a different session's
97
+ * workspace, which is exactly how workspaces cross-contaminate. Only a
98
+ * session with no registration at all is bootstrapped from the fallback, and
99
+ * such a bootstrap stays unbound (never used to bind review evidence).
100
+ */
101
+ export function registerSessionWorkspaceFromToolCall(
102
+ sessionID: string | undefined,
103
+ call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string },
104
+ ): void {
105
+ if (!sessionID) return;
106
+ const rawCandidate = call.workdir ?? call.filePath ?? call.hostWorktree;
107
+ if (rawCandidate) {
108
+ const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
109
+ const gitRoot = findGitRoot(candidatePath);
110
+ if (gitRoot) {
111
+ sessionWorkspaces.set(sessionID, gitRoot);
112
+ sessionWorkspaceBound.add(sessionID);
113
+ }
114
+ return;
115
+ }
116
+ if (sessionWorkspaces.has(sessionID)) return;
117
+ const gitRoot = findGitRoot(call.toolWorktree);
118
+ sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
119
+ }
120
+
121
+ /**
122
+ * The session's workspace resolved from its own bindings only: a directly
123
+ * registered bound workspace, else the nearest bound parent workspace
124
+ * (subagent sessions inherit their parent's binding for relay flows). Returns
125
+ * undefined when neither exists — callers that must not guess (review verdict
126
+ * binding) treat that as "resolve nothing, ask for an explicit directory".
127
+ */
128
+ export async function resolveSessionBoundWorkspace(sessionID?: string): Promise<string | undefined> {
129
+ if (!sessionID) return undefined;
130
+ const direct = getSessionBoundWorkspace(sessionID);
131
+ if (direct) return direct;
132
+ try {
133
+ const client = getSdkClient();
134
+ const session = client?.session;
135
+ const get = session?.get;
136
+ if (typeof get === "function") {
137
+ const result = await get.call(session, { path: { id: sessionID } });
138
+ const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
139
+ if (typeof parent === "string" && parent && sessionWorkspaceBound.has(parent)) {
140
+ return sessionWorkspaces.get(parent);
141
+ }
142
+ }
143
+ } catch {}
144
+ return undefined;
145
+ }
146
+
91
147
  export let lastVerify: VerifyResult | undefined;
92
148
  export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>();
93
149
 
@@ -228,10 +284,28 @@ export function getLastReviewResult(): typeof lastReview {
228
284
 
229
285
  export function getLastReviewResultForWorkspace(root: string): typeof lastReview {
230
286
  const workspace = projectRootKey(root);
231
- const candidates = [lastReview, ...sessionReviews.values()].filter(
232
- (result): result is ReviewResult => result?.workspace != null && (projectRootKey(result.workspace) === workspace || isSameGitRepo(result.workspace, root)),
287
+ const all = [lastReview, ...sessionReviews.values()].filter(
288
+ (result): result is ReviewResult & { workspace: string } => result?.workspace != null,
233
289
  );
234
- const latest = candidates.reduce<ReviewResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
290
+ // Exact worktree binding wins: concurrent loops sharing one repository
291
+ // (different worktrees) must consume their own approvals independently —
292
+ // a newer sibling-worktree review must never displace this worktree's
293
+ // between record_review and PR creation.
294
+ const exact = all.filter((result) => projectRootKey(result.workspace) === workspace);
295
+ let preferred = exact;
296
+ if (preferred.length === 0) {
297
+ const sameRepo = all.filter((result) => isSameGitRepo(result.workspace, root));
298
+ if (sameRepo.length > 0) {
299
+ // Among same-repo candidates, prefer the review whose tracked
300
+ // content matches THIS worktree (the PR preflight consumes the
301
+ // approval bound to the tree it would publish); recency only
302
+ // breaks ties.
303
+ const prFingerprint = getTrackedWorktreeFingerprint(root);
304
+ const contentMatched = prFingerprint ? sameRepo.filter((result) => result.worktreeFingerprint === prFingerprint) : [];
305
+ preferred = contentMatched.length > 0 ? contentMatched : sameRepo;
306
+ }
307
+ }
308
+ const latest = preferred.reduce<ReviewResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
235
309
  if (latest) return latest;
236
310
  const diskCached = loadReviewCache();
237
311
  if (diskCached?.workspace && (projectRootKey(diskCached.workspace) === workspace || isSameGitRepo(diskCached.workspace, root))) {
@@ -254,41 +328,36 @@ export async function resolveEffectiveWorkspace(options: {
254
328
  directory?: string;
255
329
  fallback?: string;
256
330
  }): Promise<string> {
331
+ // Intended precedence. Custom tools (resolveCallWorkspace) consult the
332
+ // explicit argument and the host per-call context BEFORE calling this, so
333
+ // inside this function the remaining order is: bound session workspace >
334
+ // host fallback > unbound bootstrap hint > global latch/cwd. A future
335
+ // direct caller passing a per-call `fallback` should apply the same
336
+ // host-context-first step that resolveCallWorkspace does.
257
337
  if (options.directory) {
258
338
  const gitRoot = findGitRoot(options.directory);
259
339
  return gitRoot ?? projectRootKey(options.directory);
260
340
  }
341
+ // 1. The session's own binding (direct, or inherited from a bound parent
342
+ // for relay flows) wins over every ambient fallback. Concurrent loops
343
+ // share one guard process; a fallback derived from the last-active
344
+ // plugin instance must never re-anchor another session's context.
345
+ const bound = await resolveSessionBoundWorkspace(options.sessionID);
346
+ if (bound) return bound;
261
347
  const fallbackRoot = options.fallback ? projectRootKey(options.fallback) : undefined;
262
348
  const isFallbackFsRoot = fallbackRoot ? resolve(fallbackRoot) === resolve(fallbackRoot, "..") : true;
263
349
 
350
+ // 2. The call's own host-provided per-call worktree/directory.
264
351
  if (fallbackRoot && !isFallbackFsRoot) {
265
- if (options.sessionID) {
266
- const sessionWs = sessionWorkspaces.get(options.sessionID);
267
- if (sessionWs && isSameGitRepo(sessionWs, fallbackRoot)) {
268
- return sessionWs;
269
- }
270
- }
271
352
  return fallbackRoot;
272
353
  }
273
354
 
274
- if (options.sessionID) {
275
- const sessionWs = sessionWorkspaces.get(options.sessionID);
276
- if (sessionWs) return sessionWs;
277
- try {
278
- const client = getSdkClient();
279
- const session = client?.session;
280
- const get = session?.get;
281
- if (typeof get === "function") {
282
- const result = await get.call(session, { path: { id: options.sessionID } });
283
- const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
284
- if (typeof parent === "string" && parent && sessionWorkspaces.has(parent)) {
285
- const parentWs = sessionWorkspaces.get(parent)!;
286
- sessionWorkspaces.set(options.sessionID, parentWs);
287
- return parentWs;
288
- }
289
- }
290
- } catch {}
291
- }
355
+ // 3. Unbound bootstrap hint: the session was registered only from the
356
+ // plugin-instance default, so it carries no per-session authority and
357
+ // loses to any real fallback (step 2). It is still better than the raw
358
+ // global latch when the fallback is absent or the filesystem root.
359
+ const hintRoot = options.sessionID ? sessionWorkspaces.get(options.sessionID) : undefined;
360
+ if (hintRoot) return hintRoot;
292
361
  const activeRoot = getWorkspaceRoot();
293
362
  if (activeRoot && resolve(activeRoot) !== resolve(activeRoot, "..")) {
294
363
  const gitRoot = findGitRoot(activeRoot);
@@ -7,6 +7,9 @@ function invocationKey(sessionID: string, callID: string): string {
7
7
 
8
8
  export class ToolInvocationLifecycle {
9
9
  private readonly postEditSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
10
+ // Shell-redirect pre-state snapshots: they seed freshness observations
11
+ // only and must never run the post-edit validators.
12
+ private readonly shellWriteSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
10
13
  private readonly startedAt = new Map<string, number>();
11
14
  private readonly readObservations = new Map<string, ReadObservation>();
12
15
  private readonly recoveryRuns = new Map<string, number>();
@@ -44,6 +47,17 @@ export class ToolInvocationLifecycle {
44
47
  return snapshots;
45
48
  }
46
49
 
50
+ setShellWriteSnapshots(sessionID: string, callID: string, root: string, snapshots: FileSnapshot[]): void {
51
+ this.shellWriteSnapshots.set(invocationKey(sessionID, callID), { root, snapshots });
52
+ }
53
+
54
+ takeShellWriteSnapshots(sessionID: string, callID: string): { root: string; snapshots: FileSnapshot[] } | undefined {
55
+ const key = invocationKey(sessionID, callID);
56
+ const snapshots = this.shellWriteSnapshots.get(key);
57
+ this.shellWriteSnapshots.delete(key);
58
+ return snapshots;
59
+ }
60
+
47
61
  setRecoveryRun(sessionID: string, run: number): void {
48
62
  this.recoveryRuns.set(sessionID, run);
49
63
  }
@@ -56,7 +70,7 @@ export class ToolInvocationLifecycle {
56
70
 
57
71
  clearSession(sessionID: string): void {
58
72
  const prefix = `${sessionID}\0`;
59
- for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots]) {
73
+ for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots, this.shellWriteSnapshots]) {
60
74
  for (const key of map.keys()) {
61
75
  if (key.startsWith(prefix)) map.delete(key);
62
76
  }
@@ -25,8 +25,8 @@ import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
25
25
  import {
26
26
  setWorkspaceRoot,
27
27
  setSdkClient,
28
- getSessionWorkspace,
29
- setSessionWorkspace,
28
+ getSessionBoundWorkspace,
29
+ registerSessionWorkspaceFromToolCall,
30
30
  runWithRuntimeState,
31
31
  getSessionMutationCount,
32
32
  sessionVerifyResults,
@@ -48,7 +48,7 @@ import {
48
48
  } from "./project-memory.ts";
49
49
  import { secretIn } from "../policies/secrets.ts";
50
50
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
51
- import { isReadOnlyRole } from "./guard-dispatcher.ts";
51
+ import { isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
52
52
  import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
53
53
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
54
54
  import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
@@ -56,7 +56,9 @@ import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
56
56
  import { checkCompletionClaims } from "../policies/completion.ts";
57
57
  import { releaseFileClaims } from "../policies/file-claims.ts";
58
58
  import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
59
- import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
59
+ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "../policies/post-edit-validation.ts";
60
+ import { extractCommands, splitShellSegments } from "./shell.ts";
61
+ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "../policies/boundary.ts";
60
62
  import { audit, summarizeInput } from "./audit.ts";
61
63
  import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
62
64
  import { loadedPluginVersion } from "./version.ts";
@@ -193,10 +195,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
193
195
  // wrap it so the V2 registry receives a real StandardSchemaV1.
194
196
  input: z.object(definition.args as unknown as z.ZodRawShape),
195
197
  execute: async (rawInput: unknown, toolContext: { sessionID: string; id: string }) => {
198
+ // Only a session-bound workspace counts as the tool's own
199
+ // context; an unbound entry is a plugin-instance bootstrap
200
+ // and must not be presented to the tools as one.
201
+ const boundWorktree = getSessionBoundWorkspace(toolContext.sessionID);
196
202
  const result = await (definition.execute as (input: unknown, context: unknown) => Promise<unknown>)(rawInput, {
197
203
  sessionID: toolContext.sessionID,
198
- worktree: getSessionWorkspace(toolContext.sessionID) ?? effectiveRoot,
199
- directory: effectiveRoot,
204
+ ...(boundWorktree ? { worktree: boundWorktree } : {}),
205
+ directory: boundWorktree ?? effectiveRoot,
200
206
  });
201
207
  const text = typeof result === "string" ? result : (result as { output?: string } | undefined)?.output;
202
208
  return {
@@ -218,7 +224,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
218
224
 
219
225
  // ── Deterministic enforcement ──
220
226
  await ctx.tool.hook("execute.before", (event) => {
221
- const toolWorktree = getSessionWorkspace(event.sessionID) || effectiveRoot;
227
+ const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
222
228
  return runWithRuntimeState(toolWorktree, client, async () => {
223
229
  const args = event.input;
224
230
  const reason = await guardToolCall(event.tool, args, {
@@ -239,11 +245,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
239
245
  const record = asRecord(args);
240
246
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
241
247
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
242
- const candidatePath = workdir ?? filePath ?? toolWorktree;
243
- if (candidatePath) {
244
- const gitRoot = findGitRoot(candidatePath);
245
- if (gitRoot) setSessionWorkspace(event.sessionID, gitRoot);
246
- }
248
+ registerSessionWorkspaceFromToolCall(event.sessionID, { workdir, filePath, toolWorktree });
247
249
  if (event.tool === "read") {
248
250
  const target = editTargets(args, toolWorktree)[0];
249
251
  if (target) {
@@ -255,6 +257,31 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
255
257
  const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
256
258
  if (snapshots.length) toolLifecycle.setPostEditSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
257
259
  }
260
+ if (SHELL_TOOL_NAMES.has(event.tool)) {
261
+ // An allowed shell redirect authors the target's bytes exactly
262
+ // like edit/write (LL-003), so pre-register each redirect
263
+ // target's pre-state and seed the session's freshness
264
+ // observation after the call succeeds. Observation only: shell
265
+ // writes never run the post-edit validators. (V1 parity.)
266
+ const shellWorkdir = typeof record?.workdir === "string" ? record.workdir : undefined;
267
+ const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
268
+ const snapshots: FileSnapshot[] = [];
269
+ for (const command of extractCommands(args)) {
270
+ for (const segment of splitShellSegments(command)) {
271
+ for (const mutation of redirectMutationsIn(segment)) {
272
+ const target = mutation.target ?? "";
273
+ if (!target || target === "/dev/null") continue;
274
+ const expanded = expandShellTargetPath(target);
275
+ if (!expanded) continue;
276
+ const resolvedTarget = resolve(shellCwd, expanded);
277
+ if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
278
+ if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
279
+ snapshots.push(snapshotFile(resolvedTarget));
280
+ }
281
+ }
282
+ }
283
+ if (snapshots.length) toolLifecycle.setShellWriteSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
284
+ }
258
285
  });
259
286
  });
260
287
 
@@ -279,6 +306,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
279
306
  if (observation) recordSuccessfulRead(observation, event.sessionID);
280
307
  }
281
308
  if (event.status === "error") return;
309
+ const shellPending = toolLifecycle.takeShellWriteSnapshots(event.sessionID, event.id);
310
+ if (shellPending) {
311
+ // Seed the session's observation of the bytes its shell redirect
312
+ // authored (LL-003); observation-only, validators are not run for
313
+ // shell writes, and the digest gate keeps no-op calls from
314
+ // seeding. (V1 parity.)
315
+ for (const before of shellPending.snapshots) recordMutationObservation(before.path, event.sessionID, before.digest);
316
+ }
282
317
  const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
283
318
  if (!pending) return;
284
319
  await runWithRuntimeState(pending.root, client, async () => {
@@ -4,7 +4,6 @@ import { dirname, join, resolve } from "node:path";
4
4
  import type { ShellMutation } from "../lib/types.ts";
5
5
  import {
6
6
  getWorkspaceRoot,
7
- getWorkspaceRootReal,
8
7
  recordMutation,
9
8
  } from "../lib/state.ts";
10
9
  import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
@@ -86,16 +85,32 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
86
85
  if (expanded === null) return true;
87
86
  const resolved = resolve(root, expanded);
88
87
  const normalizedRoot = root.endsWith("/") ? root : root + "/";
89
- if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
90
- return true;
91
- }
92
- const realRootVal = getWorkspaceRootReal();
88
+ // A path is outside only when BOTH the lexical resolution and the
89
+ // canonical resolution are outside. The lexical verdict is therefore
90
+ // held instead of returned early: symlink aliases of the workspace
91
+ // itself (e.g. /home -> /var/home) resolve lexically outside but
92
+ // canonically inside, and must be accepted, while the canonical checks
93
+ // below keep blocking symlinks inside the workspace that point outside.
94
+ // When canonicalization is indeterminate (no existing ancestor), the
95
+ // lexical verdict stands (fail-closed).
96
+ const lexicallyOutside = resolved !== root && !resolved.startsWith(normalizedRoot);
97
+ // The boundary is resolved against the workspace THIS call declares (the
98
+ // `root` argument), never against a process-global latched root: concurrent
99
+ // loops share one guard process, and a latched root must not redefine
100
+ // another session's boundary. Symlink-awareness is preserved (ancestor
101
+ // walk below) by canonicalizing the declared root itself.
102
+ let realRootVal = root;
103
+ try {
104
+ realRootVal = realpathSync(root);
105
+ } catch {}
106
+ let canonicallyInside = false;
93
107
  try {
94
108
  const real = realpathSync(resolved);
95
109
  const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";
96
110
  if (real !== realRootVal && !real.startsWith(realRoot)) {
97
111
  return true;
98
112
  }
113
+ canonicallyInside = true;
99
114
  } catch {
100
115
  let curr = resolved;
101
116
  while (curr && curr !== "/" && curr !== ".") {
@@ -108,11 +123,15 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
108
123
  if (realParent !== realRootVal && !realParent.startsWith(realRoot)) {
109
124
  return true;
110
125
  }
126
+ canonicallyInside = true;
111
127
  break;
112
128
  } catch {}
113
129
  }
114
130
  }
115
- return false;
131
+ // Relocated lexical verdict: reached only when canonicalization did not
132
+ // find the path outside, so a lexically-outside path that canonicalized
133
+ // inside (a symlink alias of the workspace) is no longer blocked here.
134
+ return lexicallyOutside && !canonicallyInside;
116
135
  }
117
136
 
118
137
  export function extractPatchPaths(patchText: string): string[] {
@@ -154,7 +173,7 @@ export function teeTargetsIn(segment: string): string[] {
154
173
  return targets;
155
174
  }
156
175
 
157
- function redirectMutationsIn(segment: string): ShellMutation[] {
176
+ export function redirectMutationsIn(segment: string): ShellMutation[] {
158
177
  const mutations: ShellMutation[] = [];
159
178
  // Redirect detection runs on the quote-stripped residue: quoted data
160
179
  // spans are command data and their ">" characters are not redirects,
@@ -193,16 +193,114 @@ export function prBodyHasLiteralLineBreakEscapes(command: string): boolean {
193
193
  return false;
194
194
  }
195
195
 
196
+ /**
197
+ * Top-level package.json sections whose change requires a matching lockfile
198
+ * update: the dependency maps (including peerDependenciesMeta) plus every
199
+ * other section a supported package manager records in its lockfile
200
+ * (engines/packageManager, overrides/resolutions, the pnpm section with
201
+ * pnpm.overrides and pnpm.patchedDependencies, the workspace graph, and the
202
+ * project name/version stored in the lockfile root). A scripts-only or
203
+ * metadata-only manifest edit regenerates the lockfile byte-identical —
204
+ * demanding a lockfile change for it is unsatisfiable and must not block PR
205
+ * creation.
206
+ */
207
+ const LOCKFILE_RELEVANT_MANIFEST_KEYS = new Set([
208
+ "name",
209
+ "version",
210
+ "dependencies",
211
+ "devDependencies",
212
+ "optionalDependencies",
213
+ "peerDependencies",
214
+ "peerDependenciesMeta",
215
+ "engines",
216
+ "packageManager",
217
+ "overrides",
218
+ "resolutions",
219
+ "workspaces",
220
+ // pnpm-specific top-level section: pnpm.overrides and
221
+ // pnpm.patchedDependencies are recorded in pnpm-lock.yaml.
222
+ "pnpm",
223
+ ]);
224
+
225
+ /** Order-insensitive JSON comparison: dependency maps are semantically
226
+ * unordered, so reordering keys alone is not a lockfile-relevant change. */
227
+ function canonicalJsonValue(value: unknown): string {
228
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
229
+ if (Array.isArray(value)) return `[${value.map(canonicalJsonValue).join(",")}]`;
230
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
231
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonValue(item)}`).join(",")}}`;
232
+ }
233
+
234
+ function manifestTouchesLockfileRelevantKeys(oldSource: string | undefined, newSource: string | undefined): boolean {
235
+ try {
236
+ const parse = (source: string | undefined): Record<string, unknown> | undefined => {
237
+ if (source === undefined) return undefined;
238
+ const parsed = JSON.parse(source) as unknown;
239
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a manifest object");
240
+ return parsed as Record<string, unknown>;
241
+ };
242
+ const oldManifest = parse(oldSource);
243
+ const newManifest = parse(newSource);
244
+ const keys = new Set([...Object.keys(oldManifest ?? {}), ...Object.keys(newManifest ?? {})]);
245
+ for (const key of keys) {
246
+ if (!LOCKFILE_RELEVANT_MANIFEST_KEYS.has(key)) continue;
247
+ if (canonicalJsonValue(oldManifest?.[key]) !== canonicalJsonValue(newManifest?.[key])) return true;
248
+ }
249
+ return false;
250
+ } catch {
251
+ return true; // unreadable manifest: fail closed and keep demanding the lockfile
252
+ }
253
+ }
254
+
255
+ function manifestSourceAt(root: string, revision: string, manifestPath: string): string | undefined {
256
+ const res = spawnSync("git", ["show", `${revision}:${manifestPath}`], { cwd: root, encoding: "utf8", timeout: 10_000 });
257
+ if (res.status !== 0) return undefined;
258
+ return res.stdout;
259
+ }
260
+
261
+ function workingTreeManifestSource(root: string, manifestPath: string): string | undefined {
262
+ try {
263
+ return readFileSync(resolve(root, manifestPath), "utf8");
264
+ } catch {
265
+ return undefined; // deleted from the working tree
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Whether a manifest change demands a lockfile update. package.json is
271
+ * compared section-by-section (scripts-only edits pass); Cargo.toml and
272
+ * go.mod have no dependency-section parser here, so any change to them
273
+ * conservatively demands the lockfile (unchanged behavior).
274
+ */
275
+ function manifestChangeRequiresLockfile(root: string, mergeBaseCommit: string | undefined, manifestPath: string): boolean {
276
+ if (!/(?:^|\/)package\.json$/.test(manifestPath)) return true;
277
+ let oldSource: string | undefined;
278
+ if (mergeBaseCommit) {
279
+ // Absent at the merge base means the manifest was introduced on this
280
+ // branch (or is untracked): the pre-branch state had no manifest, so
281
+ // every lockfile-relevant key it has counts as new.
282
+ oldSource = manifestSourceAt(root, mergeBaseCommit, manifestPath);
283
+ } else {
284
+ oldSource = manifestSourceAt(root, "HEAD", manifestPath);
285
+ }
286
+ const newSource = workingTreeManifestSource(root, manifestPath);
287
+ if (oldSource === undefined && newSource === undefined) return true;
288
+ return manifestTouchesLockfileRelevantKeys(oldSource, newSource);
289
+ }
290
+
196
291
  export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
197
292
  try {
198
293
  let modifiedFiles: string[] = [];
294
+ let mergeBaseCommit: string | undefined;
199
295
  const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
200
296
  for (const base of baseCandidates) {
201
297
  const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
202
298
  if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
203
- const diff = spawnSync("git", ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
299
+ const baseCommit = mergeBase.stdout.trim();
300
+ const diff = spawnSync("git", ["diff", "--name-only", `${baseCommit}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
204
301
  if (diff.status === 0 && diff.stdout.trim()) {
205
302
  modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
303
+ mergeBaseCommit = baseCommit;
206
304
  break;
207
305
  }
208
306
  }
@@ -236,16 +334,19 @@ export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifes
236
334
  ];
237
335
 
238
336
  for (const item of manifestToLockfiles) {
239
- const hasManifest = modifiedFiles.some((f) => item.manifest.test(f));
240
- if (hasManifest) {
241
- const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
242
- if (!hasLockfile) {
243
- return {
244
- isOutOfSync: true,
245
- manifest: item.name,
246
- reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
247
- };
248
- }
337
+ const manifestPaths = modifiedFiles.filter((f) => item.manifest.test(f));
338
+ if (manifestPaths.length === 0) continue;
339
+ const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
340
+ if (hasLockfile) continue;
341
+ // Only a lockfile-relevant manifest change demands the lockfile: a
342
+ // scripts-only edit regenerates it byte-identical, so demanding one
343
+ // is unsatisfiable.
344
+ if (manifestPaths.some((manifestPath) => manifestChangeRequiresLockfile(root, mergeBaseCommit, manifestPath))) {
345
+ return {
346
+ isOutOfSync: true,
347
+ manifest: item.name,
348
+ reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
349
+ };
249
350
  }
250
351
  }
251
352
  } catch {}
@@ -346,6 +346,24 @@ const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
346
346
  "--no-track",
347
347
  ]);
348
348
 
349
+ // Shell redirections never determine the branch start point. Exact tokens
350
+ // cover separated operators (`2> /dev/null`); the attached pattern covers
351
+ // operators fused with their target (`2>/dev/null`, `2>&1`, `&>/dev/null`).
352
+ // Valid refs cannot contain `<` or `>`, so no start point can be mistaken
353
+ // for a redirection token.
354
+ const BRANCH_REDIRECTION_OPERATORS = new Set([
355
+ "<",
356
+ ">",
357
+ "<<",
358
+ ">>",
359
+ "<<<",
360
+ "<&",
361
+ ">&",
362
+ "&>",
363
+ "&>>",
364
+ ]);
365
+ const BRANCH_REDIRECTION_ATTACHED_RE = /^(?:&|\d{1,2})?(?:>>|>|<<<|<<|>&|<&|<)[^<>]*$/;
366
+
349
367
  // Classifies the optional start-point operand of an already-matched branch
350
368
  // creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
351
369
  // the explicit start point when present, `{ start: undefined }` when the
@@ -357,7 +375,15 @@ export function branchCreationStartPoint(rest: string): { start?: string } | und
357
375
  const tokens = rest.split(/\s+/).filter(Boolean);
358
376
  let sawCreate = false;
359
377
  let named = false;
360
- for (const token of tokens) {
378
+ for (let i = 0; i < tokens.length; i++) {
379
+ const token = tokens[i]!;
380
+ if (BRANCH_REDIRECTION_OPERATORS.has(token) || /^\d{1,2}(?:>>|>|<<<|<<|>&|<&|<)$/.test(token)) {
381
+ // Separated redirection: its target word follows and is equally
382
+ // irrelevant to the start point.
383
+ i += 1;
384
+ continue;
385
+ }
386
+ if (BRANCH_REDIRECTION_ATTACHED_RE.test(token)) continue;
361
387
  const indeterminate = /[$`"']/.test(token);
362
388
  if (!sawCreate) {
363
389
  if (token === "switch" || token === "checkout") continue;
@@ -74,7 +74,7 @@ export function staleWriteReason(path: string, sessionID: string): string | unde
74
74
  const observed = reads.get(sessionID)?.get(canonical);
75
75
  if (!observed) {
76
76
  if (!current) return undefined;
77
- return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it.`;
77
+ return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it. If another session may own it, coordinate before writing.`;
78
78
  }
79
79
  if (!current || !equal(observed, current)) {
80
80
  return `Blocked: file '${canonical}' changed since this session read it. Re-read it before editing or overwriting it.`;