opencode-workflow-guard 1.10.0 → 1.11.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
@@ -42,7 +42,7 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with Ope
42
42
 
43
43
  The plugin enforces **24 deterministic policy rules** across four main pillars:
44
44
 
45
- * **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, and requires PR changelogs / changesets.
45
+ * **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, enforces secondary review approval before PR creation by default, and requires PR changelogs / changesets.
46
46
  * **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks concurrent direct edits to the same canonical file across sessions, requires same-session fresh reads before `edit`/`write` can replace existing files, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
47
47
  * **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations within the workspace root (including symlink and `../` escape checks), scrubs sensitive environment variables in agent subshells, and redacts `.env` reads with safe schema masks. Arbitrary executables are not OS-sandboxed; use containers or filesystem isolation when hard confinement is required.
48
48
  * **Destructive CLI & Environment Safety:** Intercepts destructive cloud/database/infrastructure commands, stops script laundering and interpreter evasion, blocks TTY hangs (`vim`, `nano`, `sudo`), and guards against dangerous package manager flags.
@@ -108,6 +108,22 @@ OpenCode's model/provider request timeout is separate from Workflow Guard. In Op
108
108
 
109
109
  ---
110
110
 
111
+ ## Secondary Review Requirement
112
+
113
+ Workflow Guard enforces secondary review approval (`requireReview`) by default during PR creation (`gh pr create` and `az repos pr create`). Before a pull request can be opened, an agent must invoke a secondary review subagent and record passing review approval using `record_review` evaluated against `guard_review_rubric`.
114
+
115
+ To opt out of the PR secondary review gate for a project, set `requireReview: false` in `.opencode/workflow-guard.json`:
116
+
117
+ ```json
118
+ {
119
+ "requireReview": false
120
+ }
121
+ ```
122
+
123
+ Or set `WORKFLOW_GUARD_REQUIRE_REVIEW=0` in your environment. Conversely, `WORKFLOW_GUARD_REQUIRE_REVIEW=1` enforces the review gate regardless of repository configuration.
124
+
125
+ ---
126
+
111
127
  ## Recommended Companion Configuration
112
128
 
113
129
  For defense in depth, pair `opencode-workflow-guard` with OpenCode's native permission rules in `opencode.json` (see [OpenCode Permissions](https://opencode.ai/docs/permissions/)):
package/docs/policies.md CHANGED
@@ -181,7 +181,7 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
181
181
 
182
182
  ### Inspection Tools
183
183
  - `guard_status`, `guard_audit`, `guard_why`, `guard_review_rubric`, `record_review`, `guard_review_followups`, and `guard_review_followup_resolve` provide runtime introspection of guardrail state, audit entries, block explanations, reviewer rubric definitions, reviewer decisions, and durable local review debt. `guard_status` also exposes the effective Ralph opt-in, iteration budget, and calling-session outcome when applicable.
184
- - **Priority-Ranked Review Gate:** `guard_review_rubric` includes P0-P3 severity tiers. When recording reviews via `record_review`, approvals that include active P0 (blocker) or P1 (major defect) issues are rejected until blockers are resolved.
184
+ - **Priority-Ranked Review Gate:** Secondary review approval (`requireReview`) is enforced by default during PR preflight (`gh pr create` / `az repos pr create`). Before creating a pull request, a secondary reviewer subagent must evaluate the changes using `guard_review_rubric` and record approval with `record_review`. Approvals that include active P0 (blocker) or P1 (major defect) issues are rejected until blockers are resolved. Projects can opt out via `.opencode/workflow-guard.json` (`{ "requireReview": false }`) or environment variable `WORKFLOW_GUARD_REQUIRE_REVIEW=0`. Conversely, `WORKFLOW_GUARD_REQUIRE_REVIEW=1` forces the review requirement regardless of project config.
185
185
  - **Durable Lower-Priority Follow-ups:** Accepted review summaries containing a P2 or P3 finding are persisted in the project's local Workflow Guard SQLite database. Open findings are included in compacted agent context and remain open across sessions until explicitly resolved with `guard_review_followup_resolve`; they are not silently discarded with the review session.
