opencode-workflow-guard 1.14.0 → 1.15.0

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.
@@ -0,0 +1,66 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { checkBranchBaseIsUpToDate, currentGitBranch } from "../policies/git.ts";
3
+ import { getCleanGitEnv } from "./worktree.ts";
4
+
5
+ const MERGED_BASES = ["origin/main", "origin/master", "main", "master"];
6
+ const MAX_LISTED_BRANCHES = 10;
7
+
8
+ export interface GitHygieneSnapshot {
9
+ /** Local branches fully merged into the mainline, capped for bounded output. */
10
+ mergedLocalBranches: string[];
11
+ /** True count of merged local branches (the list above may be capped). */
12
+ mergedLocalBranchCount: number;
13
+ /** Worktree admin entries whose directory is missing (clearable via cleanupGitWorktree or git worktree prune). */
14
+ prunableWorktreeCount: number;
15
+ /** Commits the current branch's base line is ahead by, when a remote base exists. */
16
+ baseBehind?: number;
17
+ baseRef?: string;
18
+ }
19
+
20
+ function gitOut(args: string[], root: string): string | undefined {
21
+ const res = spawnSync("git", args, {
22
+ cwd: root,
23
+ encoding: "utf8",
24
+ timeout: 10_000,
25
+ env: getCleanGitEnv(),
26
+ });
27
+ if (res.status !== 0) return undefined;
28
+ return res.stdout;
29
+ }
30
+
31
+ /**
32
+ * Read-only accumulation snapshot for guard_status: local branches already
33
+ * merged into the mainline, prunable worktree admin entries, and how far the
34
+ * current branch's base line is ahead. Hygiene itself stays manual — the
35
+ * guard reports, it never prunes or deletes branches on its own.
36
+ */
37
+ export function gitHygieneSnapshot(root: string): GitHygieneSnapshot | undefined {
38
+ const current = currentGitBranch(root);
39
+ if (current === undefined) {
40
+ // Not a git repository (or git unavailable): no hygiene snapshot.
41
+ return undefined;
42
+ }
43
+ const snapshot: GitHygieneSnapshot = { mergedLocalBranches: [], mergedLocalBranchCount: 0, prunableWorktreeCount: 0 };
44
+ for (const base of MERGED_BASES) {
45
+ const out = gitOut(["branch", "--merged", base, "--format=%(refname:short)"], root);
46
+ if (out === undefined) continue;
47
+ const branches = out
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter((name) => name.length > 0 && name !== base && name !== current)
51
+ .sort();
52
+ snapshot.mergedLocalBranchCount = branches.length;
53
+ snapshot.mergedLocalBranches = branches.slice(0, MAX_LISTED_BRANCHES);
54
+ break;
55
+ }
56
+ const worktreeOut = gitOut(["worktree", "list", "--porcelain"], root);
57
+ if (worktreeOut !== undefined) {
58
+ snapshot.prunableWorktreeCount = worktreeOut.split("\n").filter((line) => line.startsWith("prunable")).length;
59
+ }
60
+ const behind = checkBranchBaseIsUpToDate(root);
61
+ if (behind.isBehind) {
62
+ snapshot.baseBehind = behind.count;
63
+ snapshot.baseRef = behind.baseRef;
64
+ }
65
+ return snapshot;
66
+ }
package/src/lib/state.ts CHANGED
@@ -3,7 +3,7 @@ import { 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";
6
- import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
6
+ import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
7
7
  import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
8
8
  import type {
9
9
  TodoSdkClient,
@@ -104,15 +104,14 @@ export function recordMutation(sessionID?: string, actorSessionID?: string): voi
104
104
  sessionMutationTimestamps.set(id, lastMutationTimestamp);
105
105
  sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
106
106
  sessionVerifyResults.delete(id);
107
- sessionReviews.delete(id);
108
- }
109
- if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
110
- lastReview = undefined;
111
- try {
112
- const p = getReviewCacheFilePath();
113
- if (existsSync(p)) unlinkSync(p);
114
- } catch {}
115
107
  }
