opencode-workflow-guard 1.4.0 → 1.4.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.
- package/README.md +3 -1
- package/package.json +3 -2
- package/src/lib/state.ts +6 -2
- package/src/lib/types.ts +3 -0
- package/src/lib/verify.ts +38 -0
- package/src/policies/boundary.ts +38 -17
- package/src/policies/shell-safety.ts +25 -6
- package/src/workflow-guard.ts +7 -2
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
|
|
|
25
25
|
|
|
26
26
|
*Requires OpenCode >= 1.18.* See [docs/installation.md](docs/installation.md) for full setup options and TUI companion badge configuration.
|
|
27
27
|
|
|
28
|
+
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json` as well. Do not use `opencode-workflow-guard/ui`; npm parses that unscoped slash form as GitHub shorthand rather than a package export, which can trigger Git credential prompts. The package exposes OpenCode's native `./server` and `./tui` entrypoints so the package root is sufficient in both configs.
|
|
29
|
+
|
|
28
30
|
### 2. Verification
|
|
29
31
|
|
|
30
32
|
```bash
|
|
@@ -41,7 +43,7 @@ The plugin enforces **24 deterministic policy rules** across four main pillars:
|
|
|
41
43
|
|
|
42
44
|
* **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.
|
|
43
45
|
* **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
|
|
44
|
-
* **Secrets & Workspace Confinement:** Confines
|
|
46
|
+
* **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.
|
|
45
47
|
* **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.
|
|
46
48
|
|
|
47
49
|
For complete policy specifications and override rules, see [docs/policies.md](docs/policies.md).
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/workflow-guard.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/workflow-guard.ts",
|
|
10
|
-
"./
|
|
10
|
+
"./server": "./src/workflow-guard.ts",
|
|
11
|
+
"./tui": "./src/workflow-guard-ui.ts"
|
|
11
12
|
},
|
|
12
13
|
"publishConfig": {
|
|
13
14
|
"access": "public"
|
package/src/lib/state.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { realpathSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { persistVerifyCache } from "./audit.ts";
|
|
5
|
-
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary } from "./verify.ts";
|
|
5
|
+
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
6
6
|
import type {
|
|
7
7
|
TodoSdkClient,
|
|
8
8
|
ProjectConfig,
|
|
@@ -149,13 +149,17 @@ export function recordReviewResult(
|
|
|
149
149
|
targetSessionID?: string,
|
|
150
150
|
workspace?: string,
|
|
151
151
|
): void {
|
|
152
|
+
const root = resolve(workspace ?? getWorkspaceRoot());
|
|
152
153
|
lastReview = {
|
|
153
154
|
reviewer,
|
|
154
155
|
summary: summary.slice(-4000),
|
|
155
156
|
passed,
|
|
156
157
|
timestamp: Date.now(),
|
|
157
158
|
targetSessionID,
|
|
158
|
-
workspace,
|
|
159
|
+
workspace: root,
|
|
160
|
+
commitHash: getCurrentGitCommitHash(root),
|
|
161
|
+
gitStatus: getGitStatusSummary(root),
|
|
162
|
+
worktreeFingerprint: getGitWorktreeFingerprint(root),
|
|
159
163
|
};
|
|
160
164
|
if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
|
|
161
165
|
}
|
package/src/lib/types.ts
CHANGED
package/src/lib/verify.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { getCleanEnv, normalize } from "./utils.ts";
|
|
@@ -116,6 +117,43 @@ export function getGitStatusSummary(root: string): string | undefined {
|
|
|
116
117
|
return undefined;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
121
|
+
try {
|
|
122
|
+
// Index listing captures the exact staged/tracked content (blob hashes);
|
|
123
|
+
// a worktree-vs-index diff captures unstaged edits; untracked file
|
|
124
|
+
// contents cover files git does not yet track. Together these bind the
|
|
125
|
+
// fingerprint to file contents regardless of whether HEAD exists, so
|
|
126
|
+
// freshly initialized (commitless) repositories are covered too.
|
|
127
|
+
const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
|
|
128
|
+
cwd: root,
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: 5_000,
|
|
131
|
+
});
|
|
132
|
+
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
133
|
+
cwd: root,
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
timeout: 5_000,
|
|
136
|
+
});
|
|
137
|
+
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
138
|
+
cwd: root,
|
|
139
|
+
encoding: "utf8",
|
|
140
|
+
timeout: 5_000,
|
|
141
|
+
});
|
|
142
|
+
if (staged.status !== 0 || unstaged.status !== 0 || untracked.status !== 0) return undefined;
|
|
143
|
+
const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
144
|
+
for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
|
|
145
|
+
hash.update("\0" + file + "\0");
|
|
146
|
+
try {
|
|
147
|
+
hash.update(readFileSync(join(root, file)));
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return hash.digest("hex");
|
|
153
|
+
} catch {}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
export async function runVerify(
|
|
120
158
|
command: string,
|
|
121
159
|
root: string,
|
package/src/policies/boundary.ts
CHANGED
|
@@ -127,23 +127,29 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
127
127
|
return targets;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
|
|
130
|
+
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
|
+
const mutations: ShellMutation[] = [];
|
|
132
|
+
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/g;
|
|
133
|
+
for (const redirectMatch of segment.matchAll(redirectRe)) {
|
|
134
|
+
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
135
135
|
const op = redirectMatch[1];
|
|
136
136
|
const target = redirectMatch[2];
|
|
137
137
|
// Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
|
|
138
138
|
const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
|
|
139
139
|
if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
|
|
140
|
-
|
|
140
|
+
mutations.push({
|
|
141
141
|
kind: "redirect",
|
|
142
142
|
target,
|
|
143
143
|
what: `file redirect to '${target}'`,
|
|
144
|
-
};
|
|
144
|
+
});
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
return mutations;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
151
|
+
const redirectMutation = redirectMutationsIn(segment)[0];
|
|
152
|
+
if (redirectMutation) return redirectMutation;
|
|
147
153
|
const teeTargets = teeTargetsIn(segment);
|
|
148
154
|
if (teeTargets.length > 0) {
|
|
149
155
|
return {
|
|
@@ -192,12 +198,29 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
192
198
|
return undefined;
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate"]);
|
|
202
|
+
const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
|
|
203
|
+
|
|
195
204
|
export function simpleFilesystemMutations(segment: string): ShellMutation[] {
|
|
196
205
|
const words = shellWords(unwrapShellCommand(segment));
|
|
197
206
|
const command = words[0];
|
|
198
|
-
if (!command
|
|
207
|
+
if (!command) {
|
|
199
208
|
return [];
|
|
200
209
|
}
|
|
210
|
+
if (command === "dd") {
|
|
211
|
+
return words
|
|
212
|
+
.slice(1)
|
|
213
|
+
.filter((word) => word.startsWith("of=") && word.length > 3)
|
|
214
|
+
.map((word) => {
|
|
215
|
+
const target = word.slice(3);
|
|
216
|
+
return {
|
|
217
|
+
kind: "command" as const,
|
|
218
|
+
target,
|
|
219
|
+
what: `dd output to '${target}'`,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
|
|
201
224
|
return words
|
|
202
225
|
.slice(1)
|
|
203
226
|
.filter((word) => !word.startsWith("-"))
|
|
@@ -212,7 +235,7 @@ export function filesystemTransferInfo(
|
|
|
212
235
|
segment: string,
|
|
213
236
|
): { sources: string[]; destination?: string } | undefined {
|
|
214
237
|
const words = shellWords(unwrapShellCommand(segment));
|
|
215
|
-
if (!words[0] || !
|
|
238
|
+
if (!words[0] || !TRANSFER_COMMANDS.has(words[0])) return undefined;
|
|
216
239
|
const operands: string[] = [];
|
|
217
240
|
let targetDirectory: string | undefined;
|
|
218
241
|
for (let i = 1; i < words.length; i++) {
|
|
@@ -295,14 +318,12 @@ export async function guardShellMutation(
|
|
|
295
318
|
target,
|
|
296
319
|
what: `tee to '${target}'`,
|
|
297
320
|
}));
|
|
298
|
-
const
|
|
299
|
-
const mutations = simpleMutations
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
? [fallbackMutation]
|
|
305
|
-
: [];
|
|
321
|
+
const redirectMutations = redirectMutationsIn(segment.trim());
|
|
322
|
+
const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
|
|
323
|
+
if (mutations.length === 0) {
|
|
324
|
+
const fallbackMutation = shellMutationIn(segment.trim());
|
|
325
|
+
if (fallbackMutation) mutations.push(fallbackMutation);
|
|
326
|
+
}
|
|
306
327
|
for (const mutation of mutations) {
|
|
307
328
|
hasMutation = true;
|
|
308
329
|
const secret = secretIn(segment);
|
|
@@ -38,7 +38,9 @@ export function checkPackageHygiene(command: string): { isViolating: boolean; na
|
|
|
38
38
|
|
|
39
39
|
// AI agents running in non-interactive terminal subshells hang indefinitely when an
|
|
40
40
|
// interactive prompt (vim, nano, less, top, sudo, npm init without -y, apt without -y,
|
|
41
|
-
// git rebase -i) waits for TTY input.
|
|
41
|
+
// git rebase -i) waits for TTY input. Process monitors are handled separately in
|
|
42
|
+
// checkProcessMonitorCommand because `top` can run in a safe batch mode, while the
|
|
43
|
+
// other monitors cannot.
|
|
42
44
|
|
|
43
45
|
const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
|
|
44
46
|
{
|
|
@@ -51,11 +53,6 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
51
53
|
name: "terminal pager",
|
|
52
54
|
advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
|
|
53
55
|
},
|
|
54
|
-
{
|
|
55
|
-
regex: /\b(?:top|htop|btop|atop|glances)\b/i,
|
|
56
|
-
name: "interactive process monitor",
|
|
57
|
-
advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
|
|
58
|
-
},
|
|
59
56
|
{
|
|
60
57
|
regex: /\bsudo\b/i,
|
|
61
58
|
name: "sudo with password prompt",
|
|
@@ -88,7 +85,29 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
88
85
|
},
|
|
89
86
|
];
|
|
90
87
|
|
|
88
|
+
const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
|
|
89
|
+
// Process monitoring tokens are matched on whitespace/segment boundaries so that
|
|
90
|
+
// an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
|
|
91
|
+
// or a quoted argument like `echo "top"` is not mistaken for the `top` command.
|
|
92
|
+
const ALWAYS_INTERACTIVE_MONITOR_RE = /(?:^|[;&|\s])(?:htop|btop|atop|glances)(?:$|[;&|\s])/i;
|
|
93
|
+
const TOP_COMMAND_RE = /(?:^|[;&|\s])top(?:$|[;&|\s])/i;
|
|
94
|
+
const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
|
|
95
|
+
|
|
96
|
+
function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
97
|
+
for (const segment of command.split(/[;&|\n]+/)) {
|
|
98
|
+
if (ALWAYS_INTERACTIVE_MONITOR_RE.test(segment)) {
|
|
99
|
+
return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
|
|
100
|
+
}
|
|
101
|
+
if (TOP_COMMAND_RE.test(segment) && !TOP_BATCH_FLAG_RE.test(segment)) {
|
|
102
|
+
return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { isInteractive: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
109
|
+
const monitor = checkProcessMonitorCommand(command);
|
|
110
|
+
if (monitor.isInteractive) return monitor;
|
|
92
111
|
for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
|
|
93
112
|
if (regex.test(command)) {
|
|
94
113
|
return { isInteractive: true, name, advice };
|
package/src/workflow-guard.ts
CHANGED
|
@@ -110,6 +110,7 @@ import {
|
|
|
110
110
|
snipVerifyOutput,
|
|
111
111
|
getCurrentGitCommitHash,
|
|
112
112
|
getGitStatusSummary,
|
|
113
|
+
getGitWorktreeFingerprint,
|
|
113
114
|
} from "./lib/verify.ts";
|
|
114
115
|
|
|
115
116
|
export {
|
|
@@ -118,6 +119,7 @@ export {
|
|
|
118
119
|
snipVerifyOutput,
|
|
119
120
|
getCurrentGitCommitHash,
|
|
120
121
|
getGitStatusSummary,
|
|
122
|
+
getGitWorktreeFingerprint,
|
|
121
123
|
};
|
|
122
124
|
|
|
123
125
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
@@ -808,8 +810,11 @@ async function guardToolCallImpl(
|
|
|
808
810
|
: getLastReviewResult();
|
|
809
811
|
const reviewMatchesContext =
|
|
810
812
|
review?.passed === true &&
|
|
811
|
-
|
|
812
|
-
(!review.targetSessionID || review.targetSessionID === context?.sessionID)
|
|
813
|
+
review.workspace === resolve(currentRoot) &&
|
|
814
|
+
(!review.targetSessionID || review.targetSessionID === context?.sessionID) &&
|
|
815
|
+
review.commitHash === getCurrentGitCommitHash(currentRoot) &&
|
|
816
|
+
review.gitStatus === getGitStatusSummary(currentRoot) &&
|
|
817
|
+
review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
|
|
813
818
|
if (!reviewMatchesContext) {
|
|
814
819
|
logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
|
|
815
820
|
return (
|