opencode-workflow-guard 1.8.6 → 1.9.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/audit.ts +6 -4
- package/src/lib/custom-tools.ts +36 -13
- package/src/lib/evidence.ts +54 -0
- package/src/lib/guard-dispatcher.ts +123 -109
- package/src/lib/project-config.ts +14 -6
- package/src/lib/state.ts +56 -5
- package/src/lib/types.ts +35 -0
- package/src/policies/boundary.ts +2 -1
- package/src/policies/completion.ts +10 -2
- package/src/policies/continuation.ts +45 -11
- package/src/policies/file-claims.ts +12 -5
- package/src/policies/git.ts +2 -0
- package/src/workflow-guard-ui.ts +8 -77
- package/src/workflow-guard.ts +27 -8
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
|
|
|
23
23
|
|
|
24
24
|
*Requires OpenCode >= 1.18.* OpenCode detects the package's server and TUI targets and updates both global configs. Use the same command after a release to upgrade; the explicit version gives OpenCode a fresh package-cache key. Restart OpenCode after installation. See [docs/installation.md](docs/installation.md) for details and links to the OpenCode plugin documentation.
|
|
25
25
|
|
|
26
|
-
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion
|
|
26
|
+
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion keeps a static `Workflow Guard` shield in the prompt bar; warning toasts provide block details without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
|
|
27
27
|
|
|
28
28
|

