opencode-workflow-guard 1.3.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.3.2",
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/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 {};
@@ -49,25 +49,25 @@ export function secretPathInPayload(payload: string): string | undefined {
49
49
  return undefined;
50
50
  }
51
51
 
52
- const WRITE_VERB_RE =
53
- /\b(?:write|writeFile|writeFileSync|write_text|writeTextFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rm|rmSync|rmdir|rmdirSync|mkdir|mkdirSync|rename|renameSync|copy|cp|cpSync|truncate|move|shutil|remove|delete)\b|\bopen\s*\(\s*["'][^"']*["']\s*,\s*["'][wa]/i;
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
+ ];
54
57
 
55
58
  /**
56
59
  * Detects workspace boundary escapes (writes/redirects to paths outside the
57
- * workspace root) initiated from inline interpreter payloads.
60
+ * workspace root) initiated from inline interpreter payloads. Extracts destination
61
+ * file arguments from file-writing APIs and shell redirects.
58
62
  */
59
63
  export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
60
- const hasWrite = WRITE_VERB_RE.test(payload) || /[>]{1,2}\s*\S/.test(payload);
61
- if (!hasWrite) return undefined;
62
-
63
64
  const candidates = new Set<string>();
64
- const redirectMatches = payload.matchAll(/[>]{1,2}\s*["']?([^\s;&"'|)]+)/g);
65
- for (const m of redirectMatches) {
66
- if (m[1]) candidates.add(m[1]);
67
- }
68
- const literalMatches = payload.matchAll(/["']([^"']{2,})["']/g);
69
- for (const m of literalMatches) {
70
- if (m[1]) candidates.add(m[1]);
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
71
  }
72
72
 
73
73
  for (const candidate of candidates) {
@@ -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,