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 +2 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +31 -3
- 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/policies/git.ts +134 -11
- package/src/workflow-guard-ui.ts +1 -2
- package/src/workflow-guard.ts +14 -8
- package/test/test.mts +254 -9
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
|
|
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
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 + "/";
|