opencode-workflow-guard 1.15.0 → 1.15.2

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/docs/policies.md CHANGED
@@ -31,7 +31,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
31
31
  - The branch diff modifies a `CHANGELOG` file, OR
32
32
  - The branch diff adds/modifies a `.changeset/*.md` fragment file (Changesets workflow), OR
33
33
  - The PR description (`--body`, `--description`, or `--body-file`/`-F`) contains a release-information section headed `Changelog`, `Changes`, `Release notes`, or `Summary`. Multiline Bash ANSI-C `\n`/`\r` quoting is supported for inline descriptions; inline descriptions that would render Markdown line breaks as literal `\n`/`\r` text are blocked with guidance to use real newlines or a description file.
34
- - **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles (`package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, `go.sum`) are updated.
34
+ - **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles (`package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, `go.sum`) are updated. For `package.json` the demand is section-aware: it fires when a section the lockfile records changed (`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`, `peerDependenciesMeta`, `engines`, `packageManager`, `overrides`, `resolutions`, `workspaces`, `pnpm`, or the project `name`/`version`). Scripts- or metadata-only manifest edits regenerate the lockfile byte-identical and pass without one. `Cargo.toml` and `go.mod` changes conservatively keep demanding their lockfiles.
35
35
  - When multiple PR prerequisites fail at once, the guard reports all detected preflight failures together so they can be resolved before retrying PR creation.
36
36
 
37
37
  ### 4. Destructive CLI Operations Guard
@@ -143,7 +143,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
143
143
 
144
144
  ### 20. Merged Branch & Base Freshness Guard
145
145
  - Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
146
- - Blocks creating fresh feature branches when the local base branch is behind the remote, prompting the agent to pull latest changes first.
146
+ - Blocks creating feature branches (`git switch -c`, `git checkout -b`) from a stale base: without an explicit start point, the current branch must be fresh against the remote default; an explicit start point that already contains the remote default (`origin/HEAD`) is accepted because the new branch is fresh by construction, and start points that cannot be resolved fail closed. Block reasons name the branch actually checked and remedies that work under the guard (rebase or ff-merge the branch, or branch from the remote default directly).
147
147
  - Tag push refspecs are exempt: publishing an existing tag (`git push origin v1.2.0`, `git push origin refs/tags/v1.2.0`) and creating tags (`git tag v1.2.0`, `git tag -a -m ...`) are release operations, not branch mutations - only tag deletions (`git tag -d`, `git push origin :refs/tags/v1.2.0`, `--delete`) stay blocked.
148
148
 
149
149
  ### 21. Documentation Review & Synchronization Guard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { resolve } from "node:path";
3
4
  import type { AuditEntry, LearningEvidenceKind } from "./types.ts";
4
5
  import type { ProjectMemoryStore } from "./project-memory.ts";