108
+ // Review evidence is intentionally NOT erased here. Recorded approvals bind
109
+ // to the reviewed tracked content (commit hash + tracked worktree
110
+ // fingerprint), so freshness is re-evaluated against content at
111
+ // consumption time. Erasing on any mutation made an unrelated untracked
112
+ // scratch-file deletion (or a mutation in a sibling worktree) invalidate a
113
+ // valid approval and let a re-record silently fail to stick; any change to
114
+ // the reviewed tracked content still makes the evidence stale.
116
115
  }
117
116
 
118
117
  export function getMutationCount(sessionID?: string): number {
@@ -195,7 +194,7 @@ export function recordReviewResult(
195
194
  passed: boolean,
196
195
  targetSessionID?: string,
197
196
  workspace?: string,
198
- ): void {
197
+ ): ReviewResult {
199
198
  const root = projectRootKey(workspace ?? getWorkspaceRoot());
200
199
  lastReview = {
201
200
  reviewer,
@@ -206,12 +205,15 @@ export function recordReviewResult(
206
205
  workspace: root,
207
206
  commitHash: getCurrentGitCommitHash(root),
208
207
  gitStatus: getGitStatusSummary(root),
209
- worktreeFingerprint: getGitWorktreeFingerprint(root),
208
+ // Bind to tracked repository content only: the review covered the
209
+ // reviewed diff, and untracked scratch files are not part of it.
210
+ worktreeFingerprint: getTrackedWorktreeFingerprint(root),
210
211
  };
211
212
  if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
212
213
  if (passed) {
213
214
  persistReviewCache(lastReview);
214
215
  }
216
+ return lastReview;
215
217
  }
216
218
 
217
219
  export function getLastReviewResult(): typeof lastReview {
@@ -328,6 +330,13 @@ export function isReviewRequired(root: string): boolean {
328
330
  return cfg.requireReview !== false;
329
331
  }
330
332
 
333
+ export function isSubagentReviewRequired(root: string): boolean {
334
+ const env = process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW?.toLowerCase();
335
+ if (env === "0" || env === "false" || env === "off") return false;
336
+ if (env === "1" || env === "true" || env === "on") return true;
337
+ return getProjectConfig(root).requireSubagentReview === true;
338
+ }
339
+
331
340
  export function isDocumentationRequired(root: string): boolean {
332
341
  if (process.env.WORKFLOW_GUARD_REQUIRE_DOCS === "1") return true;
333
342
  const cfg = getProjectConfig(root);
package/src/lib/types.ts CHANGED
@@ -9,7 +9,14 @@ export interface TodoSdkClient {
9
9
  };
10
10
  session?: {
11
11
  todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
12
- get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown } }>;
12
+ /**
13
+ * The returned record's `data.time.idle` (ms epoch, server-recorded
14
+ * when a session's turn ends; absent/zero while mid-turn) is the
15
+ * stale-claim staleness boundary in src/policies/file-claims.ts. The
16
+ * field is assumption-typed because the minimal SDK surface here does
17
+ * not declare it; a missing, zero, or non-numeric value fails closed.
18
+ */
19
+ get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown; time?: { idle?: unknown } } }>;
13
20
  promptAsync?: (opts: {
14
21
  path: { id: string };
15
22
  body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }> };
@@ -34,6 +41,14 @@ export interface ProjectConfig {
34
41
  timeoutMs?: number;
35
42
  }>;
36
43
  requireReview?: boolean;
44
+ /**
45
+ * Opt-in strict mode for secondary reviews: when true, record_review
46
+ * rejects approvals recorded by the root session itself, so a passing
47
+ * verdict must come from a subagent session with a parent. Unset or false
48
+ * keeps the documented relay flow where any session may record a verdict
49
+ * that is then bound to the reviewed content.
50
+ */
51
+ requireSubagentReview?: boolean;
37
52
  requireDocumentation?: boolean;
38
53
  /**
39
54
  * Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
@@ -122,6 +137,8 @@ export interface AuditEntry {
122
137
  evidence?: {
123
138
  mutation?: boolean;
124
139
  targetPath?: string;
140
+ /** Owner of a released stale file claim (stale_claim_takeover audits). */
141
+ claimOwnerSessionID?: string;
125
142
  verification?: {
126
143
  command: string;
127
144
  passed: boolean;
@@ -130,6 +147,17 @@ export interface AuditEntry {
130
147
  };
131
148
  allowLive?: boolean;
132
149
  reviewVerdict?: "approved" | "changes_requested" | "rejected";
150
+ evidenceId?: string;
151
+ reviewer?: string;
152
+ reviewTimestamp?: number;
153
+ binding?: {
154
+ workspace?: string;
155
+ commitHash?: string;
156
+ worktreeFingerprint?: string;
157
+ recorderSessionID?: string;
158
+ recorderRole?: "root" | "subagent";
159
+ targetSessionID?: string;
160
+ };
133
161
  };
134
162
  }
