opencode-workflow-guard 1.15.1 → 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 +1 -1
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +10 -1
- package/src/lib/state.ts +118 -49
- package/src/lib/v2-plugin.ts +10 -10
- package/src/policies/boundary.ts +9 -2
- package/src/policies/changelog.ts +112 -11
- package/src/workflow-guard.ts +14 -8
- package/test/test.mts +215 -0
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
|
package/package.json
CHANGED
package/src/lib/custom-tools.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
342
|
-
|
|
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 =
|
|
350
|
-
|
|
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,
|
|
@@ -114,7 +115,15 @@ export async function guardToolCallImpl(
|
|
|
114
115
|
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
|
|
115
116
|
): Promise<PolicyDecision> {
|
|
116
117
|
const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
|
|
117
|
-
|
|
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();
|
|
118
127
|
const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
|
|
119
128
|
const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
|
|
120
129
|
const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
|
package/src/lib/state.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
232
|
-
(result): result is ReviewResult
|
|
287
|
+
const all = [lastReview, ...sessionReviews.values()].filter(
|
|
288
|
+
(result): result is ReviewResult & { workspace: string } => result?.workspace != null,
|
|
233
289
|
);
|
|
234
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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);
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -25,8 +25,8 @@ import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
|
|
|
25
25
|
import {
|
|
26
26
|
setWorkspaceRoot,
|
|
27
27
|
setSdkClient,
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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) {
|
package/src/policies/boundary.ts
CHANGED
|
@@ -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
|
-
|
|
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 + "/";
|
|
@@ -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
|
|
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
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 {}
|
package/src/workflow-guard.ts
CHANGED
|
@@ -84,6 +84,9 @@ import {
|
|
|
84
84
|
sessionReviews,
|
|
85
85
|
setSessionWorkspace,
|
|
86
86
|
getSessionWorkspace,
|
|
87
|
+
getSessionBoundWorkspace,
|
|
88
|
+
registerSessionWorkspaceFromToolCall,
|
|
89
|
+
resolveSessionBoundWorkspace,
|
|
87
90
|
resolveEffectiveWorkspace,
|
|
88
91
|
} from "./lib/state.ts";
|
|
89
92
|
import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
|
|
@@ -116,6 +119,9 @@ export {
|
|
|
116
119
|
resetReviewState,
|
|
117
120
|
setSessionWorkspace,
|
|
118
121
|
getSessionWorkspace,
|
|
122
|
+
getSessionBoundWorkspace,
|
|
123
|
+
registerSessionWorkspaceFromToolCall,
|
|
124
|
+
resolveSessionBoundWorkspace,
|
|
119
125
|
resolveEffectiveWorkspace,
|
|
120
126
|
findGitRoot,
|
|
121
127
|
isSameGitRepo,
|
|
@@ -448,12 +454,16 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
448
454
|
tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
|
|
449
455
|
|
|
450
456
|
"tool.execute.before": async (input, output) => {
|
|
451
|
-
const
|
|
457
|
+
const hostWorktree =
|
|
452
458
|
(input as { worktree?: string })?.worktree ||
|
|
453
459
|
(input as { directory?: string })?.directory ||
|
|
454
460
|
(output as { worktree?: string })?.worktree ||
|
|
455
|
-
(output as { directory?: string })?.directory
|
|
456
|
-
|
|
461
|
+
(output as { directory?: string })?.directory;
|
|
462
|
+
// Session-bound workspace before the plugin-instance fallback:
|
|
463
|
+
// concurrent loops share this process, and a fallback derived from
|
|
464
|
+
// the last-active instance must not re-anchor another session's
|
|
465
|
+
// policy context.
|
|
466
|
+
const toolWorktree = hostWorktree || getSessionBoundWorkspace(input.sessionID) || effectiveRoot;
|
|
457
467
|
const toolAgent =
|
|
458
468
|
(input as { agent?: string })?.agent ||
|
|
459
469
|
(output as { agent?: string })?.agent;
|
|
@@ -480,11 +490,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
480
490
|
const record = asRecord(args);
|
|
481
491
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
482
492
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
483
|
-
|
|
484
|
-
if (candidatePath) {
|
|
485
|
-
const gitRoot = findGitRoot(candidatePath);
|
|
486
|
-
if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
|
|
487
|
-
}
|
|
493
|
+
registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
|
|
488
494
|
}
|
|
489
495
|
if (input.tool === "read") {
|
|
490
496
|
const target = editTargets(args, toolWorktree)[0];
|
package/test/test.mts
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
persistReviewCache,
|
|
46
46
|
setSessionWorkspace,
|
|
47
47
|
getSessionWorkspace,
|
|
48
|
+
getSessionBoundWorkspace,
|
|
49
|
+
registerSessionWorkspaceFromToolCall,
|
|
48
50
|
resolveEffectiveWorkspace,
|
|
49
51
|
getLastReviewResultForWorkspace,
|
|
50
52
|
isSameGitRepo,
|
|
@@ -3169,6 +3171,219 @@ writeFileSync(join(lockRepo, "package-lock.json"), "{\"name\":\"demo\",\"version
|
|
|
3169
3171
|
check("checkLockfileSync passes when lockfile is updated", !checkLockfileSync(lockRepo).isOutOfSync);
|
|
3170
3172
|
rmSync(lockRepo, { recursive: true, force: true });
|
|
3171
3173
|
|
|
3174
|
+
// Lockfile synchronization tracks lockfile-relevant manifest sections, not
|
|
3175
|
+
// the mere presence of package.json in the branch diff. A scripts-only edit
|
|
3176
|
+
// (the documented curation act of adding a suite to a test script) regenerates
|
|
3177
|
+
// package-lock.json byte-identical, so demanding a lockfile change for it is
|
|
3178
|
+
// unsatisfiable; dependency/engines/packageManager changes still demand one.
|
|
3179
|
+
const scriptLockRepo = mkdtempSync(join(tmpdir(), "wg-lock-scripts-"));
|
|
3180
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: scriptLockRepo });
|
|
3181
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: scriptLockRepo });
|
|
3182
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: scriptLockRepo });
|
|
3183
|
+
const scriptManifestBase = { name: "demo", version: "1.0.0", scripts: { test: "node test.mjs" } };
|
|
3184
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestBase, null, 2) + "\n");
|
|
3185
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3 }, null, 2) + "\n");
|
|
3186
|
+
// A nested manifest already present at the merge base, so its scripts-only
|
|
3187
|
+
// edit on the branch is a true edit (not a new manifest).
|
|
3188
|
+
mkdirSync(join(scriptLockRepo, "packages", "app"), { recursive: true });
|
|
3189
|
+
const nestedManifestBase = { name: "app", version: "0.0.0", scripts: { build: "node build.mjs" } };
|
|
3190
|
+
writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestBase, null, 2) + "\n");
|
|
3191
|
+
spawnSync("git", ["add", "-A"], { cwd: scriptLockRepo });
|
|
3192
|
+
spawnSync("git", ["commit", "-qm", "init"], { cwd: scriptLockRepo });
|
|
3193
|
+
spawnSync("git", ["switch", "-c", "feat/scripts-only"], { cwd: scriptLockRepo });
|
|
3194
|
+
const scriptManifestEdited = JSON.parse(JSON.stringify(scriptManifestBase)) as typeof scriptManifestBase;
|
|
3195
|
+
scriptManifestEdited.scripts.test = "node test.mjs && node test-ci.mjs";
|
|
3196
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestEdited, null, 2) + "\n");
|
|
3197
|
+
check("checkLockfileSync passes an uncommitted scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3198
|
+
spawnSync("git", ["commit", "-qam", "curate test suite"], { cwd: scriptLockRepo });
|
|
3199
|
+
check("checkLockfileSync passes a committed scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3200
|
+
// The observed false positive: a scripts-only edit alongside a lockfile that
|
|
3201
|
+
// npm rewrote byte-identical (present in the diff set, yet unchanged in
|
|
3202
|
+
// content — the old check demanded exactly this unsatisfiable combination).
|
|
3203
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), readFileSync(join(scriptLockRepo, "package-lock.json"), "utf8"));
|
|
3204
|
+
check("checkLockfileSync passes a byte-identical lockfile rewrite next to a scripts-only edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3205
|
+
// A nested manifest's scripts-only edit passes too.
|
|
3206
|
+
const nestedManifestEdited = JSON.parse(JSON.stringify(nestedManifestBase)) as typeof nestedManifestBase;
|
|
3207
|
+
nestedManifestEdited.scripts.build = "node build.mjs && node verify.mjs";
|
|
3208
|
+
writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestEdited, null, 2) + "\n");
|
|
3209
|
+
check("checkLockfileSync passes a scripts-only nested manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3210
|
+
// Contract pins: sections the lockfile records still demand the lockfile.
|
|
3211
|
+
const scriptsAfterNesting = scriptManifestEdited.scripts;
|
|
3212
|
+
const relevantManifest = { name: "demo", version: "1.0.0", scripts: scriptsAfterNesting };
|
|
3213
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3214
|
+
check("checkLockfileSync flags a dependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3215
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, engines: { node: ">=22" } }, null, 2) + "\n");
|
|
3216
|
+
check("checkLockfileSync flags an engines change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3217
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, packageManager: "pnpm@9.0.0" }, null, 2) + "\n");
|
|
3218
|
+
check("checkLockfileSync flags a packageManager change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3219
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, devDependencies: { typescript: "^5.0.0" } }, null, 2) + "\n");
|
|
3220
|
+
check("checkLockfileSync flags a devDependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3221
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, pnpm: { overrides: { "left-pad": "^1.3.0" } } }, null, 2) + "\n");
|
|
3222
|
+
check("checkLockfileSync flags a pnpm-overrides change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3223
|
+
// And a relevant change WITH the lockfile updated passes.
|
|
3224
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3225
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3226
|
+
check("checkLockfileSync passes when the lockfile is updated alongside a relevant change", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3227
|
+
rmSync(scriptLockRepo, { recursive: true, force: true });
|
|
3228
|
+
|
|
3229
|
+
// Concurrent loops share ONE guard process: workspace resolution must come
|
|
3230
|
+
// from the call's own context or the session's own binding, never from the
|
|
3231
|
+
// last-active plugin instance. Two plugin instances in this process simulate
|
|
3232
|
+
// the two loops; instance B's roots must never re-anchor session A's context.
|
|
3233
|
+
const isoRepoA = mkdtempSync(join(tmpdir(), "wg-iso-a-"));
|
|
3234
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: isoRepoA });
|
|
3235
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoA });
|
|
3236
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoA });
|
|
3237
|
+
writeFileSync(join(isoRepoA, "seed.txt"), "loop a\n");
|
|
3238
|
+
spawnSync("git", ["add", "-A"], { cwd: isoRepoA });
|
|
3239
|
+
spawnSync("git", ["commit", "-qm", "init a"], { cwd: isoRepoA });
|
|
3240
|
+
spawnSync("git", ["switch", "-c", "feat/iso-a"], { cwd: isoRepoA });
|
|
3241
|
+
const isoRepoB = mkdtempSync(join(tmpdir(), "wg-iso-b-"));
|
|
3242
|
+
spawnSync("git", ["init", "-b", "loop/other-loop"], { cwd: isoRepoB });
|
|
3243
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoB });
|
|
3244
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoB });
|
|
3245
|
+
writeFileSync(join(isoRepoB, "seed.txt"), "loop b\n");
|
|
3246
|
+
spawnSync("git", ["add", "-A"], { cwd: isoRepoB });
|
|
3247
|
+
spawnSync("git", ["commit", "-qm", "init b"], { cwd: isoRepoB });
|
|
3248
|
+
const isoPluginA = await WorkflowGuard({ directory: isoRepoA, worktree: isoRepoA, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
3249
|
+
const isoPluginB = await WorkflowGuard({ directory: isoRepoB, worktree: isoRepoB, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
3250
|
+
const isoHookOf = (plugin: unknown) => (plugin as { "tool.execute.before"?: (input: unknown, output: unknown) => Promise<unknown> })["tool.execute.before"];
|
|
3251
|
+
// Session A binds to repoA from its OWN call context (an edit/read target
|
|
3252
|
+
// inside repoA delivered through the call arguments).
|
|
3253
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-shared", tool: "read", callID: "c-iso-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
|
|
3254
|
+
check("a session binds to its workspace from its own call context", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3255
|
+
// The other loop's plugin instance must not re-anchor it from its own root.
|
|
3256
|
+
await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
|
|
3257
|
+
check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3258
|
+
const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
|
|
3259
|
+
check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
|
|
3260
|
+
const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
|
|
3261
|
+
check("guard_status honors an explicit directory over the session binding", isoStatusExplicit.workspaceRoot === isoRepoB && isoStatusExplicit.branch === "loop/other-loop");
|
|
3262
|
+
check("resolveEffectiveWorkspace keeps a bound session off a foreign fallback", (await resolveEffectiveWorkspace({ sessionID: "s-iso-shared", fallback: isoRepoB })) === isoRepoA);
|
|
3263
|
+
check("resolveEffectiveWorkspace keeps fallback semantics for an unbound session", (await resolveEffectiveWorkspace({ sessionID: "s-iso-unbound", fallback: isoRepoB })) === isoRepoB);
|
|
3264
|
+
// Policy decisions follow the call's own context: a boundary write inside the
|
|
3265
|
+
// session's repo passes even while the other loop's root is the fallback.
|
|
3266
|
+
todo("s-iso-shared", item("iso work", "in_progress"));
|
|
3267
|
+
const isoWrite = await guardToolDecision("write", { filePath: join(isoRepoA, "scratch.txt"), content: "safe" }, { sessionID: "s-iso-shared" });
|
|
3268
|
+
check("boundary checks use the session's own workspace, not the other loop's root", isoWrite.status === "allowed");
|
|
3269
|
+
if (isoWrite.status !== "allowed") console.log(" blocked:", isoWrite.message);
|
|
3270
|
+
// Review verdict binding is never guessed: no explicit directory, no session
|
|
3271
|
+
// binding, no per-call worktree — the verdict is rejected, not latched.
|
|
3272
|
+
const unboundReview = await isoPluginA.tool?.record_review?.execute(
|
|
3273
|
+
{ reviewer: "no-binding", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
|
|
3274
|
+
{ sessionID: "s-iso-unbound-recorder" } as any,
|
|
3275
|
+
);
|
|
3276
|
+
check(
|
|
3277
|
+
"record_review rejects a verdict it cannot bind instead of latching a root",
|
|
3278
|
+
typeof unboundReview === "string" && unboundReview.includes("rejected") && unboundReview.includes("never bound to a guessed root") && !unboundReview.includes("APPROVED"),
|
|
3279
|
+
);
|
|
3280
|
+
const unboundReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-iso-unbound-recorder");
|
|
3281
|
+
check("unbindingable verdict audited as review_binding_unresolved", unboundReviewAudit?.reason === "review_binding_unresolved");
|
|
3282
|
+
// A bound session records through the OTHER loop's plugin instance: the
|
|
3283
|
+
// verdict binds to the session's own workspace, not the instance root.
|
|
3284
|
+
const isoBoundReview = await isoPluginB.tool?.record_review?.execute(
|
|
3285
|
+
{ reviewer: "drift-proof", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
|
|
3286
|
+
{ sessionID: "s-iso-shared" } as any,
|
|
3287
|
+
);
|
|
3288
|
+
check(
|
|
3289
|
+
"record_review binds to the session's own workspace, not the other loop's instance root",
|
|
3290
|
+
typeof isoBoundReview === "string" && isoBoundReview.includes("APPROVED") && isoBoundReview.includes(isoRepoA) && !isoBoundReview.includes(isoRepoB),
|
|
3291
|
+
);
|
|
3292
|
+
rmSync(isoRepoA, { recursive: true, force: true });
|
|
3293
|
+
rmSync(isoRepoB, { recursive: true, force: true });
|
|
3294
|
+
resetReviewState();
|
|
3295
|
+
setWorkspaceRoot(root);
|
|
3296
|
+
|
|
3297
|
+
// Review bindings are consumed per WORKTREE, not per repository-recency: two
|
|
3298
|
+
// loops sharing one repo must not displace each other's approvals between
|
|
3299
|
+
// record_review and PR creation.
|
|
3300
|
+
const raceRepo = join(root, "wg-race-repo");
|
|
3301
|
+
mkdirSync(raceRepo, { recursive: true });
|
|
3302
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: raceRepo });
|
|
3303
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: raceRepo });
|
|
3304
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: raceRepo });
|
|
3305
|
+
writeFileSync(join(raceRepo, "seed.txt"), "base\n");
|
|
3306
|
+
spawnSync("git", ["add", "-A"], { cwd: raceRepo });
|
|
3307
|
+
spawnSync("git", ["commit", "-qm", "base"], { cwd: raceRepo });
|
|
3308
|
+
mkdirSync(join(raceRepo, ".opencode"), { recursive: true });
|
|
3309
|
+
writeFileSync(join(raceRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
|
|
3310
|
+
spawnSync("git", ["switch", "-c", "feat/race-a"], { cwd: raceRepo });
|
|
3311
|
+
writeFileSync(join(raceRepo, "a.txt"), "loop a change\n");
|
|
3312
|
+
spawnSync("git", ["add", "-A"], { cwd: raceRepo });
|
|
3313
|
+
spawnSync("git", ["commit", "-qm", "loop a change"], { cwd: raceRepo });
|
|
3314
|
+
const prevWorktreeDirRace = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
|
|
3315
|
+
process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-race-worktrees");
|
|
3316
|
+
const raceWtRes = createGitWorktree("feat/race-b", "HEAD", raceRepo);
|
|
3317
|
+
check("race: sibling worktree created", raceWtRes.success && typeof raceWtRes.worktreePath === "string");
|
|
3318
|
+
const raceWt = raceWtRes.worktreePath!;
|
|
3319
|
+
writeFileSync(join(raceWt, "b.txt"), "loop b change\n");
|
|
3320
|
+
spawnSync("git", ["add", "-A"], { cwd: raceWt });
|
|
3321
|
+
spawnSync("git", ["commit", "-qm", "loop b change"], { cwd: raceWt });
|
|
3322
|
+
const raceSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
|
|
3323
|
+
// Loop A records first; loop B records a NEWER review for the sibling
|
|
3324
|
+
// worktree. Each worktree must keep consuming its OWN approval.
|
|
3325
|
+
resetReviewState();
|
|
3326
|
+
recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
|
|
3327
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3328
|
+
check("loop A consumes its own worktree review, not the newer sibling review", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
|
|
3329
|
+
check("loop B consumes its own worktree review", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
|
|
3330
|
+
resetReviewState();
|
|
3331
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3332
|
+
recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
|
|
3333
|
+
check("loop A keeps its own review when it was recorded first", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
|
|
3334
|
+
check("loop B keeps its own review when it was recorded last", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
|
|
3335
|
+
// Preflight consumption with the remote state stubbed off: loop A's PR must
|
|
3336
|
+
// pass while the sibling loop's newer review exists, and must FAIL with a
|
|
3337
|
+
// worktree-mismatch cause when only the sibling review is present.
|
|
3338
|
+
const racePrevPath = process.env.PATH;
|
|
3339
|
+
const raceBin = join(root, "wg-race-bin");
|
|
3340
|
+
mkdirSync(raceBin, { recursive: true });
|
|
3341
|
+
writeFileSync(join(raceBin, "gh"), "#!/bin/sh\nexit 1\n");
|
|
3342
|
+
writeFileSync(join(raceBin, "az"), "#!/bin/sh\nexit 1\n");
|
|
3343
|
+
chmodSync(join(raceBin, "gh"), 0o755);
|
|
3344
|
+
chmodSync(join(raceBin, "az"), 0o755);
|
|
3345
|
+
process.env.PATH = `${raceBin}:${racePrevPath ?? ""}`;
|
|
3346
|
+
const racePrBody = "gh pr create --title 'fix: race' --body 'Changelog: race'";
|
|
3347
|
+
try {
|
|
3348
|
+
const racePrA = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
|
|
3349
|
+
check("loop A's PR passes preflight while the sibling loop's newer review exists", !blocked(racePrA));
|
|
3350
|
+
if (blocked(racePrA)) console.log(" blocked:", racePrA);
|
|
3351
|
+
const racePrB = await call("bash", { command: racePrBody, workdir: raceWt }, { sessionID: "s-race-b", worktree: raceWt, directory: raceWt });
|
|
3352
|
+
check("loop B's PR passes preflight independently", !blocked(racePrB));
|
|
3353
|
+
if (blocked(racePrB)) console.log(" blocked:", racePrB);
|
|
3354
|
+
// Negative control: only the sibling review present -> the mismatch is
|
|
3355
|
+
// named instead of displacing the other loop's evidence.
|
|
3356
|
+
resetReviewState();
|
|
3357
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3358
|
+
const racePrOnlyB = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
|
|
3359
|
+
check(
|
|
3360
|
+
"loop A's PR is blocked on the sibling review alone, naming the worktree mismatch",
|
|
3361
|
+
blocked(racePrOnlyB) && String(racePrOnlyB).includes("does not match the current worktree contents"),
|
|
3362
|
+
);
|
|
3363
|
+
// Same-repo fallback still rescues content-identical trees: a review
|
|
3364
|
+
// recorded from a worktree whose tracked content equals this one applies,
|
|
3365
|
+
// even when a NEWER sibling review does not match this content.
|
|
3366
|
+
const raceWtSameContentRes = createGitWorktree("feat/race-same", "feat/race-a", raceRepo);
|
|
3367
|
+
const raceWtSame = raceWtSameContentRes.worktreePath!;
|
|
3368
|
+
resetReviewState();
|
|
3369
|
+
recordReviewResult("reviewer-same", raceSummary, true, "s-race-same", raceRepo);
|
|
3370
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3371
|
+
check(
|
|
3372
|
+
"a same-repo review whose tracked content matches this worktree is preferred over a newer mismatching one",
|
|
3373
|
+
getLastReviewResultForWorkspace(raceWtSame)?.reviewer === "reviewer-same",
|
|
3374
|
+
);
|
|
3375
|
+
rmSync(raceWtSame, { recursive: true, force: true });
|
|
3376
|
+
} finally {
|
|
3377
|
+
process.env.PATH = racePrevPath;
|
|
3378
|
+
}
|
|
3379
|
+
if (prevWorktreeDirRace === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
|
|
3380
|
+
else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirRace;
|
|
3381
|
+
rmSync(join(root, "wg-race-worktrees"), { recursive: true, force: true });
|
|
3382
|
+
rmSync(raceBin, { recursive: true, force: true });
|
|
3383
|
+
rmSync(raceRepo, { recursive: true, force: true });
|
|
3384
|
+
resetReviewState();
|
|
3385
|
+
setWorkspaceRoot(root);
|
|
3386
|
+
|
|
3172
3387
|
// 14. Documentation Review & Synchronization Guard (Policy 21)
|
|
3173
3388
|
console.log("- Policy 21: Documentation Review & Synchronization Guard -");
|
|
3174
3389
|
const docRepo = mkdtempSync(join(tmpdir(), "wg-doc-repo-"));
|