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.
- package/docs/agents/lessons.md +3 -0
- package/docs/installation.md +4 -0
- package/docs/policies.md +6 -3
- package/docs/troubleshooting.md +5 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +20 -6
- package/src/lib/guard-dispatcher.ts +61 -8
- package/src/lib/hygiene.ts +66 -0
- package/src/lib/state.ts +7 -0
- package/src/lib/types.ts +18 -1
- package/src/lib/v2-plugin.ts +2 -1
- package/src/lib/version.ts +21 -0
- package/src/policies/boundary.ts +36 -0
- package/src/policies/file-claims.ts +68 -2
- package/src/policies/git.ts +134 -11
- package/src/workflow-guard-ui.ts +1 -2
- package/src/workflow-guard.ts +6 -1
- package/test/test.mts +450 -9
|
@@ -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
|
-
|
|
40
|
-
|
|
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)) {
|
package/src/policies/git.ts
CHANGED
|
@@ -335,29 +335,152 @@ export function checkMergeConflicts(root: string): {
|
|
|
335
335
|
return { hasConflicts: false };
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
}
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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 {}
|