opencode-workflow-guard 1.13.3 → 1.13.4

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/docs/policies.md CHANGED
@@ -60,7 +60,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
60
60
  - Direct edits of the same paths through the `edit`/`write`/`apply_patch` tools
61
61
  - Shell commands invoking `opencode auth`, `opencode config`, `opencode permission`, or `opencode run --auto`
62
62
  - Evasion-normalized: quote-concatenation (`open''code.json`), escapes (`open\c\ode`), and glob wildcards (`opencode.jso?`) are stripped before matching.
63
- - Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax (redirect analysis runs on the quote-stripped residue), and the redirect heuristic applies to shell commands only (write/edit content is guarded by the target path check instead).
63
+ - Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax, and the redirect heuristic applies to shell commands only (write/edit content is guarded by the target path check instead).
64
+ - Quoted data spans are not shell syntax: pattern matching runs on the quote-stripped residue, where redirect targets are honored quoted or not and quote-concatenated paths keep normalizing. A quoted span containing a guarded path is never treated as a redirect or command verb; unquoted redirects into guarded paths stay blocked.
64
65
  - Plan files under `.opencode/plans/` are exempt: opencode's plan mode writes agent-authored plan markdown there, and plans are content, not configuration. Everything else under `.opencode/` (including the directory itself) stays protected.
65
66
  - **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
66
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.13.3",
3
+ "version": "1.13.4",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/lib/shell.ts CHANGED
@@ -75,6 +75,24 @@ export function decodeShellEscapes(text: string): string {
75
75
  .replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
76
76
  }
77
77
 