186
186
  - **Privacy-Safe Local Telemetry:** Guard decisions remain in the local audit JSONL. Tool after-hook observations add `callID` and duration records for local diagnosis and performance analysis; the current hook API does not distinguish success from every failure path, so these records do not claim a success outcome. Command and patch bodies are never written to the journal; only their byte count and SHA-256 fingerprint are retained for correlation.
187
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/lib/state.ts CHANGED
@@ -218,9 +218,11 @@ export function getProjectConfig(root: string): ProjectConfig {
218
218
  }
219
219
 
220
220
  export function isReviewRequired(root: string): boolean {
221
- if (process.env.WORKFLOW_GUARD_REQUIRE_REVIEW === "1") return true;
221
+ const env = process.env.WORKFLOW_GUARD_REQUIRE_REVIEW?.toLowerCase();
222
+ if (env === "0" || env === "false" || env === "off") return false;
223
+ if (env === "1" || env === "true" || env === "on") return true;
222
224
  const cfg = getProjectConfig(root);
223
- return cfg.requireReview === true;
225
+ return cfg.requireReview !== false;
224
226
  }
225
227
 
226
228
  export function isDocumentationRequired(root: string): boolean {
@@ -27,6 +27,7 @@ export class ToolOutcomeTracker {
27
27
  private readonly terminalCalls = new Map<string, Set<string>>();
28
28
  private readonly fallbackCalls = new Map<string, Set<string>>();
29
29
  private readonly failures = new Map<string, { signature: string; count: number }>();
30
+ private readonly consecutiveFailures = new Map<string, number>();
30
31
 
31
32
  record(part: ToolOutcomePart): ToolOutcome | undefined {
32
33
  if (part.type !== "tool" || typeof part.sessionID !== "string" || typeof part.callID !== "string" || typeof part.tool !== "string") return undefined;
@@ -51,6 +52,7 @@ export class ToolOutcomeTracker {
51
52
  const durationMs = typeof start === "number" && Number.isFinite(start) && typeof end === "number" && Number.isFinite(end) ? Math.max(0, end - start) : undefined;
52
53
  if (status === "completed") {
53
54
  this.failures.delete(part.sessionID);
55
+ this.consecutiveFailures.delete(part.sessionID);
54
56
  return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs };
55
57
  }
56
58
 
@@ -59,6 +61,10 @@ export class ToolOutcomeTracker {
59
61
  const previous = this.failures.get(part.sessionID);
60
62
  const count = previous?.signature === signature ? previous.count + 1 : 1;
61
63
  this.failures.set(part.sessionID, { signature, count });
64
+
65
+ const consecutive = (this.consecutiveFailures.get(part.sessionID) ?? 0) + 1;
66
+ this.consecutiveFailures.set(part.sessionID, consecutive);
67
+
62
68
  return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs, repeatedFailureCount: count };
63
69
  }
64
70
 
@@ -75,11 +81,14 @@ export class ToolOutcomeTracker {
75
81
  }
76
82
 
77
83
  getFailureCount(sessionID: string): number {
78
- return this.failures.get(sessionID)?.count ?? 0;
84
+ const consecutive = this.consecutiveFailures.get(sessionID) ?? 0;
85
+ const equivalent = this.failures.get(sessionID)?.count ?? 0;
86
+ return Math.max(consecutive, equivalent);
79
87
  }
80
88
 
81
89
  clearSession(sessionID: string): void {
82
90
  this.failures.delete(sessionID);
91
+ this.consecutiveFailures.delete(sessionID);
83
92
  this.terminalCalls.delete(sessionID);
84
93
  this.fallbackCalls.delete(sessionID);
85
94
  }
@@ -158,13 +158,9 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
158
158
  what: `tee to '${teeTargets[0]!}'`,
159
159
  };
160
160
  }
