opencode-workflow-guard 1.13.3 → 1.13.5

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.5",
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 {
@@ -8,15 +8,106 @@
8
8
  */
9
9
 
10
10
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
11
- import { Plugin } from "@opencode/plugin/tui";
11
+ import { Plugin, usePlugin } from "@opencode/plugin/tui";
12
12
  import type { JSX } from "@opentui/solid";
13
- import { createElement, insert, setProp } from "@opentui/solid";
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { createElement, insert, setProp, RendererContext } from "@opentui/solid";
14
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
15
  import { dirname, isAbsolute, join, relative } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { getOwner, useContext } from "solid-js";
16
18
  import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
17
19
  import { projectConfigPath } from "./lib/project-config.ts";
18
20
  import { canonicalPath } from "./policies/file-claims.ts";
19
21
 
22
+ // ── V2 render diagnostics (bounded; never throws) ────────────────────────────
23
+ // Slot renders run inside the TUI provider tree, but recurring failures have
24
+ // been observed in long-running sessions after plugin versions were swapped
25
+ // (opencode 2.0.10, "No renderer found" from @opentui/solid createElement).
26
+ // Instead of rethrowing — which makes the host error boundary toast on every
27
+ // retry for a cosmetic badge — a failing slot renders null, and failures are
28
+ // logged with owner/renderer context to a state file, bounded to the first
29
+ // DIAG_MAX_FAILURES_PER_SLOT failures per slot between successes. The next
30
+ // render attempt retries naturally, and a later success is logged again.
31
+ const DIAG_DIR = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode");
32
+ const DIAG_LOG = join(DIAG_DIR, "workflow-guard-ui.log");
33
+ const DIAG_MAX_FAILURES_PER_SLOT = 3;
34
+ const DIAG_MAX_LOG_BYTES = 2_000_000;
35
+
36
+ function diag(line: string): void {
37
+ try {
38
+ mkdirSync(DIAG_DIR, { recursive: true });
39
+ if (existsSync(DIAG_LOG) && statSync(DIAG_LOG).size > DIAG_MAX_LOG_BYTES) {
40
+ writeFileSync(DIAG_LOG, "", { mode: 0o600 });
41
+ }
42
+ appendFileSync(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
43
+ } catch {
44
+ // diagnostics must never crash the plugin
45
+ }
46
+ }
47
+
48
+ try {
49
+ diag(`module load: ${import.meta.url}; @opentui/solid -> ${import.meta.resolve("@opentui/solid")}; solid-js -> ${import.meta.resolve("solid-js")}`);
50
+ } catch (error) {
51
+ diag(`module load: ${import.meta.url}; resolve probe failed: ${error}`);
52
+ }
53
+
54
+ const diagOkSlots = new Set<string>();
55
+ const diagFailureCount = new Map<string, number>();
56
+
57
+ function diagRender(slot: string): void {
58
+ try {
59
+ const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
60
+ const contextKeys = owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "no-owner";
61
+ const renderer = useContext(RendererContext);
62
+ let plugin = "ok";
63
+ try {
64
+ usePlugin();
65
+ } catch (error) {
66
+ plugin = `FAILED: ${error}`;
67
+ }
68
+ if (!diagOkSlots.has(slot)) {
69
+ diagOkSlots.add(slot);
70
+ diag(`render ${slot} ok; owner=${owner ? "present" : "null"}; contextKeys=[${contextKeys}]; rendererContext=${renderer ? "found" : "MISSING"}; usePlugin=${plugin}`);
71
+ }
72
+ } catch (error) {
73
+ diag(`render ${slot} probe FAILED: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
74
+ }
75
+ }
76
+
77
+ function diagFailure(slot: string, error: unknown): void {
78
+ const count = (diagFailureCount.get(slot) ?? 0) + 1;
79
+ diagFailureCount.set(slot, count);
80
+ if (count > DIAG_MAX_FAILURES_PER_SLOT) {
81
+ if (count === DIAG_MAX_FAILURES_PER_SLOT + 1) {
82
+ diag(`render ${slot} still failing after ${DIAG_MAX_FAILURES_PER_SLOT} logged failures; further failures are suppressed until a render succeeds`);
83
+ }
84
+ return;
85
+ }
86
+ const lines = [
87
+ `render ${slot} FAILED (${count}/${DIAG_MAX_FAILURES_PER_SLOT}): ${error instanceof Error ? error.message : String(error)}`,
88
+ ` stack: ${error instanceof Error ? error.stack : "(none)"}`,
89
+ ];
90
+ try {
91
+ const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
92
+ lines.push(` owner=${owner ? "present" : "null"}; contextKeys=[${owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "none"}]`);
93
+ } catch (probeError) {
94
+ lines.push(` owner probe failed: ${probeError}`);
95
+ }
96
+ try {
97
+ lines.push(` useContext(RendererContext): ${useContext(RendererContext) ? "found" : "MISSING"}`);
98
+ } catch (probeError) {
99
+ lines.push(` renderer probe failed: ${probeError}`);
100
+ }
101
+ try {
102
+ usePlugin();
103
+ lines.push(` usePlugin: ok`);
104
+ } catch (probeError) {
105
+ lines.push(` usePlugin: FAILED ${probeError}`);
106
+ }
107
+ diag(lines.join("\n"));
108
+ }
109
+ // ── end render diagnostics ───────────────────────────────────────────────────
110
+
20
111
  type Child = JSX.Element | string | number | null | undefined | false;
21
112
 
22
113
  function element(
@@ -98,6 +189,8 @@ const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: st
98
189
  ];
99
190
 
100
191
  export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
192
+ diag(`setup: app=${ctx.app?.version ?? "?"}; location=${ctx.location?.directory ?? "?"}`);
193
+
101
194
  const badge = () => {
102
195
  const formatted = formatBadge();
103
196
  return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
@@ -164,8 +257,29 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
164
257
  },
165
258
  });
166
259
 
167
- ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
168
- ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
260
+ const registerBadgeSlot = (slot: NonNullable<Parameters<TuiContext["ui"]["slot"]>[0]["append"]>) => {
261
+ ctx.ui.slot({
262
+ append: slot,
263
+ render: () => {
264
+ try {
265
+ const element = badge();
266
+ if (!diagOkSlots.has(slot)) {
267
+ diagOkSlots.add(slot);
268
+ diagFailureCount.delete(slot);
269
+ diag(`render ${slot} ok`);
270
+ }
271
+ return element;
272
+ } catch (error) {
273
+ diagOkSlots.delete(slot);
274
+ diagFailure(slot, error);
275
+ return null;
276
+ }
277
+ },
278
+ });
279
+ };
280
+ registerBadgeSlot("home.footer.status");
281
+ registerBadgeSlot("prompt.footer.status");
282
+ diagRender("probe-after-setup");
169
283
 
170
284
  return () => {
171
285
  // Slot claims and keymap layers are disposed automatically on unload.
package/test/test.mts CHANGED
@@ -114,7 +114,7 @@ import { audit } from "../src/lib/audit.ts";
114
114
  import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
115
115
  import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
116
116
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
117
- import { WorkflowGuardTui, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
117
+ import { WorkflowGuardTui, WorkflowGuardTuiV2, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
118
118
  import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
119
119
  import { effectiveTodosWithOwner } from "../src/policies/todo.ts";
120
120
 
@@ -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" })));
@@ -1340,6 +1345,32 @@ check("recovery checkpoint project option refuses symlink escape", tuiSymlinkRej
1340
1345
  rmSync(outsideTuiOptions, { recursive: true, force: true });
1341
1346
  rmSync(tuiOptionsDir, { recursive: true, force: true });
1342
1347
 
1348
+ // V2 TUI companion degrades gracefully when slot renders fail (bounded diagnostics)
1349
+ const v2SlotClaims: Array<{ append: string; render: () => unknown }> = [];
1350
+ const fakeV2TuiCtx = {
1351
+ app: { version: "9.9.9-test", channel: "test" },
1352
+ location: { directory: tuiCommandOptionsDir },
1353
+ theme: { text: { feedback: { success: { base: "#00ff00" } } } },
1354
+ ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
1355
+ };
1356
+ await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
1357
+ check("v2 tui companion registers home and prompt status slots", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 2);
1358
+ const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
1359
+ const diagLogText = () => readFileSync(diagLogPath, "utf8");
1360
+ check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
1361
+ const v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
1362
+ // In a plain Node process there is no OpenTUI renderer, so badge() throws the
1363
+ // same "No renderer found" the long-running-session bug produced in the TUI.
1364
+ check("v2 slot render returns null instead of throwing when the render context is unavailable", v2HomeRender() === null);
1365
+ check("v2 slot failure log records probes and stack", diagLogText().includes("render home.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
1366
+ for (let i = 0; i < 6; i++) v2HomeRender();
1367
+ const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
1368
+ check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
1369
+ check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
1370
+ const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
1371
+ check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
1372
+
1373
+
1343
1374
  // ── Adversarial tests & hardened invariants ──
1344
1375
  console.log("- Adversarial tests & hardened invariants -");
1345
1376