opencode-workflow-guard 1.14.0 → 1.14.1

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.
@@ -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,
@@ -84,23 +84,43 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
84
84
 
85
85
  const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
86
86
  const PAGER_ADVICE = "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.";
87
- // Process monitoring tokens are matched on whitespace/segment boundaries so that
88
- // an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
89
- // or a quoted argument like `echo "top"` is not mistaken for the `top` command.
90
- const ALWAYS_INTERACTIVE_MONITOR_RE = /(?:^|[;&|\s])(?:htop|btop|atop|glances)(?:$|[;&|\s])/i;
91
- const TOP_COMMAND_RE = /(?:^|[;&|\s])top(?:$|[;&|\s])/i;
87
+ // Monitor tokens are only interactive when they are the command of a
88
+ // segment: a `top` in an argument (a secret name, a grep pattern, `echo
89
+ // top`) is data, not the monitor. Wrappers are stripped first, so `sudo top`
90
+ // and `timeout 5 top` are still caught, and `eval top` / `sh -c top` recurse
91
+ // like the pager check. `top` in batch mode stays allowed. Indirection that
92
+ // this does not model (`watch top`, `xargs top`, `man top`, shell loop bodies
93
+ // such as `while true; do top; done`) is a known, documented limitation.
94
+ const INTERACTIVE_MONITORS = new Set(["top", "htop", "btop", "atop", "glances"]);
92
95
  const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
93
96
 
94
- function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
95
- for (const segment of command.split(/[;&|\n]+/)) {
96
- if (ALWAYS_INTERACTIVE_MONITOR_RE.test(segment)) {
97
- return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
97
+ function containsMonitorCommand(command: string, depth = 0): boolean {
98
+ if (depth >= 16) return false;
99
+ for (const segment of splitShellSegments(command)) {
100
+ const words = unwrapShellWords(segment);
101
+ while (words[0] === "--") words.shift();
102
+ const executable = basename(words[0] ?? "").toLowerCase();
103
+ const candidate = executable === "busybox" ? basename(words[1] ?? "").toLowerCase() : executable;
104
+ if (INTERACTIVE_MONITORS.has(candidate)) {
105
+ // Batch mode is the monitor's own flag, not a wrapper's (e.g. `sudo -b
106
+ // top` backgrounds sudo; it does not put `top` in batch mode).
107
+ const monitorIndex = executable === candidate ? 0 : 1;
108
+ if (candidate === "top" && TOP_BATCH_FLAG_RE.test(words.slice(monitorIndex + 1).join(" "))) continue;
109
+ return true;
98
110
  }
99
- if (TOP_COMMAND_RE.test(segment) && !TOP_BATCH_FLAG_RE.test(segment)) {
100
- return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
111
+ if (executable === "eval" && words.length > 1 && containsMonitorCommand(words.slice(1).join(" "), depth + 1)) return true;
112
+ if (/^(?:ba|z|da|k)?sh$/i.test(executable)) {
113
+ const commandFlag = words.findIndex((word, index) => index > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
114
+ if (commandFlag >= 0 && words[commandFlag + 1] && containsMonitorCommand(words[commandFlag + 1]!, depth + 1)) return true;
101
115
  }
102
116
  }
103
- return { isInteractive: false };
117
+ return false;
118
+ }
119
+
120
+ function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
121
+ return containsMonitorCommand(command)
122
+ ? { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE }
123
+ : { isInteractive: false };
104
124
  }
105
125
 
106
126
  function containsPagerCommand(command: string, depth = 0): boolean {
@@ -51,6 +51,23 @@ export function recordSuccessfulRead(observation: ReadObservation, sessionID: st
51
51
  reads.set(sessionID, sessionReads);
52
52
  }
53
53
 
54
+ /**
55
+ * Records the session's observation of a file it just mutated. A successful
56
+ * edit/write means the session authored the resulting bytes, so a follow-up
57
+ * edit to that file must not demand a redundant re-read. The pre-mutation
58
+ * digest is compared so a no-op or failed call (content unchanged) does not
59
+ * seed an observation, and an external change after the mutation still fails
60
+ * the next comparison.
61
+ */
62
+ export function recordMutationObservation(path: string, sessionID: string, beforeDigest?: string): void {
63
+ const current = fingerprint(path);
64
+ if (!current) return;
65
+ if (beforeDigest !== undefined && current.digest === beforeDigest) return;
66
+ const sessionReads = reads.get(sessionID) ?? new Map<string, ReadFingerprint>();
67
+ sessionReads.set(canonicalPath(path), current);
68
+ reads.set(sessionID, sessionReads);
69
+ }
70
+
54
71
  export function staleWriteReason(path: string, sessionID: string): string | undefined {
55
72
  const current = fingerprint(path);
56
73
  const canonical = canonicalPath(path);
@@ -16,7 +16,7 @@ import { resolve } from "node:path";
16
16
  import { join } from "node:path";
17
17
  import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
18
18
  import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
19
- import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
19
+ import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
20
20
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
21
21
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
22
22
  import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -198,6 +198,7 @@ import {
198
198
  getCurrentGitCommitHash,
199
199
  getGitStatusSummary,
200
200
  getGitWorktreeFingerprint,
201
+ getTrackedWorktreeFingerprint,
201
202
  } from "./lib/verify.ts";
202
203
 
203
204
  export {
@@ -208,6 +209,7 @@ export {
208
209
  getCurrentGitCommitHash,
209
210
  getGitStatusSummary,
210
211
  getGitWorktreeFingerprint,
212
+ getTrackedWorktreeFingerprint,
211
213
  };
212
214
 
213
215
  // ── Secondary review rubric ──────────────────────────────────────────────────
@@ -221,6 +223,7 @@ import {
221
223
  isValidBranchName,
222
224
  createGitWorktree,
223
225
  cleanupGitWorktree,
226
+ resolveWorktreeTimeoutMs,
224
227
  } from "./lib/worktree.ts";
225
228
 
226
229
  export {
@@ -229,6 +232,7 @@ export {
229
232
  isValidBranchName,
230
233
  createGitWorktree,
231
234
  cleanupGitWorktree,
235
+ resolveWorktreeTimeoutMs,
232
236
  };
233
237
 
234
238
  // ── Policy modules ───────────────────────────────────────────────────────────
@@ -514,6 +518,12 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
514
518
  const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
515
519
  if (!pending) return;
516
520
  await runWithRuntimeState(pending.root, ctx.client, async () => {
521
+ // A successful mutation means this session authored the resulting
522
+ // bytes, so seed its observation: a follow-up edit to a just
523
+ // created/modified file must not demand a redundant re-read. A
524
+ // no-op/failed call (content unchanged) seeds nothing, and an
525
+ // external change after the mutation still fails the comparison.
526
+ for (const before of pending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
517
527
  const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
518
528
  const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
519
529
  if (report) output.output = `${output.output}\n\n${report}`;