5
6
  import {
@@ -31,10 +32,11 @@ import {
31
32
  recordMutation,
32
33
  recordReviewResult,
33
34
  resolveEffectiveWorkspace,
35
+ resolveSessionBoundWorkspace,
34
36
  runWithRuntimeState,
35
37
  } from "./state.ts";
36
38
  import { getRalphOutcome } from "../policies/continuation.ts";
37
- import { loadProjectConfig, projectRootKey } from "./project-config.ts";
39
+ import { findGitRoot, loadProjectConfig, projectRootKey } from "./project-config.ts";
38
40
  import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
39
41
  import { buildReviewRubric } from "./review.ts";
40
42
  import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
@@ -121,6 +123,27 @@ export function createCustomTools(options: {
121
123
  client: unknown;
122
124
  }) {
123
125
  const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
126
+ // Resolve the workspace for a tool call from the CALL's own context: an
127
+ // explicit argument first, then the host-provided per-call worktree or
128
+ // directory (even when unusable — the call declared its target), then the
129
+ // session's bound workspace. The plugin-instance default (`effectiveRoot`)
130
+ // is only the last resort — concurrent loops share one guard process, so
131
+ // an instance-derived root must never re-anchor another session's tool
132
+ // execution.
133
+ const resolveCallWorkspace = async (
134
+ toolContext: { sessionID?: string; worktree?: string; directory?: string } | undefined,
135
+ explicitDirectory?: string,
136
+ ): Promise<string> => {
137
+ if (explicitDirectory) {
138
+ return findGitRoot(explicitDirectory) ?? projectRootKey(explicitDirectory);
139
+ }
140
+ const hostContext = toolContext?.worktree || toolContext?.directory;
141
+ const hostIsFilesystemRoot = hostContext ? resolve(hostContext) === resolve(hostContext, "..") : true;
142
+ if (hostContext && !hostIsFilesystemRoot) {
143
+ return findGitRoot(hostContext) ?? projectRootKey(hostContext);
144
+ }
145
+ return resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, fallback: effectiveRoot });
146
+ };
124
147
  return {
125
148
  ...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
126
149
  guard_recovery_restore: tool({
@@ -131,7 +154,7 @@ export function createCustomTools(options: {
131
154
  const parent = await fetchParentSession(toolContext.sessionID);
132
155
  if (!parent.ok) return "[workflow-guard] Recovery rejected: could not confirm this is a root session.";
133
156
  if (parent.parentID) return "[workflow-guard] Recovery rejected: only root sessions can restore their checkpoints.";
134
- const result = restoreRecoveryCheckpoint(effectiveRoot, toolContext.sessionID, args.run);
157
+ const result = restoreRecoveryCheckpoint(await resolveCallWorkspace(toolContext), toolContext.sessionID, args.run);
135
158
  return result.ok ? `[workflow-guard] Restored recovery checkpoint for run ${args.run}.` : `[workflow-guard] Recovery rejected: ${result.error}`;
136
159
  },
137
160
  }),
@@ -139,8 +162,8 @@ export function createCustomTools(options: {
139
162
  guard_next_tasks: tool({
140
163
  description: "Load durable repository task context when deciding what to work on next or planning upcoming steps. Proactively call at session start or during planning to discover roadmap, plan, tasks, backlog, and TODO.md files.",
141
164
  args: {},
142
- execute: async () => {
143
- const sources = discoverPlanningSources(effectiveRoot);
165
+ execute: async (_args, toolContext) => {
166
+ const sources = discoverPlanningSources(await resolveCallWorkspace(toolContext));
144
167
  return sources.length > 0 ? JSON.stringify({ sources }, null, 2) : JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
145
168
  },
146
169
  }),
@@ -158,7 +181,7 @@ export function createCustomTools(options: {
158
181
  if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
159
182
  const detectedSecret = secretIn(args.content);
160
183
  if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
161
- return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot), paths: args.paths, supersedes: args.supersedes || undefined }));
184
+ return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(await resolveCallWorkspace(toolContext)), paths: args.paths, supersedes: args.supersedes || undefined }));
162
185
  },
163
186
  }),
164
187
  project_memory_export: tool({
@@ -181,7 +204,7 @@ export function createCustomTools(options: {
181
204
  execute: async (args, toolContext) => {
182
205
  const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } => (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") && candidate.concept.length > 0 && candidate.concept.length <= 100 && candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1);
183
206
  const used = learningInterventions.get(toolContext.sessionID) ?? 0;
184
- const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(effectiveRoot) });
207
+ const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(await resolveCallWorkspace(toolContext)) });
185
208
  if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
186
209
  learningInterventions.set(toolContext.sessionID, used + 1);
187
210
  return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
@@ -194,10 +217,11 @@ export function createCustomTools(options: {
194
217
  const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
195
218
  if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
196
219
  if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
220
+ const learningWorkspace = await resolveCallWorkspace(toolContext);
197
221
  try {
198
222
  updateLearnerProfile((profile) => {
199
223
  if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) throw new Error("concept-limit");
200
- recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project: effectiveRoot });
224
+ recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project: learningWorkspace });
201
225
  });
202
226
  } catch (error) {
203
227
  if ((error as Error).message === "concept-limit") return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
@@ -208,12 +232,12 @@ export function createCustomTools(options: {
208
232
  }),
209
233
  } : {}),
210
234
  guard_status: tool({
211
- description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs.",
235
+ description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs. Concurrent loops share one guard process, so pass 'directory' explicitly when inspecting a root other than this session's own workspace.",
212
236
  args: {
213
- directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to active session directory or workspace root)"),
237
+ directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to this session's own workspace binding, never another concurrent session's root)"),
214
238
  },
215
239
  execute: async (args, toolContext) => {
216
- const root = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args?.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
240
+ const root = await resolveCallWorkspace(toolContext, args?.directory);
217
241
  const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
218
242
  const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
219
243
  const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
@@ -256,14 +280,14 @@ export function createCustomTools(options: {
256
280
  },
257
281
  }),
258
282
  guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