78
+ // Reduce a shell segment to the syntax that actually executes: redirect
79
+ // targets are honored whether quoted or not (a quoted redirect target is
80
+ // still a real file), while SELF-CONTAINED quoted spans preceded by
81
+ // whitespace are command data and can never be shell redirects or
82
+ // operators. Quote spans glued to bare text are shell concatenation inside
83
+ // a single word (e.g. open''code.json) and are kept for the normalizer to
84
+ // flatten. A quoted span at segment start is NOT stripped: it can be a
85
+ // quoted command word ("opencode" auth) and must stay visible to verb
86
+ // patterns.
87
+ export function prepareRedirectResidue(segment: string): string {
88
+ const withTargets = segment
89
+ .replace(/(>>?)\s*'([^']*)'/g, "$1 $2")
90
+ .replace(/(>>?)\s*"([^"]*)"/g, '$1 $2');
91
+ return withTargets
92
+ .replace(/\s'[^']*'(?=$|[\s)>;&|])/g, " ")
93
+ .replace(/\s"[^"]*"(?=$|[\s)>;&|])/g, " ");
94
+ }
95
+
78
96
  export function shellWords(command: string): string[] {
79
97
  const words: string[] = [];
80
98
  let word = "";
@@ -7,7 +7,7 @@ import {
7
7
  getWorkspaceRootReal,
8
8
  recordMutation,
9
9
  } from "../lib/state.ts";
10
- import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
10
+ import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
11
11
  import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
12
12
  import { isSecretPath, secretIn } from "./secrets.ts";
13
13
  import { onProtectedBranch, branchGuardReason } from "./git.ts";
@@ -129,10 +129,14 @@ export function teeTargetsIn(segment: string): string[] {
129
129
 
130
130
  function redirectMutationsIn(segment: string): ShellMutation[] {
131
131
  const mutations: ShellMutation[] = [];
132
+ // Redirect detection runs on the quote-stripped residue: quoted data
133
+ // spans are command data and their ">" characters are not redirects,
134
+ // while redirect targets keep their value whether quoted or not.
132
135
  // The `(?!=)` lookahead after the op rejects comparison operators (`>=`,
133
136
  // `==`) so they cannot match as a redirect op with `=` as its target.
137
+ const residue = prepareRedirectResidue(segment);
134
138
  const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?(?!=))\s*["']?([^\s>&|;"']+)/g;
135
- for (const redirectMatch of segment.matchAll(redirectRe)) {
139
+ for (const redirectMatch of residue.matchAll(redirectRe)) {
136
140
  if (!redirectMatch[1] || !redirectMatch[2]) continue;
137
141
  const op = redirectMatch[1];
138
142
  const target = redirectMatch[2];
@@ -314,7 +318,10 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
314
318
  // Remove single- and double-quoted spans from a shell segment. Used to
315
319
  // analyze the residue of collaboration invocations: quoted arguments are
316
320
  // command data and can never be shell redirects, while unquoted redirects
317
- // keep receiving full validation.
321
+ // keep receiving full validation. NOTE: unlike shell.ts's
322
+ // prepareRedirectResidue, this strips EVERY span including redirect
323
+ // targets and glued concatenations - only use it for collaboration
324
+ // segments where all quoted content is command data.
318
325
  function stripQuotedSpans(segment: string): string {
319
326
  return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
320
327
  }
@@ -1,7 +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/shell.ts";
4
+ import { decodeShellEscapes, prepareRedirectResidue } from "../lib/shell.ts";
5
5
 
6
6
  export const PROTECTED_PATH_REASON =
7
7
  "Blocked: modifying Open" +
@@ -23,6 +23,15 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
23
23
  new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:run\\s+)?--auto\\b", "i"),
24
24
  ];
25
25
 
26
+ // Patterns 1-4 match redirect operators and mutation verbs writing into
27
+ // guarded paths: they run on the quote-stripped residue (quoted data spans
28
+ // are command data and their ">" characters are not redirects). Patterns
29
+ // 5-6 match the opencode CLI verbs themselves: those run on the
30
+ // quote-FLATTENED text, because a quoted command word or an eval payload
31
+ // still executes the verb.
32
+ const PATH_PATTERNS = SETTINGS_TAMPER_PATTERNS.slice(0, 4);
33
+ const VERB_PATTERNS = SETTINGS_TAMPER_PATTERNS.slice(4);
34
+
26
35
  export function normalizeShellEvasion(text: string): string {
27
36
  return decodeShellEscapes(text)
28
37
  .replace(/'([^']*)'/g, "$1")
@@ -48,13 +57,12 @@ export function isCollaborationInvocation(segment: string): boolean {
48
57
  }
49
58
 
50
59
  export function isSettingsTamper(command: string): boolean {
51
- const segments = command.split(/[\n|;&]+/).map((s) =>
52
- normalizeGlobPathEvasion(normalizeShellEvasion(s)),
53
- );
54
- return segments.some((segment) =>
55
- !isCollaborationInvocation(segment) &&
56
- SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
57
- );
60
+ return command.split(/[\n|;&]+/).some((s) => {
61
+ if (isCollaborationInvocation(s)) return false;
62
+ const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
63
+ const flattened = normalizeGlobPathEvasion(normalizeShellEvasion(s));
64
+ return PATH_PATTERNS.some((re) => re.test(residue)) || VERB_PATTERNS.some((re) => re.test(flattened));
65
+ });
58
66
  }
59
67
 
60
68
  export function isProtectedPath(targetPath: string): boolean {
package/test/test.mts CHANGED
@@ -539,6 +539,11 @@ check("allow gh issue create mentioning .opencode paths (not tamper)", !(await s
539
539
  check("compound collaboration + tamper still blocked", blocked(await shell(`gh issue create --repo example/proj --title t && echo x > open${"code.json"}`)));
540
540
  check("collaboration command with real redirect to global config still blocked", blocked(await shell(`gh issue list > /var/home/x/.config/open${"code"}/opencode.json`)));
541
541
  check("collaboration command with redirect outside workspace still blocked", blocked(await shell(`gh pr create --title t > /tmp/wg-escape-probe`)));
542
+ check("allow command with guarded-path text in quoted data (not tamper)", !(await shell(`echo "flow: x > ${ocDir}" > notes.md`)));
543
+ check("block unquoted redirect into .opencode", blocked(await shell(`echo x > .open${"code"}/y`)));
544
+ check("block redirect with quoted real target", blocked(await shell(`echo x > ".open${"code"}/opencode.json"`)));
545
+ check("block quoted command word running the CLI auth flow", blocked(await shell(`"open${"code"}" auth login`)));
546
+ check("block eval of a quoted CLI auth payload", blocked(await shell(`eval 'open${"code"} auth login'`)));
542
547
 
543
548
  console.log("- Policy 6: tamper via edit tools (path protection) -");
544
549
  check("block edit of project opencode.json", blocked(await call("edit", { filePath: join(root, "opencode.json"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));