135
163
 
@@ -55,10 +55,11 @@ import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTod
55
55
  import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
56
56
  import { checkCompletionClaims } from "../policies/completion.ts";
57
57
  import { releaseFileClaims } from "../policies/file-claims.ts";
58
- import { beginReadObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
58
+ import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
59
59
  import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
60
60
  import { audit, summarizeInput } from "./audit.ts";
61
61
  import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
62
+ import { loadedPluginVersion } from "./version.ts";
62
63
  import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
63
64
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
64
65
  import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
@@ -175,7 +176,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
175
176
  }
176
177
 
177
178
  try {
178
- console.log(`[workflow-guard] info: Workflow Guard plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
179
+ console.log(`[workflow-guard] info: Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
179
180
  } catch {}
180
181
 
181
182
  const toolLifecycle = new ToolInvocationLifecycle();
@@ -281,6 +282,9 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
281
282
  const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
282
283
  if (!pending) return;
283
284
  await runWithRuntimeState(pending.root, client, async () => {
285
+ // Seed the session's observation of the bytes it just mutated so a
286
+ // follow-up edit needs no redundant re-read (V1 parity).
287
+ for (const before of pending.snapshots) recordMutationObservation(before.path, event.sessionID, before.digest);
284
288
  const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
285
289
  const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
286
290
  if (!report) return;
package/src/lib/verify.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, type Hash } from "node:crypto";
3
3
  import { lstatSync, readFileSync, readlinkSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
@@ -141,6 +141,35 @@ function warnFingerprintSkip(root: string, file: string, detail: string): void {
141
141
  } catch {}
142
142
  }
143
143
 
144
+ /**
145
+ * Content hash over tracked repository content only: the index listing
146
+ * (staged/tracked blob hashes) plus the worktree-vs-index diff (unstaged
147
+ * edits). Review evidence binds to this fingerprint, not to the full one:
148
+ * untracked scratch files are not part of the reviewed diff, so creating,
149
+ * modifying, or deleting them must never invalidate a recorded approval.
150
+ */
151
+ function trackedContentHash(root: string): Hash | undefined {
152
+ const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
153
+ cwd: root,
154
+ encoding: "utf8",
155
+ timeout: 5_000,
156
+ });
157
+ const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
158
+ cwd: root,
159
+ encoding: "utf8",
160
+ timeout: 5_000,
161
+ });
162
+ if (staged.status !== 0 || unstaged.status !== 0) return undefined;
163
+ return createHash("sha256").update(staged.stdout).update(unstaged.stdout);
164
+ }
165
+
166
+ export function getTrackedWorktreeFingerprint(root: string): string | undefined {
167
+ try {
168
+ return trackedContentHash(root)?.digest("hex");
169
+ } catch {}
170
+ return undefined;
171
+ }
172
+
144
173
  export function getGitWorktreeFingerprint(root: string): string | undefined {
145
174
  try {
146
175
  // Index listing captures the exact staged/tracked content (blob hashes);
@@ -148,23 +177,14 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
148
177
  // contents cover files git does not yet track. Together these bind the
149
178
  // fingerprint to file contents regardless of whether HEAD exists, so
150
179
  // freshly initialized (commitless) repositories are covered too.
151
- const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
152
- cwd: root,
153
- encoding: "utf8",
154
- timeout: 5_000,
155
- });
156
- const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
157
- cwd: root,
158
- encoding: "utf8",
159
- timeout: 5_000,
160
- });
180
+ const hash = trackedContentHash(root);
181
+ if (!hash) return undefined;
161
182
  const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
162
183
  cwd: root,
163
184
  encoding: "utf8",
164
185
  timeout: 5_000,
165
186
  });
166
- if (staged.status !== 0 || unstaged.status !== 0 || untracked.status !== 0) return undefined;
167
- const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
187
+ if (untracked.status !== 0) return undefined;
168
188
  for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
