opencode-workflow-guard 1.3.1 → 1.3.3

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
@@ -27,7 +27,7 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
27
27
 
28
28
  ```bash
29
29
  npm run typecheck # Strict TypeScript check (0 errors)
30
- npm test # Run 320+ unit and adversarial tests
30
+ npm test # Run 400+ unit and adversarial tests
31
31
  npm run test:all # Run full verification suite (typecheck + unit + live e2e)
32
32
  ```
33
33
 
@@ -43,11 +43,11 @@ opencode-workflow-guard/
43
43
  │ ├── policies/ # Per-policy implementations (task gate, git, secrets, shell-safety, ...)
44
44
  │ └── lib/ # Engine services (state, verify, audit, review, worktree, utils, types)
45
45
  ├── test/ # Test suites
46
- │ ├── test.mts # 320+ in-memory unit & adversarial tests
46
+ │ ├── test.mts # 400+ in-memory unit & adversarial tests
47
47
  │ └── test-e2e.mts # npm tarball resolution + live runtime OpenCode loader tests
48
48
  ├── docs/ # In-depth documentation & guides
49
49
  │ ├── installation.md # Full setup options, worktrees & permissions
50
- │ ├── policies.md # Detailed 23-policy reference & overrides
50
+ │ ├── policies.md # Detailed 24-policy reference & overrides
51
51
  │ ├── troubleshooting.md # Error diagnosis & bypass explanations
52
52
  │ └── testing.md # Test architecture & coverage matrix
53
53
  ├── .changeset/ # Fragment-based versioning & release changelogs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/lib/audit.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, openSync, readSync, closeSync, statSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { AuditEntry, VerifyResult } from "./types.ts";
@@ -46,13 +46,32 @@ export function loadVerifyCache(): VerifyResult | undefined {
46
46
  return undefined;
47
47
  }
48
48
 
