opencode-workflow-guard 1.14.1 → 1.15.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,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)) {
@@ -335,29 +335,152 @@ export function checkMergeConflicts(root: string): {
335
335
  return { hasConflicts: false };
336
336
  }
337
337
 
338
- export function checkBranchBaseIsUpToDate(root: string): {
338
+ const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
339
+ "-f",
340
+ "--force",
341
+ "--discard-changes",
342
+ "-m",
343
+ "--merge",
344
+ "-q",
345
+ "--quiet",
346
+ "--no-track",
347
+ ]);
348
+
349
+ // Classifies the optional start-point operand of an already-matched branch
350
+ // creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
351
+ // the explicit start point when present, `{ start: undefined }` when the
352
+ // invocation creates a branch from the current HEAD, and `undefined` when the
353
+ // shape cannot be confidently classified — the caller must then fail closed to
354
+ // the HEAD-based staleness check. Indeterminate tokens (variables, quotes,
355
+ // unknown or value-taking flags) are never classified.
356
+ export function branchCreationStartPoint(rest: string): { start?: string } | undefined {
357
+ const tokens = rest.split(/\s+/).filter(Boolean);
358
+ let sawCreate = false;
359
+ let named = false;
360
+ for (const token of tokens) {
361
+ const indeterminate = /[$`"']/.test(token);
362
+ if (!sawCreate) {
363
+ if (token === "switch" || token === "checkout") continue;
364
+ if (token === "-c" || token === "--create" || token === "-b") {
365
+ sawCreate = true;
366
+ continue;
367
+ }
368
+ if (token.startsWith("--create=")) {
369
+ sawCreate = true;
370
+ named = true;
371
+ continue;
372
+ }
373
+ if (!token.startsWith("--") && token.length > 2 && (token.startsWith("-c") || token.startsWith("-b"))) {
374
+ sawCreate = true;
375
+ named = true;
376
+ continue;
377
+ }
378
+ if (token.startsWith("-")) {
379
+ if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
380
+ return undefined;
381
+ }
382
+ return undefined;
383
+ }
384
+ if (!named) {
385
+ if (token.startsWith("-")) {
386
+ if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
387
+ return undefined;
388
+ }
389
+ if (indeterminate) return undefined;
390
+ named = true;
391
+ continue;
392
+ }
393
+ if (token.startsWith("-")) {
394
+ if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
395
+ return undefined;
396
+ }
397
+ if (indeterminate) return undefined;
398
+ return { start: token };
399
+ }
400
+ return sawCreate ? { start: undefined } : undefined;
401
+ }
402
+
403
+ // A branch created from an explicit start point that already contains the
404
+ // remote default is fresh by construction, so the current branch's staleness
405
+ // is irrelevant for it; every provided start point must resolve and contain
406
+ // the base, and any missing start point keeps the HEAD-based check. Start
407
+ // points that cannot be resolved fail closed. Block reasons name what was
408
+ // actually checked and remedies that work under the guard.
409
+ function evaluateBranchCreationStarts(
410
+ root: string,
411
+ base: string,
412
+ startPoints: ReadonlyArray<string>,
413
+ ): { isBehind: true; baseRef: string; reason: string } | undefined {
414
+ for (const startPoint of startPoints) {
415
+ const resolved = spawnSync("git", ["rev-parse", "--verify", "--quiet", startPoint], {
416
+ cwd: root,
417
+ encoding: "utf8",
418
+ timeout: 5_000,
419
+ });
420
+ if (resolved.status !== 0 || !resolved.stdout.trim()) {
421
+ return {
422
+ isBehind: true,
423
+ baseRef: base,
424
+ reason: `the explicit start point '${startPoint}' could not be resolved as a commit, so freshness cannot be classified (fail closed). Use a literal branch name or ref as the start point.`,
425
+ };
426
+ }
427
+ const startSha = resolved.stdout.trim();
428
+ const contains = spawnSync("git", ["merge-base", "--is-ancestor", base, startSha], {
429
+ cwd: root,
430
+ encoding: "utf8",
431
+ timeout: 5_000,
432
+ });
433
+ if (contains.status !== 0) {
434
+ const behind = spawnSync("git", ["rev-list", `${startSha}..${base}`, "--count"], {
435
+ cwd: root,
436
+ encoding: "utf8",
437
+ timeout: 5_000,
438
+ });
439
+ const behindCount = parseInt((behind.stdout ?? "").trim(), 10);
440
+ return {
441
+ isBehind: true,
442
+ baseRef: base,
443
+ reason: `the explicit start point '${startPoint}' is ${isNaN(behindCount) ? "" : `${behindCount} `}commit(s) behind ${base}. Create the branch from ${base}, or from any ref that contains it, instead.`,
444
+ };
445
+ }
446
+ }
447
+ return undefined;
448
+ }
449
+
450
+ export function checkBranchBaseIsUpToDate(
451
+ root: string,
452
+ startPoints?: ReadonlyArray<string | undefined>,
453
+ ): {
339
454
  isBehind: boolean;
340
455
  count?: number;
341
456
  baseRef?: string;
342
457
  reason?: string;
343
458
  } {
459
+ const classified = startPoints && startPoints.length > 0 ? startPoints : undefined;
344
460
  for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
345
461
  const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
346
462
  cwd: root,
347
463
  encoding: "utf8",
348
464
  timeout: 5_000,
349
465
  });
350
- if (res.status === 0 && res.stdout.trim()) {
351
- const count = parseInt(res.stdout.trim(), 10);
352
- if (!isNaN(count) && count > 0) {
353
- return {
354
- isBehind: true,
355
- count,
356
- baseRef: base,
357
- reason: `Local base branch is ${count} commit(s) behind remote (${base}). Run 'git pull' or 'git fetch' on main before creating a fresh feature branch to prevent upstream conflicts.`,
358
- };
359
- }
466
+ if (res.status !== 0 || !res.stdout.trim()) continue;
467
+ const count = parseInt(res.stdout.trim(), 10);
468
+ if (isNaN(count)) continue;
469
+ const explicit = (classified ?? []).filter((startPoint): startPoint is string => startPoint !== undefined);
470
+ if (explicit.length > 0) {
471
+ const startBlock = evaluateBranchCreationStarts(root, base, explicit);
472
+ if (startBlock) return startBlock;
473
+ }
474
+ if (count > 0 && (!classified || classified.some((startPoint) => startPoint === undefined))) {
475
+ const branch = currentGitBranch(root) || "detached HEAD";
476
+ return {
477
+ isBehind: true,
478
+ count,
479
+ baseRef: base,
480
+ reason: `current branch '${branch}' is ${count} commit(s) behind remote default (${base}), so a branch created from it starts stale. Update this branch first (git rebase ${base} or git merge --ff-only ${base}), or create the new branch from ${base} directly.`,
481
+ };
360
482
  }
483
+ return { isBehind: false };
361
484
  }
362
485
  return { isBehind: false };
363
486
  }
@@ -2,7 +2,7 @@
2
2
  * Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
3
3
  *
4
4
  * - Registers the `/guard-options` palette + slash command to toggle project options.
5
- * - Renders the Workflow Guard badge in the home and prompt footer status slots.
5
+ * - Renders the Workflow Guard badge in the prompt footer status slot.
6
6
  *
7
7
  * The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
8
8
  */
@@ -277,7 +277,6 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
277
277
  },
278
278
  });
279
279
  };
280
- registerBadgeSlot("home.footer.status");
281
280
  registerBadgeSlot("prompt.footer.status");
282
281
  diagRender("probe-after-setup");
283
282
 
@@ -14,6 +14,7 @@ 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
20
  import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.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,
@@ -431,7 +436,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
431
436
  body: {
432
437
  service: "workflow-guard",
433
438
  level: "info",
434
- message: `Workflow Guard plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
439
+ message: `Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
435
440
  },
436
441
  });
437
442
  } catch {}