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.
@@ -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);
@@ -14,9 +14,10 @@ import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { join } from "node:path";
17
+ import { loadedPluginVersion } from "./lib/version.ts";
17
18
  import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
18
19
  import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
19
- import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
20
+ import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
20
21
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
21
22
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
22
23
  import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -70,6 +71,7 @@ import {
70
71
  getOperationProfile,
71
72
  isRecoveryCheckpointsEnabled,
72
73
  isReviewRequired,
74
+ isSubagentReviewRequired,
73
75
  isDocumentationRequired,
74
76
  getSubagentMutationBudget,
75
77
  isLearningEnabled,
@@ -103,6 +105,7 @@ export {
103
105
  getOperationProfile,
104
106
  isRecoveryCheckpointsEnabled,
105
107
  isReviewRequired,
108
+ isSubagentReviewRequired,
106
109
  isDocumentationRequired,
107
110
  getSubagentMutationBudget,
108
111
  isLearningEnabled,
@@ -189,6 +192,8 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
189
192
  : "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
190
193
  }
191
194
 
195
+ export { loadedPluginVersion } from "./lib/version.ts";
196
+
192
197
  // ── Verification engine ──────────────────────────────────────────────────────
193
198
  import {
194
199
  detectVerifyCommand,
@@ -198,6 +203,7 @@ import {
198
203
  getCurrentGitCommitHash,
199
204
  getGitStatusSummary,
200
205
  getGitWorktreeFingerprint,
206
+ getTrackedWorktreeFingerprint,
201
207
  } from "./lib/verify.ts";
202
208
 
203
209
  export {
@@ -208,6 +214,7 @@ export {
208
214
  getCurrentGitCommitHash,
209
215
  getGitStatusSummary,
210
216
  getGitWorktreeFingerprint,
217
+ getTrackedWorktreeFingerprint,
211
218
  };
212
219
 
213
220
  // ── Secondary review rubric ──────────────────────────────────────────────────
@@ -221,6 +228,7 @@ import {
221
228
  isValidBranchName,
222
229
  createGitWorktree,
223
230
  cleanupGitWorktree,
231
+ resolveWorktreeTimeoutMs,
224
232
  } from "./lib/worktree.ts";
225
233
 
226
234
  export {
@@ -229,6 +237,7 @@ export {
229
237
  isValidBranchName,
230
238
  createGitWorktree,
231
239
  cleanupGitWorktree,
240
+ resolveWorktreeTimeoutMs,
232
241
  };
233
242
 
234
243
  // ── Policy modules ───────────────────────────────────────────────────────────
@@ -427,7 +436,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
427
436
  body: {
428
437
  service: "workflow-guard",
429
438
  level: "info",
430
- message: `Workflow Guard plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
439
+ message: `Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
431
440
  },
432
441
  });
433
442
  } catch {}
@@ -514,6 +523,12 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
514
523
  const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
515
524
  if (!pending) return;
516
525
  await runWithRuntimeState(pending.root, ctx.client, async () => {
526
+ // A successful mutation means this session authored the resulting
527
+ // bytes, so seed its observation: a follow-up edit to a just
528
+ // created/modified file must not demand a redundant re-read. A
529
+ // no-op/failed call (content unchanged) seeds nothing, and an
530
+ // external change after the mutation still fails the comparison.
531
+ for (const before of pending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
517
532
  const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
518
533
  const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
519
534
  if (report) output.output = `${output.output}\n\n${report}`;