|
|
29
29
|
|
package/package.json
CHANGED
package/src/lib/audit.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { Database, type SqliteDatabase } from "./sqlite.ts";
|
|
6
|
-
import type { AuditEntry, VerifyResult } from "./types.ts";
|
|
6
|
+
import type { AuditEntry, PolicyDecision, VerifyResult } from "./types.ts";
|
|
7
7
|
import { asRecord } from "./utils.ts";
|
|
8
8
|
|
|
9
9
|
const AUDIT_DIR = join(
|
|
@@ -207,17 +207,19 @@ export function logDecision(
|
|
|
207
207
|
tool: string,
|
|
208
208
|
input: unknown,
|
|
209
209
|
context: { sessionID?: string; callID?: string } | undefined,
|
|
210
|
-
|
|
210
|
+
policyDecision: PolicyDecision,
|
|
211
211
|
evidence?: AuditEntry["evidence"],
|
|
212
212
|
): void {
|
|
213
|
+
const blocked = policyDecision.status !== "allowed";
|
|
213
214
|
audit({
|
|
214
215
|
ts: new Date().toISOString(),
|
|
215
216
|
sessionID: context?.sessionID,
|
|
216
217
|
callID: context?.callID,
|
|
217
218
|
tool,
|
|
218
|
-
decision:
|
|
219
|
+
decision: blocked ? "block" : "allow",
|
|
220
|
+
policyDecision,
|
|
219
221
|
phase: "decision",
|
|
220
|
-
reason,
|
|
222
|
+
reason: blocked ? policyDecision.message : undefined,
|
|
221
223
|
input: summarizeInput(input),
|
|
222
224
|
evidence,
|
|
223
225
|
});
|
package/src/lib/custom-tools.ts
CHANGED
|
@@ -12,23 +12,31 @@ import {
|
|
|
12
12
|
searchProjectMemory,
|
|
13
13
|
} from "./project-memory.ts";
|
|
14
14
|
import { loadLearnerProfile, recordLearningEvidence, selectLearningOpportunity, updateLearnerProfile } from "./learning.ts";
|
|
15
|
+
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
15
16
|
import {
|
|
16
17
|
getLearningInterventionBudget,
|
|
18
|
+
getOperationProfile,
|
|
17
19
|
getProjectConfig,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
getRalphMaxIterations,
|
|
21
|
+
getLastReviewResultForWorkspace,
|
|
22
|
+
getLastVerifyResultForWorkspace,
|
|
23
|
+
getWorkspaceMutationCount,
|
|
24
|
+
getWorkspaceMutationTimestamp,
|
|
22
25
|
getWorkspaceRoot,
|
|
23
26
|
isDocumentationRequired,
|
|
27
|
+
isRecoveryCheckpointsEnabled,
|
|
28
|
+
isRalphModeEnabled,
|
|
24
29
|
isReviewRequired,
|
|
25
30
|
recordMutation,
|
|
26
31
|
recordReviewResult,
|
|
32
|
+
runWithRuntimeState,
|
|
27
33
|
} from "./state.ts";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
34
|
+
import { getRalphOutcome } from "../policies/continuation.ts";
|
|
35
|
+
import { loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
36
|
+
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint } from "./verify.ts";
|
|
30
37
|
import { buildReviewRubric } from "./review.ts";
|
|
31
38
|
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
39
|
+
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
32
40
|
import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
|
|
33
41
|
import { audit, getRecentAuditEntries } from "./audit.ts";
|
|
34
42
|
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
@@ -53,10 +61,11 @@ export function createCustomTools(options: {
|
|
|
53
61
|
followupStore: ProjectMemoryStore | undefined;
|
|
54
62
|
portableMemoryPath: string;
|
|
55
63
|
learningInterventions: Map<string, number>;
|
|
64
|
+
client: unknown;
|
|
56
65
|
}) {
|
|
57
|
-
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions } = options;
|
|
66
|
+
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
|
|
58
67
|
return {
|
|
59
|
-
...(
|
|
68
|
+
...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
|
|
60
69
|
guard_recovery_restore: tool({
|
|
61
70
|
description: "Restore this root session's workspace to its pre-run recovery checkpoint. Refuses if the run has not gone idle or if the workspace changed after that boundary.",
|
|
62
71
|
args: { run: tool.schema.number() },
|
|
@@ -143,19 +152,33 @@ export function createCustomTools(options: {
|
|
|
143
152
|
} : {}),
|
|
144
153
|
guard_status: tool({
|
|
145
154
|
description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
|
|
146
|
-
execute: async () => {
|
|
147
|
-
const root =
|
|
148
|
-
|
|
155
|
+
execute: async (_args, toolContext) => {
|
|
156
|
+
const root = effectiveRoot; const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
|
|
157
|
+
const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
|
|
158
|
+
const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
|
|
159
|
+
const reviewEvidenceRecord = lastR ? reviewEvidence(lastR) : undefined;
|
|
160
|
+
const verifyCommand = detectVerifyCommand(root);
|
|
161
|
+
const verifyFresh = Boolean(lastV && verifyEvidence && lastV.passed && lastV.command === verifyCommand && isEvidenceFresh(verifyEvidence, subject, lastMut));
|
|
162
|
+
const reviewRequired = isReviewRequired(root);
|
|
163
|
+
const reviewFresh = Boolean(lastR && reviewEvidenceRecord && lastR.passed && isEvidenceFresh(reviewEvidenceRecord, { ...subject, sessionID: lastR.targetSessionID }, lastMut));
|
|
164
|
+
const documentationRequired = isDocumentationRequired(root);
|
|
165
|
+
const outstandingRequirements = [
|
|
166
|
+
...(verifyCommand && !verifyFresh ? ["verification"] : []),
|
|
167
|
+
...(reviewRequired && !reviewFresh ? ["review"] : []),
|
|
168
|
+
...(documentationRequired && !branchHasDocumentationChange(root) ? ["documentation"] : []),
|
|
169
|
+
];
|
|
170
|
+
const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
|
|
171
|
+
return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
|
|
149
172
|
},
|
|
150
173
|
}),
|
|
151
174
|
guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
|
|
152
|
-
guard_why: tool({ description: "Simulate and
|
|
175
|
+
guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
|
|
153
176
|
record_review: tool({
|
|
154
177
|
description: "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.",
|
|
155
178
|
args: { reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"), summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"), passed: tool.schema.boolean().describe("True if change is approved, false if changes requested") },
|
|
156
179
|
execute: async (args, toolContext) => {
|
|
157
180
|
const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
|
|
158
|
-
const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
|
|
181
|
+
const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
|
|
159
182
|
if (!parentSessionID) { auditVerdict("rejected", "missing_parent_session"); return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session."; }
|
|
160
183
|
const axesRefs = ["test integrity", "task completeness", "cleanliness", "security", "platform"];
|
|
161
184
|
const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ToolOutcome } from "./tool-outcomes.ts";
|
|
3
|
+
import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
|
|
6
|
+
return createHash("sha256").update(JSON.stringify({ kind, observedAt, subject, source })).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function verificationEvidence(result: VerifyResult, sessionID?: string): EvidenceRecord {
|
|
10
|
+
const subject = { workspace: result.workspaceRoot, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID };
|
|
11
|
+
const source = { command: result.command, passed: result.passed, durationMs: result.durationMs, gitStatus: result.gitStatus };
|
|
12
|
+
return { id: evidenceId("verification", result.timestamp, subject, source), kind: "verification", confidence: "deterministic_observation", observedAt: result.timestamp, subject, source };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function reviewEvidence(result: ReviewResult): EvidenceRecord {
|
|
16
|
+
const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.targetSessionID, actor: result.reviewer };
|
|
17
|
+
const source = { reviewer: result.reviewer, passed: result.passed, summary: result.summary, gitStatus: result.gitStatus };
|
|
18
|
+
return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
|
|
22
|
+
const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
|
|
23
|
+
const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
|
|
24
|
+
return { id: evidenceId("tool_outcome", observedAt, subject, source), kind: "tool_outcome", confidence: "deterministic_observation", observedAt, subject, source };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function policyDecisionEvidence(decision: PolicyDecision, observedAt: number, subject: EvidenceSubject): EvidenceRecord {
|
|
28
|
+
const source = { status: decision.status, code: decision.code, policy: decision.policy };
|
|
29
|
+
return { id: evidenceId("policy_evaluation", observedAt, subject, source), kind: "policy_evaluation", confidence: "deterministic_observation", observedAt, subject, source };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function lifecycleStateEvidence(state: string, observedAt: number, subject: EvidenceSubject, source: Record<string, unknown> = {}): EvidenceRecord {
|
|
33
|
+
const lifecycleSource = { state, ...source };
|
|
34
|
+
return { id: evidenceId("lifecycle_state", observedAt, subject, lifecycleSource), kind: "lifecycle_state", confidence: "derived_state", observedAt, subject, source: lifecycleSource };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function agentAssertionEvidence(assertion: string, observedAt: number, subject: EvidenceSubject): EvidenceRecord {
|
|
38
|
+
const source = { assertion };
|
|
39
|
+
return { id: evidenceId("agent_assertion", observedAt, subject, source), kind: "agent_assertion", confidence: "agent_assertion", observedAt, subject, source };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function evidenceMatchesSubject(evidence: EvidenceRecord, subject: EvidenceSubject): boolean {
|
|
43
|
+
return evidence.subject.workspace === subject.workspace &&
|
|
44
|
+
evidence.subject.commitHash === subject.commitHash &&
|
|
45
|
+
evidence.subject.worktreeFingerprint === subject.worktreeFingerprint &&
|
|
46
|
+
evidence.subject.sessionID === subject.sessionID;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isEvidenceFresh(evidence: EvidenceRecord, subject: EvidenceSubject, mutationTimestamp: number): boolean {
|
|
50
|
+
// HEAD is legitimately absent in a freshly initialized repository, but the
|
|
51
|
+
// worktree fingerprint remains available there and is required for freshness.
|
|
52
|
+
if (!evidence.subject.worktreeFingerprint || !subject.worktreeFingerprint) return false;
|
|
53
|
+
return evidence.observedAt >= mutationTimestamp && evidenceMatchesSubject(evidence, subject);
|
|
54
|
+
}
|