opencode-workflow-guard 1.3.8 → 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.
@@ -19,5 +19,8 @@
19
19
  * **[`changesets`](https://github.com/changesets/changesets)** by the [Changesets Team](https://github.com/changesets/changesets)
20
20
  * For the fragment-based changelog and versioning model that prevents merge conflicts across parallel feature PRs.
21
21
 
22
+ * **[`oh-my-pi`](https://github.com/can1357/oh-my-pi)** by [@can1357](https://github.com/can1357), **`omp-guardrails`** by [@breathi3552](https://github.com/breathi3552), **`agentsflow`** by [@xzhang17](https://github.com/xzhang17), **`tools`** by [@marcelsud](https://github.com/marcelsud), and the **Claude Code** security hook community
23
+ * Inspired the hardened shell wrapper tokenization/normalization in **`unwrapShellWords`**, multi-ecosystem verification detection in **Policy 10**, structured severity ranking (P0-P3 blocker gate) for the secondary review rubric in **Policy 19 / `guard_review_rubric`**, disk wipe, reverse shell & system permission signatures in **Policy 4**, lockfile synchronization checks in **Policy 3**, and subagent read-only role confinement & mutation budgets in **Policy 1**.
24
+
22
25
  * **[OpenCode Team at Anomaly](https://github.com/anomalyco/opencode)**
23
26
  * For building the OpenCode terminal AI coding agent and its extensible plugin architecture (`@opencode-ai/plugin`).
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.3.8",
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/review.ts CHANGED
@@ -34,12 +34,18 @@ export function buildReviewRubric(diffText: string, taskPrompt?: string): string
34
34
  "### 5. Platform & Architecture Fit (GitHub & Azure DevOps)",
35
35
  "- Does the change fit established repository patterns and CI/CD pipelines?",
36
36
  "",
37
+ "### Finding Severity & Priority Tiers (inspired by OMP Advisor):",
38
+ "- **P0**: Blocker / security vulnerability / data loss risk / release-blocking regression (must block approval).",
39
+ "- **P1**: Major defect / missing core requirement / broken error handling (must block approval).",
40
+ "- **P2**: Minor bug / edge case / hygiene issue / missing unit test.",
41
+ "- **P3**: Suggestion / nitpick / optimization idea.",
42
+ "",
37
43
  taskPrompt ? `### User Request / Context:\n${taskPrompt}\n` : "",
38
44
  "### Code Diff Under Review:",
39
45
  "```diff",
40
46
  diffText.slice(0, 30_000),
41
47
  "```",
42
48
  "",
43
- "Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings.",
49
+ "Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings ranked by priority (P0-P3).",
44
50
  ].join("\n");
45
51
  }
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
  }
@@ -275,3 +279,15 @@ export function isDocumentationRequired(root: string): boolean {
275
279
  const cfg = getProjectConfig(root);
276
280
  return cfg.requireDocumentation === true;
277
281
  }
282
+
283
+ export function getSubagentMutationBudget(root: string): number {
284
+ if (process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS) {
285
+ const parsed = parseInt(process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS, 10);
286
+ if (!Number.isNaN(parsed) && parsed > 0) return parsed;
287
+ }
288
+ const cfg = getProjectConfig(root);
289
+ if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
290
+ return cfg.maxSubagentMutations;
291
+ }
292
+ return 50;
293
+ }
package/src/lib/types.ts CHANGED
@@ -21,6 +21,7 @@ export interface ProjectConfig {
21
21
  verifyCommand?: string;
22
22
  requireReview?: boolean;
23
23
  requireDocumentation?: boolean;
24
+ maxSubagentMutations?: number;
24
25
  }
25
26
 