161
- const sedWords = shellWords(unwrapShellCommand(segment));
162
- if (sedWords[0] === "sed" && sedWords.slice(1).some((word) => /^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word))) {
163
- const operands = sedWords.slice(1).filter((word) => !word.startsWith("-"));
164
- const target = operands.at(-1);
165
- if (!target) return undefined;
166
- return { kind: "command", target, what: `sed -i on '${target}'` };
167
- }
161
+ const simple = simpleFilesystemMutations(segment);
162
+ if (simple.length > 0) return simple[0];
163
+
168
164
  const transfer = filesystemTransferInfo(segment);
169
165
  if (transfer?.destination) {
170
166
  return {
@@ -173,34 +169,45 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
173
169
  what: `copy/move/link to '${transfer.destination}'`,
174
170
  };
175
171
  }
176
- const fsMutationMatch = segment.match(
177
- /\b(?:touch|mkdir|rm|unlink|rmdir|ln)\b[^|;&]*\s+["']?([^\s;&|"']+)["']?\s*$/,
178
- );
179
- if (fsMutationMatch?.[1] && !fsMutationMatch[1].startsWith("-")) {
180
- return {
181
- kind: "command",
182
- target: fsMutationMatch[1],
183
- what: `filesystem mutation of '${fsMutationMatch[1]}'`,
184
- };
172
+ const words = shellWords(unwrapShellCommand(segment));
173
+ const command = words[0];
174
+ if (!command) return undefined;
175
+
176
+ if (command === "curl") {
177
+ for (let i = 1; i < words.length; i++) {
178
+ const w = words[i]!;
179
+ if ((w === "-o" || w === "--output") && i + 1 < words.length) {
180
+ return { kind: "command", target: words[i + 1]!, what: `curl output to '${words[i + 1]!}'` };
181
+ }
182
+ if (w.startsWith("--output=")) {
183
+ const target = w.slice("--output=".length);
184
+ return { kind: "command", target, what: `curl output to '${target}'` };
185
+ }
186
+ }
185
187
  }
186
- const copyMatch = segment.match(
187
- /\b(?:cp|mv|rsync|install|cpio|scp|wget|curl)\b[^|;&]*?(-o\s+)?(["']?)([^\s;&|"']+)\2?\s*$/,
188
- );
189
- if (copyMatch?.[3] && /\b(?:cp|mv|rsync|install)\b/.test(segment)) {
190
- return {
191
- kind: "command",
192
- target: copyMatch[3],
193
- what: `copy/move to '${copyMatch[3]}'`,
194
- };
188
+ if (command === "wget") {
189
+ for (let i = 1; i < words.length; i++) {
190
+ const w = words[i]!;
191
+ if ((w === "-O" || w === "--output-document") && i + 1 < words.length) {
192
+ return { kind: "command", target: words[i + 1]!, what: `wget output to '${words[i + 1]!}'` };
193
+ }
194
+ if (w.startsWith("--output-document=")) {
195
+ const target = w.slice("--output-document=".length);
196
+ return { kind: "command", target, what: `wget output to '${target}'` };
197
+ }
198
+ }
195
199
  }
196
- if (/\bgit\s+(?:apply|am)\b/.test(segment)) {
197
- return { kind: "command", what: "git apply/am (patch via shell)" };
200
+ if (command === "git") {
201
+ const sub = words.slice(1).find((w) => !w.startsWith("-"));
202
+ if (sub === "apply" || sub === "am") {
203
+ return { kind: "command", what: "git apply/am (patch via shell)" };
204
+ }
198
205
  }
199
206
  return undefined;
200
207
  }
201
208
 
202
209
  const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate", "chmod", "chown", "chgrp"]);
203
- const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
210
+ const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln", "rsync", "install", "cpio", "scp"]);
204
211
 
205
212
  export function simpleFilesystemMutations(segment: string): ShellMutation[] {
206
213
  const words = shellWords(unwrapShellCommand(segment));
package/test/test.mts CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  checkLockfileSync,
63
63
  branchHasDocumentationChange,
64
64
  isDocumentationRequired,
65
+ isReviewRequired,
65
66
  getOperationProfile,
66
67
  isRecoveryCheckpointsEnabled,
67
68
  getSubagentMutationBudget,
@@ -241,6 +242,7 @@ check("allow git push origin feature/x", !(await shell("git push origin feature/
241
242
  check("allow push to main-backup (ref-like path)", !(await shell("git push origin main-backup")));
242
243
 
243
244
  console.log("- Policy 3: PR changelog (GitHub & Azure DevOps) -");
245
+ process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = "0";
244
246
  check("block gh pr create without changelog", blocked(await shell("gh pr create --title t --body 'no changes here'")));
245
247
  check("allow gh pr create with Changelog: body", !(await shell("gh pr create --title t --body 'Changelog: fixed stuff'")));
246
248
  check("allow gh pr create with Summary release information", !(await shell("gh pr create --title t --body '## Summary\n- Fix stale tool outcome tracking'")));
@@ -341,6 +343,8 @@ check("unrelated shell workdir cannot supply PR changelog evidence", blocked(awa
341
343
  spawnSync("git", ["worktree", "remove", "--force", changesetWorktree], { cwd: changesetRepo });
342
344
  rmSync(changesetRepo, { recursive: true, force: true });
343
345
 
346
+ delete process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
347
+
344
348
  console.log("- Policy 4: destructive commands -");
345
349
  check("block kubectl delete", blocked(await shell("kubectl delete pod foo")));
346
350
  check("block helm uninstall", blocked(await shell("helm uninstall my-release")));
@@ -497,6 +501,9 @@ check("sed -i on opencode.json blocked (tamper)", blocked(await call("bash", { c
497
501
  check("git apply needs todos (patch via shell)", blocked(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-empty" })));
498
502
  check("git apply allowed with todos", !(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-active" })));
499
503
  check("non-mutating shell unaffected (ls, cat)", !(await call("bash", { command: "ls -la && cat file" }, { sessionID: "s-empty" })));
504
+ check("inspection command with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'strings /bin/opencode | grep -E "install plugin and update config" -C 10' }, { sessionID: "s-empty" })));
505
+ check("git log with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'git log --grep="rm old files"' }, { sessionID: "s-empty" })));
506
+ check("grep searching for mutation command name is not blocked", !(await call("bash", { command: 'grep -rn "mkdir" src/' }, { sessionID: "s-empty" })));
500
507
  check("stderr redirect to /dev/null is not a file mutation", !(await call("bash", { command: "ls missing 2>/dev/null" }, { sessionID: "s-empty" })));
501
508
  check("touch outside workspace is blocked", blocked(await call("bash", { command: "touch /tmp/wg-outside-touch" }, { sessionID: "s-active" })));
502
509
  check("mkdir outside workspace is blocked", blocked(await call("bash", { command: "mkdir /tmp/wg-outside-dir" }, { sessionID: "s-active" })));
@@ -1112,6 +1119,12 @@ outcomeTracker.record({ type: "tool", sessionID: "s-tracker", callID: "success",
1112
1119
  check("successful tool outcomes reset equivalent failure tracking", outcomeTracker.record(failedPart("after-success", "same failure"))?.repeatedFailureCount === 1);
1113
1120
  outcomeTracker.record(failedPart("different", "different failure"));
1114
1121
  check("distinct failures reset equivalent failure tracking", outcomeTracker.record(failedPart("after-different", "same failure"))?.repeatedFailureCount === 1);
1122
+ const consecutiveTracker = new ToolOutcomeTracker();
1123
+ consecutiveTracker.record(failedPart("diverse-1", "failure A"));
1124
+ consecutiveTracker.record(failedPart("diverse-2", "failure B"));
1125
+ check("consecutive diverse failures track failure count for circuit breaker", consecutiveTracker.getFailureCount("s-tracker") === 2);
1126
+ consecutiveTracker.record({ type: "tool", sessionID: "s-tracker", callID: "diverse-success", tool: "bash", state: { status: "completed" } });
1127
+ check("successful tool outcome resets consecutive failure count", consecutiveTracker.getFailureCount("s-tracker") === 0);
1115
1128
  const boundedTracker = new ToolOutcomeTracker();
1116
1129
  for (let i = 0; i < 4096; i++) boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: `call-${i}`, tool: "bash", state: { status: "completed" } });
1117
1130
  check("terminal call deduplication remains stable within its bounded window", boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: "call-0", tool: "bash", state: { status: "completed" } }) === undefined);
@@ -1793,6 +1806,44 @@ setWorkspaceRoot(root);
1793
1806
  reloadProjectConfig(root);
1794
1807
  resetReviewState();
1795
1808
 
1809
+ // Default requireReview behavior: true by default, opt-out with config false or env 0
1810
+ const defaultReviewRepo = mkdtempSync(join(tmpdir(), "wg-default-review-"));
1811
+ mkdirSync(join(defaultReviewRepo, ".opencode"), { recursive: true });
1812
+ setWorkspaceRoot(defaultReviewRepo);
1813
+ reloadProjectConfig(defaultReviewRepo);
1814
+ check("requireReview defaults to true when unconfigured", isReviewRequired(defaultReviewRepo) === true);
1815
+ resetReviewState();
1816
+ check(
1817
+ "PR creation blocked by default without recorded review in unconfigured repo",
1818
+ blocked(await shell("gh pr create --title t --body 'Changelog: update'")),
1819
+ );
1820
+ writeFileSync(join(defaultReviewRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false }));
1821
+ reloadProjectConfig(defaultReviewRepo);
1822
+ check("explicit requireReview: false disables review gate", isReviewRequired(defaultReviewRepo) === false);
1823
+ check(
1824
+ "PR creation allowed when requireReview is explicitly false",
1825
+ !(await shell("gh pr create --title t --body 'Changelog: update'")),
1826
+ );
1827
+ process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = "1";
1828
+ check("WORKFLOW_GUARD_REQUIRE_REVIEW=1 forces review even when config is false", isReviewRequired(defaultReviewRepo) === true);
1829
+ check(
1830
+ "PR creation blocked when WORKFLOW_GUARD_REQUIRE_REVIEW=1 overrides config false",
1831
+ blocked(await shell("gh pr create --title t --body 'Changelog: update'")),
1832
+ );
1833
+ writeFileSync(join(defaultReviewRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
1834
+ reloadProjectConfig(defaultReviewRepo);
1835
+ process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = "0";
1836
+ check("WORKFLOW_GUARD_REQUIRE_REVIEW=0 overrides config true", isReviewRequired(defaultReviewRepo) === false);
1837
+ check(
1838
+ "PR creation allowed when WORKFLOW_GUARD_REQUIRE_REVIEW=0 overrides config true",
1839
+ !(await shell("gh pr create --title t --body 'Changelog: update'")),
1840
+ );
1841
+ delete process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
1842
+ rmSync(defaultReviewRepo, { recursive: true, force: true });
1843
+ setWorkspaceRoot(root);
1844
+ reloadProjectConfig(root);
1845
+ resetReviewState();
1846
+
1796
1847
  // 12. Custom Plugin Tools & Event Auditing
1797
1848
  console.log("- Custom Tools & Event Auditing -");
1798
1849
  const customPlugin = await (defaultExport?.server ?? WorkflowGuard)({
@@ -2182,6 +2233,7 @@ check(
2182
2233
  // Now update README.md or docs
2183
2234
  writeFileSync(join(docRepo, "README.md"), "# Updated Docs\n");
2184
2235
  spawnSync("git", ["commit", "-am", "docs: update README"], { cwd: docRepo });
2236
+ recordReviewResult("reviewer-agent", "LGTM - 5 axes verified", true, undefined, docRepo);
2185
2237
  check("branchHasDocumentationChange returns true once docs are modified", branchHasDocumentationChange(docRepo));
2186
2238
  check(
2187
2239
  "gh pr create passes once documentation update is present",