259
- guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
283
+ guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(await resolveCallWorkspace(toolContext), client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
260
284
  record_review: tool({
261
285
  description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform). Projects with requireSubagentReview enabled only accept APPROVALS recorded from a subagent session (one with a parent session), never from the root session itself.",
262
286
  args: {
263
287
  reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
264
288
  summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
265
289
  passed: tool.schema.boolean().describe("True if change is approved, false if changes requested"),
266
- directory: tool.schema.string().optional().describe("Target repository directory being reviewed (defaults to parent session's repository or active directory)"),
290
+ directory: tool.schema.string().optional().describe("Target repository directory being reviewed. Pass it explicitly whenever the recorder session has no workspace binding: concurrent loops share one guard process, and a verdict is never bound to a guessed root."),
267
291
  },
268
292
  execute: async (args, toolContext) => {
269
293
  const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string, binding?: NonNullable<AuditEntry["evidence"]>["binding"]) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict, ...(binding ? { binding } : {}) } });
@@ -277,7 +301,19 @@ export function createCustomTools(options: {
277
301
  // hash + tracked worktree fingerprint); the recorder's lineage is
278
302
  // captured in the audit trail. The review-before-PR requirement
279
303
  // itself is unchanged.
280
- const reviewWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
304
+ // Binding is never guessed: an explicit directory, the session's
305
+ // own workspace binding (direct or inherited from a bound
306
+ // parent), or a host-provided per-call worktree. When none of
307
+ // those exist the verdict is rejected instead of silently bound
308
+ // to a latched root — concurrent loops share one guard process,
309
+ // and a latched root can belong to another loop's repository.
310
+ const hostProvidesWorkspace = Boolean(toolContext?.worktree);
311
+ const sessionBinding = await resolveSessionBoundWorkspace(toolContext?.sessionID);
312
+ if (!args.directory && !sessionBinding && !hostProvidesWorkspace) {
313
+ auditVerdict("rejected", "review_binding_unresolved", { recorderSessionID: toolContext?.sessionID });
314
+ return "[workflow-guard] Review rejected: the reviewed workspace could not be resolved from this call (no explicit 'directory', no session workspace binding, no per-call worktree). Concurrent loops share one guard process, so a verdict is never bound to a guessed root. Call record_review with 'directory' set to the reviewed repository worktree.";
315
+ }
316
+ const reviewWorkspace = await resolveCallWorkspace(toolContext, args.directory);
281
317
  const bindingFingerprint = getTrackedWorktreeFingerprint(reviewWorkspace);
282
318
  if (!bindingFingerprint) {
283
319
  auditVerdict("rejected", "review_workspace_unbindingable", { workspace: reviewWorkspace, recorderSessionID: toolContext.sessionID });
@@ -325,7 +361,7 @@ export function createCustomTools(options: {
325
361
  execute: async (args, toolContext) => {
326
362
  const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
327
363
  const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
328
- const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
364
+ const rubricWorkspace = await resolveCallWorkspace(toolContext, args?.directory);
329
365
  let diffText = "";
330
366
  // Revision before the trailing `--` (LL-001) so the base ref is
331
367
  // always parsed as a revision, never as a pathspec.
@@ -338,16 +374,20 @@ export function createCustomTools(options: {
338
374
  description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
339
375
  execute: async (args, toolContext) => {
340
376
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
341
- const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
342
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
377
+ const toolRoot = await resolveCallWorkspace(toolContext); const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
378
+ const mutationRoot = await resolveCallWorkspace(toolContext);
379
+ await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
380
+ return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
343
381
  },
344
382
  }),
345
383
  guard_worktree_cleanup: tool({
346
384
  description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
347
385
  execute: async (args, toolContext) => {
348
386
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
349
- const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
350
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
387
+ const toolRoot = await resolveCallWorkspace(toolContext); const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
388
+ const mutationRoot = await resolveCallWorkspace(toolContext);
389
+ await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
390
+ return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
351
391
  },
352
392
  }),
353
393
  };
@@ -7,6 +7,7 @@ import {
7
7
  getLastReviewResultForWorkspace,
8
8
  getLiveControlPlaneRoots,
9
9
  getSdkClient,
10
+ getSessionBoundWorkspace,
10
11
  getWorkspaceRoot,
11
12
  isDocumentationRequired,
12
13
  isReviewRequired,
@@ -48,6 +49,7 @@ import {
48
49
  GIT_WRITE_RE,
49
50
  PUSH_TO_MAIN_RE,
50
51
  branchGuardReason,
52
+ branchCreationStartPoint,
51
53
  checkBranchBaseIsUpToDate,
52
54
  checkMergeConflicts,
53
55
  currentGitBranch,
@@ -113,7 +115,15 @@ export async function guardToolCallImpl(
113
115
  context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
114
116
  ): Promise<PolicyDecision> {
115
117
  const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
116
- const currentRoot = getWorkspaceRoot();
118
+ // The workspace for THIS decision comes from the call's own context (the
119
+ // hook's per-call worktree/directory) or, failing that, the session's own
120
+ // bound workspace — never from one process-global slot: concurrent loops
121
+ // share this process, and the last-active instance must not re-anchor
122
+ // another session's policy evaluation.
123
+ const currentRoot = context?.worktree
124
+ || context?.directory
125
+ || getSessionBoundWorkspace(context?.sessionID)
126
+ || getWorkspaceRoot();
117
127
  const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
118
128
  const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
119
129
  const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
@@ -366,9 +376,27 @@ export async function guardToolCallImpl(
366
376
  }
367
377
  }
368
378
  if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
369
- const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
379
+ // A branch created from an explicit start point that already contains
380
+ // the remote default is fresh by construction; only creations without
381
+ // one (or with an unclassifiable shape — fail closed) inherit the
382
+ // current branch's staleness.
383
+ const creationStarts: Array<string | undefined> = [];
384
+ let classifiable = true;
385
+ for (const invocation of gitInvocations) {
386
+ if (!GIT_BRANCH_CREATE_RE.test(`git ${invocation.rest}`)) continue;
387
+ const parsed = branchCreationStartPoint(invocation.rest);
388
+ if (parsed === undefined) {
389
+ classifiable = false;
390
+ break;
391
+ }
392
+ creationStarts.push(parsed.start);
393
+ }
394
+ const behindCheck = checkBranchBaseIsUpToDate(
395
+ effectiveRoot,
396
+ classifiable && creationStarts.length > 0 ? creationStarts : undefined,
397
+ );
370
398
  if (behindCheck.isBehind) {
371
- logPolicyBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
399
+ logPolicyBlock(`[workflow-guard] blocked branch creation: ${behindCheck.reason ?? "base is behind remote"}`);
372
400
  return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
373
401
  }
374
402
  }
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);
@@ -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,
@@ -193,10 +193,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
193
193
  // wrap it so the V2 registry receives a real StandardSchemaV1.
194
194
  input: z.object(definition.args as unknown as z.ZodRawShape),
195
195
  execute: async (rawInput: unknown, toolContext: { sessionID: string; id: string }) => {
196
+ // Only a session-bound workspace counts as the tool's own
197
+ // context; an unbound entry is a plugin-instance bootstrap
198
+ // and must not be presented to the tools as one.
199
+ const boundWorktree = getSessionBoundWorkspace(toolContext.sessionID);
196
200
  const result = await (definition.execute as (input: unknown, context: unknown) => Promise<unknown>)(rawInput, {
197
201
  sessionID: toolContext.sessionID,
198
- worktree: getSessionWorkspace(toolContext.sessionID) ?? effectiveRoot,
199
- directory: effectiveRoot,
202
+ ...(boundWorktree ? { worktree: boundWorktree } : {}),
203
+ directory: boundWorktree ?? effectiveRoot,
200
204
  });
201
205
  const text = typeof result === "string" ? result : (result as { output?: string } | undefined)?.output;
202
206
  return {
@@ -218,7 +222,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
218
222
 
219
223
  // ── Deterministic enforcement ──
220
224
  await ctx.tool.hook("execute.before", (event) => {
221
- const toolWorktree = getSessionWorkspace(event.sessionID) || effectiveRoot;
225
+ const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
222
226
  return runWithRuntimeState(toolWorktree, client, async () => {
223
227
  const args = event.input;
224
228
  const reason = await guardToolCall(event.tool, args, {
@@ -239,11 +243,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
239
243
  const record = asRecord(args);
240
244
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
241
245
  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
- }
246
+ registerSessionWorkspaceFromToolCall(event.sessionID, { workdir, filePath, toolWorktree });
247
247
  if (event.tool === "read") {
248
248
  const target = editTargets(args, toolWorktree)[0];
249
249
  if (target) {
@@ -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";
@@ -89,7 +88,15 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
89
88
  if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
90
89
  return true;
91
90
  }
92
- const realRootVal = getWorkspaceRootReal();
91
+ // The boundary is resolved against the workspace THIS call declares (the
92
+ // `root` argument), never against a process-global latched root: concurrent
93
+ // loops share one guard process, and a latched root must not redefine
94
+ // another session's boundary. Symlink-awareness is preserved (ancestor
95
+ // walk below) by canonicalizing the declared root itself.
96
+ let realRootVal = root;
97
+ try {
98
+ realRootVal = realpathSync(root);
99
+ } catch {}
93
100
  try {
94
101
  const real = realpathSync(resolved);
95
102
  const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";