49
+ /**
50
+ * Reads recent audit entries from the end of the JSONL audit log with a bounded
51
+ * buffer window to prevent memory spikes on long-lived installations.
52
+ */
49
53
  export function getRecentAuditEntries(limit = 10): AuditEntry[] {
50
54
  try {
51
55
  if (!existsSync(AUDIT_FILE)) return [];
52
- const lines = readFileSync(AUDIT_FILE, "utf8").trim().split("\n");
56
+ const stat = statSync(AUDIT_FILE);
57
+ if (stat.size === 0) return [];
58
+
59
+ const maxBytesToRead = Math.min(stat.size, 65_536);
60
+ const buffer = Buffer.alloc(maxBytesToRead);
61
+ const fd = openSync(AUDIT_FILE, "r");
62
+ try {
63
+ readSync(fd, buffer, 0, maxBytesToRead, stat.size - maxBytesToRead);
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+
68
+ const text = buffer.toString("utf8");
69
+ const lines = text.trim().split("\n");
70
+ const completeLines = maxBytesToRead < stat.size ? lines.slice(1) : lines;
71
+
53
72
  const entries: AuditEntry[] = [];
54
- for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
55
- const line = lines[i]?.trim();
73
+ for (let i = completeLines.length - 1; i >= 0 && entries.length < limit; i--) {
74
+ const line = completeLines[i]?.trim();
56
75
  if (line) {
57
76
  try {
58
77
  entries.push(JSON.parse(line));
package/src/lib/state.ts CHANGED
@@ -171,6 +171,72 @@ export function resetReviewState(): void {
171
171
 
172
172
  export let cachedProjectConfig: ProjectConfig | undefined;
173
173
 
174
+ /**
175
+ * Strips single-line comments, block comments, and trailing commas from JSONC
176
+ * text without corrupting comment markers inside string literals or URLs.
177
+ */
178
+ export function stripJsonComments(jsonc: string): string {
179
+ let insideString = false;
180
+ let stringQuote = "";
181
+ let escaped = false;
182
+ let output = "";
183
+ let i = 0;
184
+
185
+ while (i < jsonc.length) {
186
+ const char = jsonc[i]!;
187
+ const next = jsonc[i + 1];
188
+
189
+ if (escaped) {
190
+ output += char;
191
+ escaped = false;
192
+ i++;
193
+ continue;
194
+ }
195
+
196
+ if (char === "\\") {
197
+ output += char;
198
+ escaped = true;
199
+ i++;
200
+ continue;
201
+ }
202
+
203
+ if (char === '"' || char === "'") {
204
+ if (!insideString) {
205
+ insideString = true;
206
+ stringQuote = char;
207
+ } else if (stringQuote === char) {
208
+ insideString = false;
209
+ }
210
+ output += char;
211
+ i++;
212
+ continue;
213
+ }
214
+
215
+ if (!insideString) {
216
+ if (char === "/" && next === "/") {
217
+ i += 2;
218
+ while (i < jsonc.length && jsonc[i] !== "\n" && jsonc[i] !== "\r") {
219
+ i++;
220
+ }
221
+ continue;
222
+ }
223
+ if (char === "/" && next === "*") {
224
+ i += 2;
225
+ while (i < jsonc.length && !(jsonc[i] === "*" && jsonc[i + 1] === "/")) {
226
+ i++;
227
+ }
228
+ i += 2;
229
+ continue;
230
+ }
231
+ }
232
+
233
+ output += char;
234
+ i++;
235
+ }
236
+
237
+ return output.replace(/,(\s*[}\]])/g, "$1");
238
+ }
239
+
174
240
  export function loadProjectConfig(root: string): ProjectConfig {
175
241
  const candidates = [
176
242
  join(root, ".opencode", "workflow-guard.json"),
@@ -181,7 +247,8 @@ export function loadProjectConfig(root: string): ProjectConfig {
181
247
  for (const candidate of candidates) {
182
248
  try {
183
249
  const raw = readFileSync(candidate, "utf8");
184
- return JSON.parse(raw);
250
+ const sanitized = stripJsonComments(raw);
251
+ return JSON.parse(sanitized);
185
252
  } catch {}
186
253
  }
187
254
  return {};
@@ -1,5 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
3
4
  import type { ShellMutation } from "../lib/types.ts";
4
5
  import {
5
6
  getWorkspaceRoot,
@@ -16,9 +17,47 @@ import {
16
17
  hasActiveTodo,
17
18
  } from "./todo.ts";
18
19
 
20
+ /**
21
+ * Expands leading `~`, `~user`, `$HOME`, and `${HOME}` in candidate targets so
22
+ * shell-level expansion cannot evade workspace boundary checks. Paths that
23
+ * contain unresolvable `$VARIABLE` references return `null` so callers treat
24
+ * indeterminate destinations as outside-workspace (fail-closed).
25
+ */
26
+ export function expandShellTargetPath(targetPath: string): string | null {
27
+ if (!targetPath) return targetPath;
28
+ const trimmed = targetPath.trim().replace(/^["']|["']$/g, "");
29
+ if (!trimmed) return trimmed;
30
+
31
+ const home = process.env.HOME || homedir();
32
+
33
+ if (trimmed === "~") return home;
34
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
35
+ return join(home, trimmed.slice(2));
36
+ }
37
+ if (/^~[A-Za-z0-9_.-]+(?:\/|\\|$)/.test(trimmed)) {
38
+ const parts = trimmed.slice(1).split(/[/\\]/);
39
+ const user = parts[0]!;
40
+ const rest = parts.slice(1);
41
+ return join(dirname(home), user, ...rest);
42
+ }
43
+
44
+ let out = trimmed;
45
+ if (/^\$(?:HOME|\{HOME\})(?=$|[/\\])/.test(out)) {
46
+ out = out.replace(/^\$(?:HOME|\{HOME\})/, home);
47
+ }
48
+
49
+ if (out.includes("$")) {
50
+ return null;
51
+ }
52
+
53
+ return out;
54
+ }
55
+
19
56
  export function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
20
57
  if (!targetPath) return false;
21
- const resolved = resolve(root, targetPath);
58
+ const expanded = expandShellTargetPath(targetPath);
59
+ if (expanded === null) return true;
60
+ const resolved = resolve(root, expanded);
22
61
  const normalizedRoot = root.endsWith("/") ? root : root + "/";
23
62
  if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
24
63
  return true;
@@ -64,26 +103,53 @@ export function extractPatchPaths(patchText: string): string[] {
64
103
  return paths;
65
104
  }
66
105
 
106
+ export function teeTargetsIn(segment: string): string[] {
107
+ const words = shellWords(unwrapShellCommand(segment));
108
+ if (words[0] !== "tee") {
109
+ const teeIdx = words.indexOf("tee");
110
+ if (teeIdx === -1) return [];
111
+ }
112
+ const startIdx = words[0] === "tee" ? 1 : words.indexOf("tee") + 1;
113
+ const targets: string[] = [];
114
+ let stopFlags = false;
115
+ for (let i = startIdx; i < words.length; i++) {
116
+ const w = words[i]!;
117
+ if (/^[|;&<>]/.test(w)) break;
118
+ if (!stopFlags) {
119
+ if (w === "--") {
120
+ stopFlags = true;
121
+ continue;
122
+ }
123
+ if (w.startsWith("-")) continue;
124
+ }
125
+ targets.push(w);
126
+ }
127
+ return targets;
128
+ }
129
+
67
130
  export function shellMutationIn(segment: string): ShellMutation | undefined {
68
131
  const redirectMatch = segment.match(
69
- /(?:^|\s|>)(?:[0-9]*>>?)\s*["']?([^\s>&|;"']+)/,
132
+ /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/,
70
133
  );
71
- if (redirectMatch?.[1]) {
72
- if (/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(redirectMatch[1])) {
73
- return undefined;
134
+ if (redirectMatch?.[1] && redirectMatch?.[2]) {
135
+ const op = redirectMatch[1];
136
+ const target = redirectMatch[2];
137
+ // Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
138
+ const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
139
+ if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
140
+ return {
141
+ kind: "redirect",
142
+ target,
143
+ what: `file redirect to '${target}'`,
144
+ };
74
145
  }
75
- return {
76
- kind: "redirect",
77
- target: redirectMatch[1],
78
- what: `file redirect to '${redirectMatch[1]}'`,
79
- };
80
146
  }
81
- const teeMatch = segment.match(/\btee\s+(?:-a\s+)?["']?([^\s;&|"']+)/);
82
- if (teeMatch?.[1]) {
147
+ const teeTargets = teeTargetsIn(segment);
148
+ if (teeTargets.length > 0) {
83
149
  return {
84
150
  kind: "command",
85
- target: teeMatch[1],
86
- what: `tee to '${teeMatch[1]}'`,
151
+ target: teeTargets[0]!,
152
+ what: `tee to '${teeTargets[0]!}'`,
87
153
  };
88
154
  }
89
155
  const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
@@ -205,12 +271,20 @@ export async function guardShellMutation(
205
271
  }
206
272
  }
207
273
  const simpleMutations = simpleFilesystemMutations(segment);
274
+ const teeTargets = teeTargetsIn(segment);
275
+ const teeMutations: ShellMutation[] = teeTargets.map((target) => ({
276
+ kind: "command" as const,
277
+ target,
278
+ what: `tee to '${target}'`,
279
+ }));
208
280
  const fallbackMutation = shellMutationIn(segment.trim());
209
281
  const mutations = simpleMutations.length > 0
210
282
  ? simpleMutations
211
- : fallbackMutation
212
- ? [fallbackMutation]
213
- : [];
283
+ : teeMutations.length > 0
284
+ ? teeMutations
285
+ : fallbackMutation
286
+ ? [fallbackMutation]
287
+ : [];
214
288
  for (const mutation of mutations) {
215
289
  hasMutation = true;
216
290
  const secret = secretIn(segment);
@@ -44,6 +44,9 @@ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
44
44
  "--exec-path",
45
45
  ]);
46
46
 
47
+ const GIT_ENV_DIR_RE =
48
+ /(?:^|\s)(?:env\s+(?:-\S+\s+)*)?(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(?:"([^"]*)"|'([^']*)'|(\S+))/;
49
+
47
50
  export function parseGitInvocation(command: string): GitInvocation | undefined {
48
51
  const root = getWorkspaceRoot();
49
52
  const tokens = unwrapShellWords(command);
@@ -51,12 +54,22 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
51
54
  let i = 1;
52
55
  let repoDir = root;
53
56
  let sawDirOption = false;
57
+
58
+ const envDirMatch = command.match(GIT_ENV_DIR_RE);
59
+ if (envDirMatch) {
60
+ const envVal = envDirMatch[1] ?? envDirMatch[2] ?? envDirMatch[3];
61
+ if (envVal) {
62
+ repoDir = resolve(root, envVal);
63
+ sawDirOption = true;
64
+ }
65
+ }
66
+
54
67
  while (i < tokens.length) {
55
68
  const tok = tokens[i]!;
56
69
  if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
57
70
  const value = tokens[i + 1];
58
71
  if (value === undefined) return { repoDir, rest: "" };
59
- if (tok === "-C" || tok === "--git-dir") {
72
+ if (tok === "-C" || tok === "--git-dir" || tok === "--work-tree") {
60
73
  repoDir = resolve(root, value);
61
74
  sawDirOption = true;
62
75
  }
@@ -67,6 +80,9 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
67
80
  if (tok.startsWith("--git-dir=")) {
68
81
  repoDir = resolve(root, tok.slice("--git-dir=".length));
69
82
  sawDirOption = true;
83
+ } else if (tok.startsWith("--work-tree=")) {
84
+ repoDir = resolve(root, tok.slice("--work-tree=".length));
85
+ sawDirOption = true;
70
86
  }
71
87
  i += 1;
72
88
  continue;
@@ -1,7 +1,10 @@
1
+ import { isSecretPath } from "./secrets.ts";
2
+ import { isPathOutsideWorkspace } from "./boundary.ts";
3
+
1
4
  export function extractInterpreterPayload(segment: string): string[] {
2
5
  const payloads: string[] = [];
3
6
  const inlineMatch = segment.match(
4
- /\b(?:python3?|node|perl|ruby|osascript)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
7
+ /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
5
8
  );
6
9
  if (inlineMatch?.[1] || inlineMatch?.[2]) {
7
10
  payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
@@ -26,3 +29,52 @@ export function extractInterpreterPayload(segment: string): string[] {
26
29
  }
27
30
  return payloads;
28
31
  }
32
+
33
+ const FILE_ACCESS_VERB_RE =
34
+ /\b(?:open|cat|read|readFile|readFileSync|read_text|readTextFile|write|writeFile|writeFileSync|write_text|writeTextFile|source|slurp|appendFile|appendFileSync|createReadStream|createWriteStream|load|loads|exec|execSync|spawn|spawnSync)\b/i;
35
+
36
+ /**
37
+ * Detects sensitive credential/secret file targets referenced inside inline
38
+ * interpreter scripts or subshells.
39
+ */
40
+ export function secretPathInPayload(payload: string): string | undefined {
41
+ if (!FILE_ACCESS_VERB_RE.test(payload)) return undefined;
42
+ const tokens = payload.match(/["'][^"']*["']|[^\s(){}[\];,|&<>]+/g) ?? [];
43
+ for (const token of tokens) {
44
+ const cleaned = token.replace(/^["']|["']$/g, "");
45
+ if (cleaned && isSecretPath(cleaned)) {
46
+ return cleaned;
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ const FUNCTION_TARGET_PATTERNS: RegExp[] = [
53
+ /\b(?:writeFileSync|writeFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rmSync|rmdirSync|mkdirSync|renameSync|copyFileSync|cpSync|truncateSync)\s*\(\s*["']([^"']+)["']/g,
54
+ /\b(?:open|os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move|Path)\s*\(\s*["']([^"']+)["']/g,
55
+ /[>]{1,2}\s*["']?([^\s;&"'|)]+)/g,
56
+ ];
57
+
58
+ /**
59
+ * Detects workspace boundary escapes (writes/redirects to paths outside the
60
+ * workspace root) initiated from inline interpreter payloads. Extracts destination
61
+ * file arguments from file-writing APIs and shell redirects.
62
+ */
63
+ export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
64
+ const candidates = new Set<string>();
65
+
66
+ for (const pattern of FUNCTION_TARGET_PATTERNS) {
67
+ const matches = payload.matchAll(pattern);
68
+ for (const m of matches) {
69
+ if (m[1]) candidates.add(m[1]);
70
+ }
71
+ }
72
+
73
+ for (const candidate of candidates) {
74
+ if (/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(candidate)) continue;
75
+ if (isPathOutsideWorkspace(candidate, root)) {
76
+ return candidate;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
@@ -100,18 +100,43 @@ export function isEnvFilePath(targetPath: string): boolean {
100
100
 
101
101
  /**
102
102
  * Parses a raw .env file and returns a sanitized schema mask where variable
103
- * names and comment structures are preserved, but sensitive values are
104
- * redacted to '********'.
103
+ * names and comment structures are preserved, but sensitive values (including
104
+ * multiline secret continuations) are redacted to '********'.
105
105
  */
106
106
  export function generateMaskedEnvSchema(content: string): string {
107
107
  const lines = content.split("\n");
108
+ let inMultilineQuote: string | null = null;
109
+
108
110
  return lines
109
111
  .map((line) => {
110
112
  const trimmed = line.trim();
111
- if (!trimmed || trimmed.startsWith("#")) return line;
113
+ if (!trimmed || trimmed.startsWith("#")) {
114
+ inMultilineQuote = null;
115
+ return line;
116
+ }
117
+
118
+ if (inMultilineQuote) {
119
+ if (trimmed.endsWith(inMultilineQuote) || trimmed.includes(inMultilineQuote)) {
120
+ inMultilineQuote = null;
121
+ }
122
+ return "********";
123
+ }
124
+
112
125
  const eqIdx = line.indexOf("=");
113
- if (eqIdx === -1) return line;
126
+ if (eqIdx === -1) {
127
+ return "********";
128
+ }
129
+
114
130
  const key = line.slice(0, eqIdx);
131
+ const val = line.slice(eqIdx + 1).trim();
132
+
133
+ if (
134
+ (val.startsWith('"') && !val.slice(1).includes('"')) ||
135
+ (val.startsWith("'") && !val.slice(1).includes("'"))
136
+ ) {
137
+ inMultilineQuote = val[0]!;
138
+ }
139
+
115
140
  return `${key}=********`;
116
141
  })
117
142
  .join("\n");
@@ -97,6 +97,17 @@ export function checkInteractiveTtyCommand(command: string): { isInteractive: bo
97
97
  return { isInteractive: false };
98
98
  }
99
99
 
100
+ /**
101
+ * Escapes strings safely for interpolation into AppleScript string literals.
102
+ * Replaces newlines/CRs with spaces, escapes backslashes first, then double quotes.
103
+ */
104
+ export function escapeAppleScriptString(str: string): string {
105
+ return str
106
+ .replace(/[\r\n]+/g, " ")
107
+ .replace(/\\/g, "\\\\")
108
+ .replace(/"/g, '\\"');
109
+ }
110
+
100
111
  /**
101
112
  * Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
102
113
  * on important guard events (policy blocks, verification completion).
@@ -104,11 +115,11 @@ export function checkInteractiveTtyCommand(command: string): { isInteractive: bo
104
115
  */
105
116
  export function sendDesktopNotification(title: string, message: string): void {
106
117
  if (process.env.WORKFLOW_GUARD_NOTIFY === "0") return;
107
- const safeTitle = title.replace(/"/g, '\\"');
108
- const safeMsg = message.slice(0, 150).replace(/"/g, '\\"');
109
118
 
110
119
  try {
111
120
  if (process.platform === "darwin") {
121
+ const safeTitle = escapeAppleScriptString(title);
122
+ const safeMsg = escapeAppleScriptString(message.slice(0, 150));
112
123
  const child = spawn(
113
124
  "osascript",
114
125
  ["-e", `display notification "${safeMsg}" with title "${safeTitle}"`],
@@ -117,6 +128,8 @@ export function sendDesktopNotification(title: string, message: string): void {
117
128
  child.on("error", () => {});
118
129
  child.unref();
119
130
  } else if (process.platform === "linux") {
131
+ const safeTitle = title.replace(/[\r\n\0]/g, " ");
132
+ const safeMsg = message.slice(0, 150).replace(/[\0]/g, "");
120
133
  const child = spawn("notify-send", [safeTitle, safeMsg], {
121
134
  stdio: "ignore",
122
135
  detached: true,
@@ -18,7 +18,7 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
18
18
  new RegExp(`${GT}\\s*["']?[\\w\\/.~-]*(?:${C_LIST})`, "i"),
19
19
  new RegExp(`(?:^|\\s)(?:${V_LIST})\\s+[^|;&]*?[\\w\\/.~-]*(?:${U_LIST})`, "i"),
20
20
  new RegExp(`${GT}\\s*["']?[\\w\\/.~-]*(?:${U_LIST})`, "i"),
21
- new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:auth|config|permission)\\b", "i"),
21
+ new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:-[^|;&]*\\s+)*(?:auth|config|permission)\\b", "i"),
22
22
  new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:run\\s+)?--auto\\b", "i"),
23
23
  ];
24
24
 
@@ -44,6 +44,7 @@ import {
44
44
  sessionVerifyResults,
45
45
  loadProjectConfig,
46
46
  reloadProjectConfig,
47
+ stripJsonComments,
47
48
  isReviewRequired,
48
49
  isDocumentationRequired,
49
50
  recordReviewResult,
@@ -64,6 +65,7 @@ export {
64
65
  resetVerifyState,
65
66
  loadProjectConfig,
66
67
  reloadProjectConfig,
68
+ stripJsonComments,
67
69
  isReviewRequired,
68
70
  isDocumentationRequired,
69
71
  recordReviewResult,
@@ -201,8 +203,12 @@ import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMasked
201
203
 
202
204
  export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
203
205
 
204
- import { extractInterpreterPayload } from "./policies/interpreter.ts";
205
- export { extractInterpreterPayload };
206
+ import {
207
+ extractInterpreterPayload,
208
+ secretPathInPayload,
209
+ outsideWritePathInPayload,
210
+ } from "./policies/interpreter.ts";
211
+ export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
206
212
 
207
213
  import { branchHasDocumentationChange } from "./policies/docs.ts";
208
214
  export { branchHasDocumentationChange };
@@ -211,13 +217,14 @@ import {
211
217
  checkInteractiveTtyCommand,
212
218
  checkPackageHygiene,
213
219
  sendDesktopNotification,
220
+ escapeAppleScriptString,
214
221
  } from "./policies/shell-safety.ts";
215
222
 
216
223
  import { checkCompletionClaims } from "./policies/completion.ts";
217
224
 
218
225
  export { checkCompletionClaims };
219
226
 
220
- export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
227
+ export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification, escapeAppleScriptString };
221
228
 
222
229
  const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
223
230
 
@@ -610,8 +617,18 @@ async function guardToolCallImpl(
610
617
  }
611
618
 
612
619
  // ── Policy 18: interpreter inline evasion scanner ──
613
- if (!allowLive) {
614
- for (const payload of extractInterpreterPayload(command)) {
620
+ for (const payload of extractInterpreterPayload(command)) {
621
+ // Workspace boundary check inside inline interpreter payloads has no
622
+ // allow-live override (same invariant as file redirects).
623
+ const outsidePath = outsideWritePathInPayload(payload, currentRoot);
624
+ if (outsidePath) {
625
+ logBlock(
626
+ `[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`,
627
+ );
628
+ return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
629
+ }
630
+
631
+ if (!allowLive) {
615
632
  const normPayload = normalizeGitCommands(normalize(payload));
616
633
  const liveCheck = liveMutationIn(normPayload);
617
634
  if (liveCheck) {
@@ -626,6 +643,16 @@ async function guardToolCallImpl(
626
643
  );
627
644
  return PROTECTED_PATH_REASON;
628
645
  }
646
+ const secretPath = secretPathInPayload(payload);
647
+ if (secretPath) {
648
+ logBlock(
649
+ `[workflow-guard] blocked interpreter payload accessing secret file: ${secretPath}`,
650
+ );
651
+ return (
652
+ `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted. ` +
653
+ "Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
654
+ );
655
+ }
629
656
  }
630
657
  }
631
658
 
@@ -993,14 +1020,20 @@ export const WorkflowGuard: Plugin = async (ctx) => {
993
1020
  .describe("Base ref to diff against (default: origin/main, origin/master, main)"),
994
1021
  },
995
1022
  execute: async (args) => {
996
- const bases = args.base
997
- ? [args.base]
1023
+ const sanitizedBase =
1024
+ typeof args.base === "string" &&
1025
+ !args.base.startsWith("-") &&
1026
+ !/[\s;'"\0]/.test(args.base)
1027
+ ? args.base
1028
+ : undefined;
1029
+ const bases = sanitizedBase
1030
+ ? [sanitizedBase]
998
1031
  : ["origin/main", "origin/master", "main", "master"];
999
1032
  let diffText = "";
1000
1033
  for (const base of bases) {
1001
1034
  const res = spawnSync(
1002
1035
  "git",
1003
- ["diff", `${base}...HEAD`],
1036
+ ["diff", "--", `${base}...HEAD`],
1004
1037
  { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
1005
1038
  );
1006
1039
  if (res.status === 0 && res.stdout.trim()) {
@@ -1009,7 +1042,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
1009
1042
  }
1010
1043
  }
1011
1044
  if (!diffText) {
1012
- const last = spawnSync("git", ["diff", "HEAD~1"], {
1045
+ const last = spawnSync("git", ["diff", "--", "HEAD~1"], {
1013
1046
  cwd: getWorkspaceRoot(),
1014
1047
  encoding: "utf8",
1015
1048
  timeout: 10_000,