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 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 all edits, shell redirects, and git operations within the workspace root (symlink & `../` escape safe), scrubs sensitive environment variables in agent subshells, and redacts `.env` reads with safe schema masks.
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.0",
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
- "./ui": "./src/workflow-guard-ui.ts"
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
@@ -42,6 +42,9 @@ export interface ReviewResult {
42
42
  timestamp: number;
43
43
  targetSessionID?: string;
44
44
  workspace?: string;
45
+ commitHash?: string;
46
+ gitStatus?: string;
47
+ worktreeFingerprint?: string;
45
48
  }
46
49
 
47
50
  export interface AuditEntry {
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,
@@ -127,23 +127,29 @@ export function teeTargetsIn(segment: string): string[] {
127
127
  return targets;
128
128
  }
129
129
 
130
- export function shellMutationIn(segment: string): ShellMutation | undefined {
131
- const redirectMatch = segment.match(
132
- /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/,
133
- );
134
- if (redirectMatch?.[1] && redirectMatch?.[2]) {
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
- return {
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 || !new Set(["touch", "mkdir", "rm", "unlink", "rmdir"]).has(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] || !new Set(["cp", "mv", "ln"]).has(words[0])) return undefined;
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 fallbackMutation = shellMutationIn(segment.trim());
299
- const mutations = simpleMutations.length > 0
300
- ? simpleMutations
301
- : teeMutations.length > 0
302
- ? teeMutations
303
- : fallbackMutation
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 };
@@ -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
- (!review.workspace || review.workspace === currentRoot) &&
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 (