169
189
  // The path itself is always part of the fingerprint, so a skipped
170
190
  // entry still binds its presence even when its contents do not.
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * The version of the package this module was loaded from, read from the
6
+ * adjacent package.json (the package ships TypeScript source, so the manifest
7
+ * always sits two levels above src/). Fail-soft: an unreadable or malformed
8
+ * manifest (for example a bundled or relocated copy without package.json)
9
+ * yields undefined, and callers surface that absence rather than guessing.
10
+ * This exists because OpenCode resolves configured bare package names through
11
+ * Node's parent-directory lookup from its configuration location, so the
12
+ * loaded copy can silently differ from a globally npm-installed one.
13
+ */
14
+ export function loadedPluginVersion(moduleUrl: string = import.meta.url): string | undefined {
15
+ try {
16
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", moduleUrl)), "utf8")) as { version?: unknown };
17
+ return typeof manifest.version === "string" && manifest.version ? manifest.version : undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
@@ -1,5 +1,5 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
1
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, join, resolve, sep } from "node:path";
5
5
  import { getWorkspaceRoot } from "./state.ts";
@@ -63,6 +63,89 @@ function localBranchExists(branch: string, root: string): boolean {
63
63
  return res.status === 0;
64
64
  }
65
65
 
66
+ /**
67
+ * Worktree timeout: WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS env wins, then the
68
+ * 15s default. Large checkouts can exceed 15s; without this the killed
69
+ * `git worktree add` left the branch behind with only its stderr progress
70
+ * text as the reported reason.
71
+ */
72
+ export function resolveWorktreeTimeoutMs(): number {
73
+ const envValue = Number(process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS);
74
+ return Number.isFinite(envValue) && envValue > 0 ? envValue : 15_000;
75
+ }
76
+
77
+ /** git exit status is the failure signal; stderr is only context (progress lines land there too). */
78
+ function describeGitFailure(operation: string, res: SpawnSyncReturns<string>): string {
79
+ const status = res.status === null
80
+ ? `null (killed${res.signal ? ` via ${res.signal}` : ""}${res.error ? `: ${res.error.message}` : ""})`
81
+ : String(res.status);
82
+ const stderrTail = (res.stderr ?? "").trim().split("\n").filter(Boolean).slice(-3).join("; ");
83
+ return `git ${operation} failed (exit status ${status})${stderrTail ? `: ${stderrTail}` : ""}`;
84
+ }
85
+
86
+ /**
87
+ * Rolls back the partial state of a failed `git worktree add`. git creates
88
+ * the branch ref before the worktree checkout, so a failure after that point
89
+ * (target path collision, killed checkout, hook error) leaves a dangling
90
+ * branch and possibly stale worktree admin entries behind. Deletion of the
91
+ * branch only happens when this call created it.
92
+ */
93
+ function rollbackFailedWorktreeAdd(options: { branch: string; branchCreatedByUs: boolean; targetPath: string; existedBefore: boolean; root: string }): string {
94
+ const { branch, branchCreatedByUs, targetPath, existedBefore, root } = options;
95
+ const notes: string[] = [];
96
+ try {
97
+ const registered = (registeredWorktreePaths(root) ?? []).map((path) => resolve(path));
98
+ const targetRegistered = registered.includes(resolve(targetPath));
99
+ if (targetRegistered && !existedBefore && existsSync(targetPath)) {
100
+ // Only a worktree this call registered may be force-removed. A
101
+ // pre-existing registered worktree at the same sanitized path
102
+ // (duplicate name, concurrent subagents, feat/x vs feat:x) is the
103
+ // reason the add failed - it must never be destroyed by rollback.
104
+ spawnSync("git", ["worktree", "remove", "--force", targetPath], {
105
+ cwd: root,
106
+ env: getCleanGitEnv(),
107
+ encoding: "utf8",
108
+ timeout: 10_000,
109
+ });
110
+ }
111
+ if (!existedBefore) {
112
+ // Clear stale worktree admin entries left by an interrupted add for
113
+ // this new path. Scoped to the new-path case so unrelated
114
+ // worktrees on temporarily unavailable paths are never pruned.
115
+ spawnSync("git", ["worktree", "prune"], {
116
+ cwd: root,
117
+ env: getCleanGitEnv(),
118
+ encoding: "utf8",
119
+ timeout: 10_000,
120
+ });
121
+ }
122
+ if (!existedBefore && existsSync(targetPath)) {
123
+ // git may have partially populated the directory before failing.
124
+ // It did not exist before this call, so removing it is safe; a
125
+ // pre-existing path (e.g. the reason the add failed) is left alone.
126
+ try { rmSync(targetPath, { recursive: true, force: true }); } catch {}
127
+ }
128
+ if (branchCreatedByUs) {
129
+ // After the deregistration above: a branch cannot be deleted while
130
+ // still checked out in a worktree.
131
+ const deleteRes = spawnSync("git", ["branch", "-D", branch], {
132
+ cwd: root,
133
+ env: getCleanGitEnv(),
134
+ encoding: "utf8",
135
+ timeout: 5_000,
136
+ });
137
+ if (deleteRes.status !== 0) {
138
+ notes.push(`the created branch '${branch}' could not be rolled back and was left in place; delete it manually if it is not needed`);
139
+ }
140
+ }
141
+ } catch {
142
+ if (branchCreatedByUs) {
143
+ notes.push(`the created branch '${branch}' may remain; delete it manually if it is not needed`);
144
+ }
145
+ }
146
+ return notes.join("; ");
147
+ }
148
+
66
149
  export function createGitWorktree(
67
150
  branch: string,
68
151
  baseBranch = "HEAD",
@@ -77,12 +160,13 @@ export function createGitWorktree(
77
160
 
78
161
  const storageBase = getWorktreeStorageDir(root);
79
162
  const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
163
+ const existedBefore = existsSync(targetPath);
80
164
 
81
165
  try {
82
166
  mkdirSync(storageBase, { recursive: true });
83
167
 
84
- const exists = localBranchExists(branch, root);
85
- const gitArgs = exists
168
+ const branchAlreadyExisted = localBranchExists(branch, root);
169
+ const gitArgs = branchAlreadyExisted
86
170
  ? ["worktree", "add", targetPath, branch]
87
171
  : ["worktree", "add", "-b", branch, targetPath, baseBranch];
88
172
 
@@ -90,10 +174,12 @@ export function createGitWorktree(
90
174
  cwd: root,
91
175
  env: getCleanGitEnv(),
92
176
  encoding: "utf8",
93
- timeout: 15_000,
177
+ timeout: resolveWorktreeTimeoutMs(),
94
178
  });
95
179
  if (addRes.status !== 0) {
96
- return { success: false, error: addRes.stderr.trim() || `git worktree add failed` };
180
+ const rollbackNote = rollbackFailedWorktreeAdd({ branch, branchCreatedByUs: !branchAlreadyExisted, targetPath, existedBefore, root });
181
+ const error = describeGitFailure("worktree add", addRes) + (rollbackNote ? `; ${rollbackNote}` : "");
182
+ return { success: false, error };
97
183
  }
98
184
 
99
185
  // Symlink the parent's node_modules so tooling works without a fresh install.
@@ -131,16 +217,44 @@ export function cleanupGitWorktree(
131
217
  root = getWorkspaceRoot(),
132
218
  ): { success: boolean; error?: string } {
133
219
  try {
220
+ const resolved = resolve(worktreePath);
221
+ const storageBase = resolve(getWorktreeStorageDir(root));
134
222
  if (!existsSync(worktreePath)) {
135
- return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
223
+ // A registered worktree whose directory is gone (manual removal, a
224
+ // crashed add, external cleanup) still holds a stale admin entry that
225
+ // keeps it in `git worktree list` as prunable, and must still be
226
+ // cleanable. Anything outside storage or unregistered is refused.
227
+ // The primary working tree is never at risk: prune only removes
228
+ // admin dirs under .git/worktrees/<id>, which the main tree lacks.
229
+ if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
230
+ return {
231
+ success: false,
232
+ error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
233
+ };
234
+ }
235
+ const registeredMissing = registeredWorktreePaths(root);
236
+ if (!registeredMissing) {
237
+ return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
238
+ }
239
+ if (!registeredMissing.map((path) => resolve(path)).includes(resolved)) {
240
+ return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
241
+ }
242
+ const prune = spawnSync("git", ["worktree", "prune"], {
243
+ cwd: root,
244
+ env: getCleanGitEnv(),
245
+ encoding: "utf8",
246
+ timeout: 10_000,
247
+ });
248
+ if (prune.status !== 0) {
249
+ return { success: false, error: describeGitFailure("worktree prune", prune) };
250
+ }
251
+ return { success: true };
136
252
  }
137
253
 
138
254
  // Ownership validation: the path must live under the configured worktree
139
255
  // storage directory AND be a registered worktree of this repository. This
140
256
  // refuses arbitrary directories, other repos' worktrees, and the primary
141
257
  // working tree - cleanup must never be an unrestricted delete primitive.
142
- const resolved = resolve(worktreePath);
143
- const storageBase = resolve(getWorktreeStorageDir(root));
144
258
  if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
145
259
  return {
146
260
  success: false,
@@ -53,6 +53,33 @@ export function expandShellTargetPath(targetPath: string): string | null {
53
53
  return out;
54
54
  }
55
55
 
56
+ /**
57
+ * A mutation/redirect target whose expansion is indeterminate (it contains an
58
+ * unresolvable `$VARIABLE` reference): the boundary treats such destinations
59
+ * as outside the workspace, and block messages attribute that cause instead
60
+ * of the generic escape message.
61
+ */
62
+ export function hasUnresolvableVariable(targetPath: string): boolean {
63
+ return expandShellTargetPath(targetPath) === null;
64
+ }
65
+
66
+ /**
67
+ * The OpenCode harness designates this directory as the agents' scratch
68
+ * space ("pre-created and approved for external access"), but the workspace
69
+ * boundary is workspace-absolute and does not cover it. Targets under it get
70
+ * a cause-attributed block message so agents stop treating the block as a
71
+ * guard bug and write scratch as untracked files inside the workspace
72
+ * instead. Message-only: the boundary decision itself is unchanged.
73
+ */
74
+ export const OPENCODE_SCRATCH_DIR = "/tmp/opencode";
75
+
76
+ export function isEnvironmentScratchTarget(targetPath: string, root: string): boolean {
77
+ const expanded = expandShellTargetPath(targetPath);
78
+ if (expanded === null) return false;
79
+ const resolved = resolve(root, expanded);
80
+ return resolved === OPENCODE_SCRATCH_DIR || resolved.startsWith(OPENCODE_SCRATCH_DIR + "/");
81
+ }
82
+
56
83
  export function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
57
84
  if (!targetPath) return false;
58
85
  const expanded = expandShellTargetPath(targetPath);
@@ -372,6 +399,9 @@ export async function guardShellMutation(
372
399
  return PROTECTED_PATH_REASON;
373
400
  }
374
401
  if (isPathOutsideWorkspace(source, root)) {
402
+ if (hasUnresolvableVariable(source)) {
403
+ return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
404
+ }
375
405
  return `Blocked: mv would remove source '${source}' from outside the workspace root (${root}). File mutations must stay within the workspace.`;
376
406
  }
377
407
  }
@@ -403,6 +433,12 @@ export async function guardShellMutation(
403
433
  // The workspace boundary has no override: a write outside the
404
434
  // workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
405
435
  // (that override covers live-system commands, not the boundary).
436
+ if (hasUnresolvableVariable(target)) {
437
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
438
+ }
439
+ if (isEnvironmentScratchTarget(target, root)) {
440
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${root}) instead (for example .tmp-notes.md).`;
441
+ }
406
442
  return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${root}). All changes must stay within the workspace.`;
407
443
  }
408
444
  if (onProtectedBranch(root)) {
@@ -1,9 +1,13 @@
1
1
  import { readlinkSync, realpathSync } from "node:fs";
2
2
  import { dirname, relative, resolve } from "node:path";
3
+ import { audit } from "../lib/audit.ts";
4
+ import { getSdkClient } from "../lib/state.ts";
3
5
 
4
6
  interface FileClaim {
5
7
  sessionID: string;
6
8
  callID: string;
9
+ /** Creation timestamp; a claim older than its owner's last idle is stale. */
10
+ ts: number;
7
11
  }
8
12
 
9
13
  const claims = new Map<string, FileClaim>();
@@ -36,8 +40,14 @@ export function claimFiles(paths: string[], sessionID: string, callID: string):
36
40
  const conflict = fileClaimConflictReason(canonical, sessionID);
37
41
  if (conflict) return conflict;
38
42
  for (const path of canonical) {
39
- if (!claims.has(path)) {
40
- claims.set(path, { sessionID, callID });
43
+ const existing = claims.get(path);
44
+ if (!existing) {
45
+ claims.set(path, { sessionID, callID, ts: Date.now() });
46
+ } else if (existing.sessionID === sessionID) {
47
+ // The same session re-claiming the path refreshes the claim so the
48
+ // timestamp (and callID) reflect the in-flight call: stale-takeover
49
+ // never fires for it, and the after-hook release matches.
50
+ claims.set(path, { sessionID, callID, ts: Date.now() });
41
51
  }
42
52
  }
43
53
  return undefined;
@@ -54,6 +64,62 @@ export function fileClaimConflictReason(paths: string[], sessionID: string): str
54
64
  return undefined;
55
65
  }
56
66
 
67
+ /**
68
+ * A claim is normally released by its owning tool call (after-hook) or by the
69
+ * owner's session.idle/session.deleted events. When an idle release is missed,
70
+ * the claim would otherwise block every other session forever. A claim created
71
+ * BEFORE its owner's last recorded idle timestamp cannot belong to an
72
+ * in-flight tool call — that call finished before the turn ended — so it is
73
+ * provably stale and is released with an audited takeover. Without an SDK
74
+ * client, or when the owner record cannot be read, nothing is released:
75
+ * fail-closed keeps live two-session protection intact. Clock skew between
76
+ * this process and the server is not expected for a local (same-host)
77
+ * service: negative skew fails closed; forward skew could make fresh claims
78
+ * look stale and is accepted for that deployment. Concurrent claimants can
79
+ * emit duplicate takeover audits (append-only, harmless). Release is
80
+ * per-claim: only claims whose ts is provably stale are dropped, re-read
81
+ * AFTER the lookup round-trip so an owner that re-claimed (and refreshed) a
82
+ * path meanwhile keeps its live claim.
83
+ */
84
+ export async function releaseStaleFileClaims(paths: string[], claimantSessionID: string): Promise<void> {
85
+ const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
86
+ const staleOwners = new Map<string, string>();
87
+ for (const path of canonical) {
88
+ const existing = claims.get(path);
89
+ if (existing && existing.sessionID !== claimantSessionID && !staleOwners.has(existing.sessionID)) {
90
+ staleOwners.set(existing.sessionID, path);
91
+ }
92
+ }
93
+ if (staleOwners.size === 0) return;
94
+ const client = getSdkClient();
95
+ const session = client?.session;
96
+ const get = session?.get;
97
+ if (!session || typeof get !== "function") return;
98
+ for (const [ownerSessionID] of staleOwners) {
99
+ try {
100
+ const result = await get.call(session, { path: { id: ownerSessionID } });
101
+ const idle = (result as { data?: { time?: { idle?: unknown } } } | undefined)?.data?.time?.idle;
102
+ if (typeof idle !== "number" || idle <= 0) continue;
103
+ // Release every claim of this owner that is provably stale. Each
104
+ // claim is re-read after the lookup round-trip so an owner that
105
+ // re-claimed (and refreshed) a path meanwhile keeps its live claim.
106
+ for (const [claimPath, claim] of [...claims]) {
107
+ if (claim.sessionID !== ownerSessionID || claim.ts >= idle) continue;
108
+ claims.delete(claimPath);
109
+ audit({
110
+ ts: new Date().toISOString(),
111
+ sessionID: claimantSessionID,
112
+ tool: "file-claims",
113
+ decision: "allow",
114
+ phase: "event",
115
+ reason: "stale_claim_takeover",
116
+ evidence: { mutation: false, targetPath: claimPath, claimOwnerSessionID: ownerSessionID },
117
+ });
118
+ }
119
+ } catch {}
120
+ }
121
+ }
122
+
57
123
  export function releaseFileClaims(sessionID: string, callID?: string): void {
58
124
  for (const [path, claim] of claims) {
59
125
  if (claim.sessionID === sessionID && (callID === undefined || claim.callID === callID)) {