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 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 shows a `Workflow Guard` shield in the prompt bar and reflects blocked state without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
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
  ![Workflow Guard status badge in the OpenCode TUI](docs/assets/workflow-guard-tui-badge.png)
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.6",
3
+ "version": "1.9.0",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- reason: string | undefined,
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: reason ? "block" : "allow",
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
  });
@@ -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
- getLastMutationTimestamp,
19
- getLastReviewResult,
20
- getLastVerifyResult,
21
- getMutationCount,
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 { loadProjectConfig } from "./project-config.ts";
29
- import { detectVerifyCommand, getCurrentGitCommitHash } from "./verify.ts";
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
- ...(getProjectConfig(effectiveRoot).recoveryCheckpoints === true ? {
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 = getWorkspaceRoot(); const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResult(); const lastR = getLastReviewResult(); const lastMut = getLastMutationTimestamp(); const cfg = loadProjectConfig(root);
148
- return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, lastMutationTimestamp: lastMut, mutationCount: getMutationCount(), lastVerify: lastV ? { command: lastV.command, passed: lastV.passed, fresh: lastV.timestamp >= lastMut, commitHash: lastV.commitHash } : null, lastReview: lastR ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary } : null, projectConfig: { protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: detectVerifyCommand(root) ?? null, requireReview: isReviewRequired(root), requireDocumentation: isDocumentationRequired(root) } }, null, 2);
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 explain whether a specific tool call or command would be blocked by guardrails.", 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) => { const reason = await guardToolCallImpl(args.tool, args.input ?? {}); return reason ? `BLOCKED: ${reason}` : "ALLOWED: Satisfies all current guardrails."; } }),
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
+ }