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 +1 -1
- package/docs/installation.md +16 -0
- package/docs/policies.md +1 -1
- package/package.json +1 -1
- package/src/lib/state.ts +4 -2
- package/src/lib/tool-outcomes.ts +10 -1
- package/src/policies/boundary.ts +35 -28
- package/test/test.mts +52 -0
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.
|
package/docs/installation.md
CHANGED
|
@@ -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:** `
|
|
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
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
|
-
|
|
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
|
|
225
|
+
return cfg.requireReview !== false;
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
export function isDocumentationRequired(root: string): boolean {
|
package/src/lib/tool-outcomes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -158,13 +158,9 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
158
158
|
what: `tee to '${teeTargets[0]!}'`,
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 (
|
|
197
|
-
|
|
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",
|