26
27
  export interface VerifyResult {
@@ -41,6 +42,9 @@ export interface ReviewResult {
41
42
  timestamp: number;
42
43
  targetSessionID?: string;
43
44
  workspace?: string;
45
+ commitHash?: string;
46
+ gitStatus?: string;
47
+ worktreeFingerprint?: string;
44
48
  }
45
49
 
46
50
  export interface AuditEntry {
package/src/lib/utils.ts CHANGED
@@ -29,6 +29,20 @@ export function normalize(cmd: string): string {
29
29
  return cmd.replace(/\s+/g, " ");
30
30
  }
31
31
 
32
+ export function decodeShellEscapes(text: string): string {
33
+ return text
34
+ // ANSI-C quoting wrapper: $'...' -> ...
35
+ .replace(/\$'([^']*)'/g, "$1")
36
+ // Hex escapes: \xHH
37
+ .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
38
+ // Unicode escapes: \uHHHH
39
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
40
+ // Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
41
+ .replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
42
+ // Single character escapes: \c -> c (e.g. \r\m -> rm)
43
+ .replace(/\\(.)/g, "$1");
44
+ }
45
+
32
46
  export function shellWords(command: string): string[] {
33
47
  return (command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((word) =>
34
48
  word.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2"),
@@ -69,14 +83,69 @@ export function splitShellSegments(command: string): string[] {
69
83
  }
70
84
 
71
85
  export function unwrapShellWords(command: string): string[] {
72
- const words = shellWords(command.trim());
86
+ let cleanCmd = decodeShellEscapes(command.trim());
87
+ // Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
88
+ while (
89
+ (cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
90
+ (cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
91
+ ) {
92
+ cleanCmd = cleanCmd.slice(1, -1).trim();
93
+ if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
94
+ }
95
+
96
+ const words = shellWords(cleanCmd);
73
97
  let i = 0;
74
98
  while (i < words.length) {
75
- if (words[i] === "command") {
99
+ const rawWord = words[i]!;
100
+ const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
101
+ if (!word) {
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
107
+ i++;
108
+ continue;
109
+ }
110
+ if (word === "nice") {
111
+ i++;
112
+ if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
113
+ if (words[i] === "-n") i += 2;
114
+ else i++;
115
+ }
116
+ continue;
117
+ }
118
+ if (word === "timeout") {
119
+ i++;
120
+ while (i < words.length && words[i]!.startsWith("-")) {
121
+ const opt = words[i++]!;
122
+ if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
123
+ i++;
124
+ }
125
+ }
126
+ if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
127
+ i++;
128
+ }
129
+ continue;
130
+ }
131
+ if (word === "stdbuf") {
76
132
  i++;
133
+ while (i < words.length && words[i]!.startsWith("-")) {
134
+ const opt = words[i++]!;
135
+ if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
136
+ i++;
137
+ }
138
+ }
139
+ continue;
140
+ }
141
+ if (word === "time") {
142
+ i++;
143
+ while (i < words.length && words[i]!.startsWith("-")) {
144
+ i++;
145
+ }
77
146
  continue;
78
147
  }
79
- if (words[i] === "sudo") {
148
+ if (word === "sudo" || word === "doas") {
80
149
  i++;
81
150
  const valueOptions = new Set([
82
151
  "-u",
@@ -100,7 +169,7 @@ export function unwrapShellWords(command: string): string[] {
100
169
  }
101
170
  continue;
102
171
  }
103
- if (words[i] === "env") {
172
+ if (word === "env") {
104
173
  i++;
105
174
  const valueOptions = new Set([
106
175
  "-u",
@@ -111,24 +180,27 @@ export function unwrapShellWords(command: string): string[] {
111
180
  "--split-string",
112
181
  ]);
113
182
  while (i < words.length) {
114
- const word = words[i]!;
115
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
183
+ const w = words[i]!;
184
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
116
185
  i++;
117
186
  continue;
118
187
  }
119
- if (!word.startsWith("-")) break;
188
+ if (!w.startsWith("-")) break;
120
189
  i++;
121
- if (valueOptions.has(word) && i < words.length) i++;
190
+ if (valueOptions.has(w) && i < words.length) i++;
122
191
  }
123
192
  continue;
124
193
  }
125
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) {
194
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
126
195
  i++;
127
196
  continue;
128
197
  }
129
198
  break;
130
199
  }
131
- return words.slice(i);
200
+ return words
201
+ .slice(i)
202
+ .map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
203
+ .filter(Boolean);
132
204
  }
133
205
 
134
206
  export function unwrapShellCommand(command: string): string {
package/src/lib/verify.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync, existsSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { getCleanEnv, normalize } from "./utils.ts";
5
6
  import { getProjectConfig } from "./state.ts";
@@ -18,11 +19,35 @@ export function detectVerifyCommand(root: string): string | undefined {
18
19
  return cmd || undefined;
19
20
  }
20
21
  try {
21
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
22
- scripts?: Record<string, string>;
23
- };
24
- if (pkg.scripts?.test) return "npm test";
22
+ const pkgPath = join(root, "package.json");
23
+ if (existsSync(pkgPath)) {
24
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
25
+ scripts?: Record<string, string>;
26
+ };
27
+ if (pkg.scripts?.test) return "npm test";
28
+ if (pkg.scripts?.typecheck) return "npm run typecheck";
29
+ if (pkg.scripts?.check) return "npm run check";
30
+ if (pkg.scripts?.build) return "npm run build";
31
+ }
25
32
  } catch {}
33
+
34
+ if (existsSync(join(root, "Cargo.toml"))) {
35
+ return "cargo test";
36
+ }
37
+ if (existsSync(join(root, "go.mod"))) {
38
+ return "go test ./...";
39
+ }
40
+ if (
41
+ existsSync(join(root, "pytest.ini")) ||
42
+ existsSync(join(root, "pyproject.toml")) ||
43
+ existsSync(join(root, "setup.py"))
44
+ ) {
45
+ return "pytest";
46
+ }
47
+ if (existsSync(join(root, "deno.json")) || existsSync(join(root, "deno.jsonc"))) {
48
+ return "deno test";
49
+ }
50
+
26
51
  return undefined;
27
52
  }
28
53
 
@@ -92,6 +117,43 @@ export function getGitStatusSummary(root: string): string | undefined {
92
117
  return undefined;
93
118
  }
94
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
+
95
157
  export async function runVerify(
96
158
  command: string,
97
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++) {
@@ -244,6 +267,24 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
244
267
  return undefined;
245
268
  }
246
269
 
270
+ export function detectShellMutation(command: string): ShellMutation | undefined {
271
+ for (const segment of command.split(/[\n|;&]+/)) {
272
+ const simpleMutations = simpleFilesystemMutations(segment);
273
+ if (simpleMutations.length > 0) return simpleMutations[0];
274
+ const teeTargets = teeTargetsIn(segment);
275
+ if (teeTargets.length > 0) {
276
+ return {
277
+ kind: "command",
278
+ target: teeTargets[0],
279
+ what: `tee to '${teeTargets[0]}'`,
280
+ };
281
+ }
282
+ const fallback = shellMutationIn(segment.trim());
283
+ if (fallback) return fallback;
284
+ }
285
+ return undefined;
286
+ }
287
+
247
288
  export async function guardShellMutation(
248
289
  command: string,
249
290
  sessionID: string | undefined,
@@ -277,14 +318,12 @@ export async function guardShellMutation(
277
318
  target,
278
319
  what: `tee to '${target}'`,
279
320
  }));
280
- const fallbackMutation = shellMutationIn(segment.trim());
281
- const mutations = simpleMutations.length > 0
282
- ? simpleMutations
283
- : teeMutations.length > 0
284
- ? teeMutations
285
- : fallbackMutation
286
- ? [fallbackMutation]
287
- : [];
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
+ }
288
327
  for (const mutation of mutations) {
289
328
  hasMutation = true;
290
329
  const secret = secretIn(segment);
@@ -78,3 +78,62 @@ export function prBodyIncludesChangelog(command: string): boolean {
78
78
  }
79
79
  return false;
80
80
  }
81
+
82
+ export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
83
+ try {
84
+ let modifiedFiles: string[] = [];
85
+ const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
86
+ for (const base of baseCandidates) {
87
+ const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
88
+ if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
89
+ const diff = spawnSync("git", ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
90
+ if (diff.status === 0 && diff.stdout.trim()) {
91
+ modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
92
+ break;
93
+ }
94
+ }
95
+ const headDiff = spawnSync("git", ["diff", "--name-only", "HEAD"], { cwd: root, encoding: "utf8", timeout: 10_000 });
96
+ if (headDiff.status === 0 && headDiff.stdout.trim()) {
97
+ const uncommitted = headDiff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
98
+ modifiedFiles = Array.from(new Set([...modifiedFiles, ...uncommitted]));
99
+ }
100
+ const untracked = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8", timeout: 10_000 });
101
+ if (untracked.status === 0 && untracked.stdout.trim()) {
102
+ const files = untracked.stdout.split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
103
+ modifiedFiles = Array.from(new Set([...modifiedFiles, ...files]));
104
+ }
105
+
106
+ const manifestToLockfiles: Array<{ manifest: RegExp; lockfiles: RegExp; name: string }> = [
107
+ {
108
+ manifest: /(?:^|\/)package\.json$/,
109
+ lockfiles: /(?:^|\/)(?:package-lock\.json|bun\.lock|bun\.lockb|pnpm-lock\.yaml|yarn\.lock)$/,
110
+ name: "package.json",
111
+ },
112
+ {
113
+ manifest: /(?:^|\/)Cargo\.toml$/,
114
+ lockfiles: /(?:^|\/)Cargo\.lock$/,
115
+ name: "Cargo.toml",
116
+ },
117
+ {
118
+ manifest: /(?:^|\/)go\.mod$/,
119
+ lockfiles: /(?:^|\/)go\.sum$/,
120
+ name: "go.mod",
121
+ },
122
+ ];
123
+
124
+ for (const item of manifestToLockfiles) {
125
+ const hasManifest = modifiedFiles.some((f) => item.manifest.test(f));
126
+ if (hasManifest) {
127
+ const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
128
+ if (!hasLockfile) {
129
+ return {
130
+ isOutOfSync: true,
131
+ manifest: item.name,
132
+ reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
133
+ };
134
+ }
135
+ }
136
+ }
137
+ } catch {}
138
+ return { isOutOfSync: false };
139
+ }
@@ -48,6 +48,18 @@ export const LIVE_MUTATION_PATTERNS: LivePattern[] = [
48
48
  { re: /\b(?:curl|wget)\b[^;&]*\|\s*(?:bash|sh|zsh)\b/, what: ["remote ", "download piped directly to a shell"].join("") },
49
49
  // Destructive git operations
50
50
  { re: /\bgit\s+push\b[^|;&]*(?:--force\b|--force-with-lease\b|\s-f\b)/, what: ["force ", "push"].join("") },
51
+ // Low-level disk / filesystem / partition destruction
52
+ { re: /\b(?:mkfs(?:\.[a-z0-9]+)?|wipefs|parted|sfdisk|gdisk)\b/, what: ["disk/filesystem ", "format or partition manipulation"].join("") },
53
+ { re: /\bdd\s+[^|;&]*\bof=\/dev\/(?:sd[a-z]|nvme\d|vd[a-z]|hd[a-z]|disk\d|rdisk\d|loop\d)/, what: ["raw disk/block device ", "overwrite via dd"].join("") },
54
+ { re: /\bshred\s+[^|;&]*\/dev\/(?:sd[a-z]|nvme\d|vd[a-z]|hd[a-z]|disk\d|rdisk\d|loop\d)/, what: ["disk device ", "shredding"].join("") },
55
+ // Recursive permission/ownership clobbering on root or home
56
+ { re: /\bchmod\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+\S+\s+(?:\/|~|\$HOME)(?:\s|$)/, what: ["recursive ", "permission clobbering of root/home path"].join("") },
57
+ { re: /\bchown\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+\S+\s+(?:\/|~|\$HOME)(?:\s|$)/, what: ["recursive ", "ownership clobbering of root/home path"].join("") },
58
+ // Network reverse shell / raw socket execution / data exfiltration channels
59
+ { re: /\/dev\/(?:tcp|udp)\/[a-zA-Z0-9_.-]+\/\d+/, what: ["raw network socket / ", "reverse shell channel (/dev/tcp or /dev/udp)"].join("") },
60
+ { re: /\b(?:nc|ncat|netcat)\b[^|;&]*-[a-zA-Z]*e[a-zA-Z]*\s+(?:\/bin\/(?:ba)?sh|sh|bash|cmd\.exe|powershell)/i, what: ["netcat reverse ", "shell execution (-e)"].join("") },
61
+ { re: /\bsocat\b[^|;&]*\bexec:\s*['"]?(?:\/bin\/(?:ba)?sh|sh|bash)/i, what: ["socat interactive ", "reverse shell spawn"].join("") },
62
+ { re: /\bmknod\s+\S+\s+p\b/, what: ["named FIFO pipe ", "creation (reverse shell backpipe)"].join("") },
51
63
  ];
52
64
 
53
65
  export function liveMutationIn(command: string): string | undefined {
@@ -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 };
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { basename, relative, resolve } from "node:path";
3
3
  import { getWorkspaceRoot } from "../lib/state.ts";
4
+ import { decodeShellEscapes } from "../lib/utils.ts";
4
5
 
5
6
  export const PROTECTED_PATH_REASON =
6
7
  "Blocked: modifying Open" +
@@ -23,10 +24,9 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
23
24
  ];
24
25
 
25
26
  export function normalizeShellEvasion(text: string): string {
26
- return text
27
+ return decodeShellEscapes(text)
27
28
  .replace(/'([^']*)'/g, "$1")
28
- .replace(/"([^"]*)"/g, "$1")
29
- .replace(/\\(.)/g, "$1");
29
+ .replace(/"([^"]*)"/g, "$1");
30
30
  }
