opencode-workflow-guard 1.8.7 → 1.9.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.
@@ -0,0 +1,84 @@
1
+ # Troubleshooting Guide
2
+
3
+ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
4
+
5
+ ---
6
+
7
+ ### 1. `plugin config hook failed` / `Unexpected server error` on startup
8
+
9
+ - **Symptoms:** OpenCode crashes or reports `Unexpected server error` when starting a session, with logs showing `TypeError: undefined is not an object (evaluating '...event')`.
10
+ - **Root Cause:** OpenCode 1.18+'s legacy loader treats *every exported function* in a plugin module as a separate plugin instance. If a plugin exports helper functions (like `guardToolCall` or `setWorkspaceRoot`) alongside a default function, OpenCode calls them as plugins, pushes `undefined` into the hook registry, and crashes event dispatch on `session.created`.
11
+ - **Solution:** Ensure `src/workflow-guard.ts` uses the V1 `PluginModule` export format (`export default { id: "workflow-guard", server: WorkflowGuard } satisfies PluginModule`). File plugins require an explicit `id` string.
12
+
13
+ ---
14
+
15
+ ### 2. Edits Blocked: `Blocked: no active todo item`
16
+
17
+ - **Symptoms:** The agent attempts an edit (`edit`, `write`, `apply_patch`) and receives a block message directing it to create a task list.
18
+ - **Root Cause:** Policy 1 blocks file-editing tools until the session's native todo list (`GET /session/:id/todo`) contains at least one task with status `pending` or `in_progress`.
19
+ - **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits will block again until a new breakdown is created for subsequent work.
20
+
21
+ ---
22
+
23
+ ### 3. Multiple tasks are `in_progress`
24
+
25
+ - **Symptoms:** A task list contains multiple `in_progress` items during parallel work.
26
+ - **Root Cause:** This is supported. Policy 1 permits multiple tasks to be `in_progress` so independent work and subagents can proceed concurrently.
27
+ - **Solution:** No correction is required. Keep each active item in every replacement list until it is explicitly marked `completed` or `cancelled`.
28
+
29
+ ---
30
+
31
+ ### 4. `Blocked todowrite: active task '...' was removed`
32
+
33
+ - **Symptoms:** `todowrite` is blocked after an active item disappears from the submitted list.
34
+ - **Root Cause:** Each `todowrite` call replaces the complete list. Policy 1 therefore requires active tasks to be explicitly completed or cancelled rather than silently omitted. Independent items may otherwise be completed out of order.
35
+ - **Solution:** Keep the task in the list and mark it `completed` or `cancelled` before removing it in a later request.
36
+
37
+ ---
38
+
39
+ ### 5. Edits Blocked: `Blocked: the workspace is on a protected branch (main/master)`
40
+
41
+ - **Symptoms:** Direct edits or git commit/merge commands are rejected.
42
+ - **Root Cause:** Policy 7 requires all code modifications to happen on a feature branch.
43
+ - **Solution:** Switch to a feature branch (`git switch -c feat/my-feature`) before making changes.
44
+
45
+ ---
46
+
47
+ ### 6. Edits Blocked: `Blocked: file path '...' escapes workspace root`
48
+
49
+ - **Symptoms:** Edits targeting files with `../` or external absolute paths are rejected.
50
+ - **Root Cause:** Policy 8 prevents directory traversal outside the workspace root.
51
+ - **Solution:** Ensure all target file paths resolve within the project directory.
52
+
53
+ ---
54
+
55
+ ### 7. Subagents Cannot Edit Files
56
+
57
+ - **Symptoms:** A subagent spawned via `task` fails to edit files.
58
+ - **Root Cause:** OpenCode disables `todowrite` for subagents by default.
59
+ - **Solution:** The guard automatically walks up the `parentID` session hierarchy so subagents inherit the parent session's active tasks. Ensure the parent session has active tasks before a subagent attempts guarded mutations.
60
+
61
+ ---
62
+
63
+ ### 8. Destructive Commands Blocked (`kubectl`, `terraform`, `psql`, etc.)
64
+
65
+ - **Symptoms:** A CLI command is rejected with a live-system mutation warning.
66
+ - **Root Cause:** Policy 4 blocks destructive cloud, database, and infrastructure operations.
67
+ - **Solution:** To intentionally run a live command, set `WORKFLOW_GUARD_ALLOW_LIVE=1` in the environment before launching OpenCode. There is no in-command override; an agent cannot grant this permission to itself.
68
+
69
+ ---
70
+
71
+ ### 9. TUI plugin is missing or remains on an older release
72
+
73
+ - **Symptoms:** Workflow Guard does not appear in the TUI, or npm reports a newer Workflow Guard release while OpenCode still runs an older one.
74
+ - **Root Cause:** OpenCode caches npm plugins. A bare package name is resolved as `@latest`, but an already-populated package cache can continue using the version that originally populated that cache key. Running `npm update` in `~/.config/opencode` does not update this OpenCode-managed plugin installation.
75
+ - **Solution:** Use OpenCode's documented plugin installer with the explicit version you want. `--force` replaces the configured plugin version, and the explicit version gives OpenCode a new package-cache key:
76
+
77
+ ```bash
78
+ VERSION=$(npm view opencode-workflow-guard version)
79
+ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
80
+ ```
81
+
82
+ OpenCode documents `opencode plugin <module>` as installing a plugin and updating its config, with `--global` for global config and `--force` to replace an existing plugin version: https://opencode.ai/docs/cli/#plugin. Its plugin documentation also describes npm plugins as OpenCode-managed, cached installations: https://opencode.ai/docs/plugins/#how-plugins-are-installed.
83
+
84
+ After the command reports `Detected server + tui targets`, confirm it reports replacements/additions for both the OpenCode and TUI config files, then restart OpenCode. Manual cache deletion should not be necessary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.7",
3
+ "version": "1.9.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,10 @@
22
22
  },
23
23
  "files": [
24
24
  "bin/",
25
+ "docs/",
25
26
  "src/",
27
+ "test/",
28
+ "tsconfig.json",
26
29
  "README.md",
27
30
  "LICENSE",
28
31
  "ACKNOWLEDGEMENTS.md"
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,24 +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,
27
32
  runWithRuntimeState,
28
33
  } from "./state.ts";
29
- import { loadProjectConfig } from "./project-config.ts";
30
- 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";
31
37
  import { buildReviewRubric } from "./review.ts";
32
38
  import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
39
+ import { branchHasDocumentationChange } from "../policies/docs.ts";
33
40
  import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
34
41
  import { audit, getRecentAuditEntries } from "./audit.ts";
35
42
  import { guardToolCallImpl } from "./guard-dispatcher.ts";
@@ -58,7 +65,7 @@ export function createCustomTools(options: {
58
65
  }) {
59
66
  const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
60
67
  return {
61
- ...(getProjectConfig(effectiveRoot).recoveryCheckpoints === true ? {
68
+ ...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
62
69
  guard_recovery_restore: tool({
63
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.",
64
71
  args: { run: tool.schema.number() },
@@ -145,13 +152,27 @@ export function createCustomTools(options: {
145
152
  } : {}),
146
153
  guard_status: tool({
147
154
  description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
148
- execute: async () => {
149
- 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);
150
- 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);
151
172
  },
152
173
  }),
153
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) }),
154
- 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) }),
155
176
  record_review: tool({
156
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.",
157
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") },
@@ -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
+ }