opencode-workflow-guard 1.2.1 → 1.3.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/README.md +1 -0
- package/package.json +1 -1
- package/src/policies/completion.ts +90 -0
- package/src/workflow-guard.ts +97 -2
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ opencode-workflow-guard/
|
|
|
79
79
|
| **21** | **Documentation Synchronization** | Verifies documentation is updated when introducing new public features or policies. |
|
|
80
80
|
| **22** | **TTY Hang Guard** | Blocks interactive editors (`vim`, `nano`), pagers (`less`), and commands missing non-interactive flags (`npm init` without `-y`). |
|
|
81
81
|
| **23** | **Package Supply-Chain Hygiene** | Blocks destructive `npm audit fix --force`, global unversioned installs (`npm i -g`), and direct CLI `npm publish`. |
|
|
82
|
+
| **24** | **Completion Claims vs Evidence** | When the final response asserts completion/passing tests, the claim is compared against recorded verification evidence; failing, stale, or missing evidence is journaled (observability, never blocks). Also enriches `todowrite`'s tool description with the finalization-gate note via `tool.definition`. |
|
|
82
83
|
|
|
83
84
|
👉 **For complete policy specifications and override rules, see [docs/policies.md](docs/policies.md).**
|
|
84
85
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { sessionVerifyResults, lastVerify, sessionMutationTimestamps, lastMutationTimestamp } from "../lib/state.ts";
|
|
2
|
+
import type { VerifyResult } from "../lib/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claims-vs-evidence check (Policy 24): when the assistant's final text
|
|
6
|
+
* asserts that work is complete or that tests pass, compare the claim against
|
|
7
|
+
* recorded verification evidence. This is observability, not gating - the
|
|
8
|
+
* hook audits mismatches so a confident wrap-up cannot silently contradict a
|
|
9
|
+
* failing or stale verification state.
|
|
10
|
+
*
|
|
11
|
+
* Heuristic by design: natural-language claims are matched on explicit
|
|
12
|
+
* completion/verification phrases. Only high-signal assertions
|
|
13
|
+
* ("all tests pass", "done", "verified", "green build") count; casual usage
|
|
14
|
+
* of the same words ("testing the fix", "it passed earlier") should not trip
|
|
15
|
+
* the detector on multi-sentence responses without assertion framing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const COMPLETION_CLAIM_RE =
|
|
19
|
+
/\b(?:all\s+(?:tests?|checks?)\s+(?:pass(?:es|ed|ing)?|green|succeed(?:ed|ing)?)|tests?\s+(?:are\s+)?(?:all\s+)?(?:pass(?:ing|ed)?|green)|build\s+(?:is\s+)?green|(?:successfully\s+)?verif(?:ied|ication\s+(?:passed|succeeded|is\s+clean))|(?:work|task|implementation)\s+is\s+(?:complete|done|finished)|everything\s+(?:is\s+)?(?:done|complete|works)|(?:all|every)\s+(?:task|item)s?\s+(?:is|are)\s+(?:done|complete(?:d)?))\b/i;
|
|
20
|
+
|
|
21
|
+
const VERIFICATION_FRESH_WINDOW_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
export interface CompletionClaimCheck {
|
|
24
|
+
claimsCompletion: boolean;
|
|
25
|
+
claim?: string;
|
|
26
|
+
evidenceState?: "fresh-pass" | "stale-pass" | "failing" | "missing";
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function checkCompletionClaims(
|
|
31
|
+
text: string,
|
|
32
|
+
context?: { sessionID?: string },
|
|
33
|
+
): CompletionClaimCheck {
|
|
34
|
+
if (!text) return { claimsCompletion: false };
|
|
35
|
+
|
|
36
|
+
const match = text.match(COMPLETION_CLAIM_RE);
|
|
37
|
+
if (!match) return { claimsCompletion: false };
|
|
38
|
+
|
|
39
|
+
// Session-scoped evidence wins when the session has its own entry OR has
|
|
40
|
+
// recorded mutations (evidence was invalidated); otherwise fall back to the
|
|
41
|
+
// most recent global result.
|
|
42
|
+
const sessionHadEvidence =
|
|
43
|
+
context?.sessionID !== undefined &&
|
|
44
|
+
(sessionVerifyResults.has(context.sessionID) ||
|
|
45
|
+
(sessionMutationTimestamps.get(context.sessionID) ?? 0) > 0);
|
|
46
|
+
const verifyResult: VerifyResult | undefined = context?.sessionID
|
|
47
|
+
? (sessionVerifyResults.get(context.sessionID) ??
|
|
48
|
+
(sessionHadEvidence ? undefined : lastVerify))
|
|
49
|
+
: lastVerify;
|
|
50
|
+
|
|
51
|
+
let evidenceState: CompletionClaimCheck["evidenceState"];
|
|
52
|
+
const mutationTimestamp = context?.sessionID
|
|
53
|
+
? (sessionMutationTimestamps.get(context.sessionID) ?? 0)
|
|
54
|
+
: lastMutationTimestamp;
|
|
55
|
+
if (!verifyResult) {
|
|
56
|
+
// A session-scoped lookup that was invalidated by a later mutation is
|
|
57
|
+
// stale, not merely missing - the agent verified and then changed code.
|
|
58
|
+
evidenceState =
|
|
59
|
+
context?.sessionID && mutationTimestamp > 0 ? "stale-pass" : "missing";
|
|
60
|
+
} else if (!verifyResult.passed) {
|
|
61
|
+
evidenceState = "failing";
|
|
62
|
+
} else if (
|
|
63
|
+
verifyResult.timestamp < mutationTimestamp ||
|
|
64
|
+
Date.now() - verifyResult.timestamp > VERIFICATION_FRESH_WINDOW_MS
|
|
65
|
+
) {
|
|
66
|
+
evidenceState = "stale-pass";
|
|
67
|
+
} else {
|
|
68
|
+
evidenceState = "fresh-pass";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (evidenceState === "fresh-pass") {
|
|
72
|
+
return { claimsCompletion: true, claim: match[0], evidenceState };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reasonByState = {
|
|
76
|
+
missing:
|
|
77
|
+
"the response asserts completion/verification, but no verification evidence has been recorded in this session.",
|
|
78
|
+
failing: `the response asserts completion, but the latest verification (${verifyResult?.command}) FAILED.`,
|
|
79
|
+
"stale-pass":
|
|
80
|
+
"the response asserts completion, but the last passing verification is stale - mutations were recorded after it.",
|
|
81
|
+
"fresh-pass": "",
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
claimsCompletion: true,
|
|
86
|
+
claim: match[0],
|
|
87
|
+
evidenceState,
|
|
88
|
+
reason: reasonByState[evidenceState],
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/workflow-guard.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
12
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
14
|
import { resolve } from "node:path";
|
|
14
15
|
|
|
@@ -212,6 +213,10 @@ import {
|
|
|
212
213
|
sendDesktopNotification,
|
|
213
214
|
} from "./policies/shell-safety.ts";
|
|
214
215
|
|
|
216
|
+
import { checkCompletionClaims } from "./policies/completion.ts";
|
|
217
|
+
|
|
218
|
+
export { checkCompletionClaims };
|
|
219
|
+
|
|
215
220
|
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
|
|
216
221
|
|
|
217
222
|
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
@@ -547,9 +552,11 @@ async function guardToolCallImpl(
|
|
|
547
552
|
// Workspace boundary check for external git repo targets
|
|
548
553
|
for (const invocation of gitInvocations) {
|
|
549
554
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
555
|
+
// The workspace boundary has no allow-live override (same invariant as
|
|
556
|
+
// file redirects/mv): an external repository write is out of bounds even
|
|
557
|
+
// with WORKFLOW_GUARD_ALLOW_LIVE=1.
|
|
550
558
|
if (
|
|
551
559
|
isPathOutsideWorkspace(invocation.repoDir, currentRoot) &&
|
|
552
|
-
!allowLive &&
|
|
553
560
|
(GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
|
|
554
561
|
) {
|
|
555
562
|
logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
@@ -929,7 +936,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
929
936
|
}),
|
|
930
937
|
record_review: tool({
|
|
931
938
|
description:
|
|
932
|
-
"Record a secondary reviewer agent's approval or critique of the current changes.",
|
|
939
|
+
"Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric.",
|
|
933
940
|
args: {
|
|
934
941
|
reviewer: tool.schema
|
|
935
942
|
.string()
|
|
@@ -946,6 +953,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
946
953
|
if (!parentSessionID) {
|
|
947
954
|
return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
|
|
948
955
|
}
|
|
956
|
+
// The rubric is the contract: a recorded review must demonstrate it
|
|
957
|
+
// was evaluated against the 5 axes, not just assert a verdict.
|
|
958
|
+
const axesRefs = [
|
|
959
|
+
"test integrity",
|
|
960
|
+
"task completeness",
|
|
961
|
+
"cleanliness",
|
|
962
|
+
"security",
|
|
963
|
+
"platform",
|
|
964
|
+
];
|
|
965
|
+
const summaryLower = args.summary.toLowerCase();
|
|
966
|
+
const referenced = axesRefs.filter((axis) => summaryLower.includes(axis));
|
|
967
|
+
if (referenced.length < 3) {
|
|
968
|
+
return (
|
|
969
|
+
`[workflow-guard] Review rejected: summary must reference the review axes ` +
|
|
970
|
+
`(found ${referenced.length}/5). Call guard_review_rubric to get the rubric, ` +
|
|
971
|
+
`evaluate each axis, and include findings per axis in the summary.`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
949
974
|
recordReviewResult(
|
|
950
975
|
args.reviewer,
|
|
951
976
|
args.summary,
|
|
@@ -958,6 +983,42 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
958
983
|
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
|
|
959
984
|
},
|
|
960
985
|
}),
|
|
986
|
+
guard_review_rubric: tool({
|
|
987
|
+
description:
|
|
988
|
+
"Get the secondary-review rubric for the current branch diff. The orchestrator calls this, spawns a reviewer subagent with the rubric as the prompt, then the reviewer records its verdict via record_review.",
|
|
989
|
+
args: {
|
|
990
|
+
base: tool.schema
|
|
991
|
+
.string()
|
|
992
|
+
.optional()
|
|
993
|
+
.describe("Base ref to diff against (default: origin/main, origin/master, main)"),
|
|
994
|
+
},
|
|
995
|
+
execute: async (args) => {
|
|
996
|
+
const bases = args.base
|
|
997
|
+
? [args.base]
|
|
998
|
+
: ["origin/main", "origin/master", "main", "master"];
|
|
999
|
+
let diffText = "";
|
|
1000
|
+
for (const base of bases) {
|
|
1001
|
+
const res = spawnSync(
|
|
1002
|
+
"git",
|
|
1003
|
+
["diff", `${base}...HEAD`],
|
|
1004
|
+
{ cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
|
|
1005
|
+
);
|
|
1006
|
+
if (res.status === 0 && res.stdout.trim()) {
|
|
1007
|
+
diffText = res.stdout;
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (!diffText) {
|
|
1012
|
+
const last = spawnSync("git", ["diff", "HEAD~1"], {
|
|
1013
|
+
cwd: getWorkspaceRoot(),
|
|
1014
|
+
encoding: "utf8",
|
|
1015
|
+
timeout: 10_000,
|
|
1016
|
+
});
|
|
1017
|
+
diffText = last.status === 0 ? last.stdout : "(no diff available)";
|
|
1018
|
+
}
|
|
1019
|
+
return buildReviewRubric(diffText);
|
|
1020
|
+
},
|
|
1021
|
+
}),
|
|
961
1022
|
guard_worktree_create: tool({
|
|
962
1023
|
description:
|
|
963
1024
|
"Create an isolated git worktree directory for concurrent subagent execution.",
|
|
@@ -1142,6 +1203,40 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1142
1203
|
});
|
|
1143
1204
|
},
|
|
1144
1205
|
|
|
1206
|
+
// Policy 24 (observability): claims-vs-evidence. When the assistant's final
|
|
1207
|
+
// text asserts completion or passing verification, compare the claim against
|
|
1208
|
+
// recorded verification evidence. A mismatch is journaled (not blocked) so
|
|
1209
|
+
// confident wrap-ups cannot silently contradict failing or stale state.
|
|
1210
|
+
"experimental.text.complete": async (input, output) => {
|
|
1211
|
+
try {
|
|
1212
|
+
const sessionID = input.sessionID;
|
|
1213
|
+
const text = typeof output.text === "string" ? output.text : "";
|
|
1214
|
+
const check = checkCompletionClaims(text, { sessionID });
|
|
1215
|
+
if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
|
|
1216
|
+
logBlock(`[workflow-guard] completion claim mismatch: ${check.reason}`);
|
|
1217
|
+
audit({
|
|
1218
|
+
ts: new Date().toISOString(),
|
|
1219
|
+
sessionID,
|
|
1220
|
+
tool: "experimental.text.complete",
|
|
1221
|
+
decision: "allow",
|
|
1222
|
+
reason: `Completion claim '${check.claim}' has ${check.evidenceState} verification evidence`,
|
|
1223
|
+
input: { claim: check.claim, evidenceState: check.evidenceState },
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
} catch {}
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
// Keep tool descriptions honest: todowrite's description reflects the
|
|
1230
|
+
// finalization gates so the model is not surprised by verification blocks.
|
|
1231
|
+
"tool.definition": async (input, output) => {
|
|
1232
|
+
if (input.toolID !== "todowrite") return;
|
|
1233
|
+
const description = typeof output.description === "string" ? output.description : "";
|
|
1234
|
+
if (description.includes("verification evidence")) return;
|
|
1235
|
+
output.description =
|
|
1236
|
+
description +
|
|
1237
|
+
"\n\nNote: marking every task completed triggers the workflow guard's finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1145
1240
|
event: async ({
|
|
1146
1241
|
event,
|
|
1147
1242
|
}: { event: { type?: string; properties?: unknown } }) => {
|