31
31
 
32
32
  export function normalizeGlobPathEvasion(text: string): string {
@@ -47,6 +47,7 @@ import {
47
47
  stripJsonComments,
48
48
  isReviewRequired,
49
49
  isDocumentationRequired,
50
+ getSubagentMutationBudget,
50
51
  recordReviewResult,
51
52
  getLastReviewResult,
52
53
  resetReviewState,
@@ -68,6 +69,7 @@ export {
68
69
  stripJsonComments,
69
70
  isReviewRequired,
70
71
  isDocumentationRequired,
72
+ getSubagentMutationBudget,
71
73
  recordReviewResult,
72
74
  getLastReviewResult,
73
75
  resetReviewState,
@@ -108,6 +110,7 @@ import {
108
110
  snipVerifyOutput,
109
111
  getCurrentGitCommitHash,
110
112
  getGitStatusSummary,
113
+ getGitWorktreeFingerprint,
111
114
  } from "./lib/verify.ts";
112
115
 
113
116
  export {
@@ -116,6 +119,7 @@ export {
116
119
  snipVerifyOutput,
117
120
  getCurrentGitCommitHash,
118
121
  getGitStatusSummary,
122
+ getGitWorktreeFingerprint,
119
123
  };
120
124
 
121
125
  // ── Secondary review rubric ──────────────────────────────────────────────────
@@ -179,8 +183,11 @@ import {
179
183
  hasPrCreateInvocation,
180
184
  branchHasChangelogChange,
181
185
  prBodyIncludesChangelog,
186
+ checkLockfileSync,
182
187
  } from "./policies/changelog.ts";
183
188
 
189
+ export { checkLockfileSync };
190
+
184
191
  import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
185
192
 
186
193
  import { mcpMutationTool } from "./policies/mcp.ts";
@@ -197,6 +204,7 @@ import {
197
204
  isPathOutsideWorkspace,
198
205
  extractPatchPaths,
199
206
  guardShellMutation,
207
+ detectShellMutation,
200
208
  } from "./policies/boundary.ts";
201
209
 
202
210
  import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
@@ -240,13 +248,38 @@ function logBlock(message: string): void {
240
248
  } catch {}
241
249
  }
242
250
 
251
+ const READ_ONLY_ROLES = new Set([
252
+ "reviewer",
253
+ "planner",
254
+ "advisor",
255
+ "critic",
256
+ "explorer",
257
+ "scout",
258
+ "evaluator",
259
+ ]);
260
+
261
+ export function isReadOnlyRole(agent?: string): boolean {
262
+ if (!agent) return false;
263
+ const lower = agent.toLowerCase().trim();
264
+ return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
265
+ }
266
+
243
267
  async function guardToolCallImpl(
244
268
  toolName: string,
245
269
  input: unknown,
246
- context?: { sessionID?: string },
270
+ context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
247
271
  ): Promise<string | undefined> {
248
272
  const currentRoot = getWorkspaceRoot();
249
273
 
274
+ // ── Role confinement: subagents in read-only roles cannot mutate files ──
275
+ if (context?.agent && isReadOnlyRole(context.agent)) {
276
+ if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
277
+ const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
278
+ logBlock(`[workflow-guard] ${reason}`);
279
+ return reason;
280
+ }
281
+ }
282
+
250
283
  // ── Policy 17: secret file read block via read tool ──
251
284
  if (toolName === "read" || toolName === "read_file") {
252
285
  const record = asRecord(input);
@@ -499,6 +532,18 @@ async function guardToolCallImpl(
499
532
  "starting new work."
500
533
  );
501
534
  }
535
+ if (context?.sessionID) {
536
+ const parentID = await fetchParentSessionID(context.sessionID);
537
+ if (parentID) {
538
+ const count = getMutationCount(context.sessionID);
539
+ const budget = getSubagentMutationBudget(currentRoot);
540
+ if (count >= budget) {
541
+ const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
542
+ logBlock(`[workflow-guard] ${reason}`);
543
+ return reason;
544
+ }
545
+ }
546
+ }
502
547
  recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
503
548
  return undefined;
504
549
  }
@@ -765,8 +810,11 @@ async function guardToolCallImpl(
765
810
  : getLastReviewResult();
766
811
  const reviewMatchesContext =
767
812
  review?.passed === true &&
768
- (!review.workspace || review.workspace === currentRoot) &&
769
- (!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);
770
818
  if (!reviewMatchesContext) {
771
819
  logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
772
820
  return (
@@ -802,6 +850,13 @@ async function guardToolCallImpl(
802
850
  `'Changelog:' section in the PR description (${descFlag}).`
803
851
  );
804
852
  }
853
+
854
+ // Lockfile synchronization pre-flight gate
855
+ const lockCheck = checkLockfileSync(currentRoot);
856
+ if (lockCheck.isOutOfSync) {
857
+ logBlock(`[workflow-guard] blocked ${prTool}: ${lockCheck.reason}`);
858
+ return `Blocked: ${lockCheck.reason}`;
859
+ }
805
860
  }
806
861
 
807
862
  const hasGitMutation = gitInvocations.some((invocation) => {
@@ -811,6 +866,35 @@ async function guardToolCallImpl(
811
866
  /\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation)
812
867
  );
813
868
  });
869
+
870
+ if (context?.agent && isReadOnlyRole(context.agent)) {
871
+ const mutation = detectShellMutation(command);
872
+ if (mutation) {
873
+ logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
874
+ return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
875
+ }
876
+ if (hasGitMutation) {
877
+ logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
878
+ return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
879
+ }
880
+ }
881
+
882
+ const shellMut = detectShellMutation(command);
883
+ if (hasGitMutation || shellMut) {
884
+ if (context?.sessionID) {
885
+ const parentID = await fetchParentSessionID(context.sessionID);
886
+ if (parentID) {
887
+ const count = getMutationCount(context.sessionID);
888
+ const budget = getSubagentMutationBudget(currentRoot);
889
+ if (count >= budget) {
890
+ const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
891
+ logBlock(`[workflow-guard] ${reason}`);
892
+ return reason;
893
+ }
894
+ }
895
+ }
896
+ }
897
+
814
898
  if (hasGitMutation) {
815
899
  recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
816
900
  }
@@ -826,9 +910,13 @@ async function guardToolCallImpl(
826
910
  export async function guardToolCall(
827
911
  toolName: string,
828
912
  input: unknown,
829
- context?: { sessionID?: string },
913
+ context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
830
914
  ): Promise<string | undefined> {
831
- const reason = await guardToolCallImpl(toolName, input, context);
915
+ const customRoot = context?.worktree || context?.directory;
916
+ const runImpl = () => guardToolCallImpl(toolName, input, context);
917
+ const reason = customRoot
918
+ ? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
919
+ : await runImpl();
832
920
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
833
921
  const isMutation = EDIT_TOOL_NAMES.has(toolName);
834
922
  const record = asRecord(input);
@@ -998,6 +1086,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
998
1086
  `evaluate each axis, and include findings per axis in the summary.`
999
1087
  );
1000
1088
  }
1089
+ if (args.passed) {
1090
+ if (/(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) {
1091
+ return (
1092
+ "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. " +
1093
+ "Resolve all P0/P1 issues before approving or record review with passed=false."
1094
+ );
1095
+ }
1096
+ }
1001
1097
  recordReviewResult(
1002
1098
  args.reviewer,
1003
1099
  args.summary,
@@ -1116,12 +1212,23 @@ export const WorkflowGuard: Plugin = async (ctx) => {
1116
1212
  },
1117
1213
 
1118
1214
  "tool.execute.before": async (input, output) => {
1119
- return runWithRuntimeState(effectiveRoot, ctx.client, async () => {
1215
+ const toolWorktree =
1216
+ (input as { worktree?: string })?.worktree ||
1217
+ (input as { directory?: string })?.directory ||
1218
+ (output as { worktree?: string })?.worktree ||
1219
+ (output as { directory?: string })?.directory ||
1220
+ effectiveRoot;
1221
+ const toolAgent =
1222
+ (input as { agent?: string })?.agent ||
1223
+ (output as { agent?: string })?.agent;
1224
+ return runWithRuntimeState(toolWorktree, ctx.client, async () => {
1120
1225
  const args =
1121
1226
  (output as { args?: unknown } | undefined)?.args ??
1122
1227
  (input as { args?: unknown }).args;
1123
1228
  const reason = await guardToolCall(input.tool, args, {
1124
1229
  sessionID: input.sessionID,
1230
+ worktree: toolWorktree,
1231
+ agent: toolAgent,
1125
1232
  });
1126
1233
  if (reason !== undefined) {
1127
1234
  await emitBlockFeedback(reason);