opencode-workflow-guard 1.13.2 → 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.
@@ -27,12 +27,12 @@ OpenCode 2 documents `opencode plugin add <package>` as the command to install a
27
27
 
28
28
  Use the same two commands to upgrade after a Workflow Guard release. Keep the explicit version in OpenCode's configuration rather than relying on a bare package name or `@latest`: OpenCode caches npm plugins, and an already-populated `@latest` cache key may continue resolving to the version that originally populated it. An explicit new version creates a new package-cache key without manually deleting OpenCode's cache.
29
29
 
30
- If you only want the server guard without the TUI companion, add the package to your project's `opencode.json` (or global `~/.config/opencode/opencode.json`):
30
+ If you only want the server guard without the TUI companion, add the package to your project's `opencode.json` (or global `~/.config/opencode/opencode.json`). OpenCode 2 uses the plural `plugins` key; OpenCode 1.x uses the singular `plugin` key (V2 normalizes the singular form in memory, but the native shape is `plugins`):
31
31
 
32
32
  ```json
33
33
  {
34
34
  "$schema": "https://opencode.ai/config.json",
35
- "plugin": [
35
+ "plugins": [
36
36
  "opencode-workflow-guard"
37
37
  ]
38
38
  }
@@ -61,17 +61,31 @@ OpenCode automatically discovers the adapter from the project-level `.opencode/p
61
61
 
62
62
  To enable the static `Workflow Guard 🛡️` prompt-bar badge, with block details reported separately through warning toasts:
63
63
 
64
- The recommended `opencode plugin "opencode-workflow-guard@$VERSION" --global --force` command configures this automatically. For manual installation, use the same explicit package version in `~/.config/opencode/tui.json`:
64
+ The recommended `opencode plugin add "opencode-workflow-guard@$VERSION"` command configures this automatically: OpenCode detects both targets and registers the server plugin in the global `opencode.json(c)` and the TUI companion in the global `~/.config/opencode/cli.json` (OpenCode 2). For manual installation, use the same explicit package version in the TUI target of your OpenCode generation - global `~/.config/opencode/cli.json` with the `plugins` array on OpenCode 2:
65
+
66
+ ```json
67
+ {
68
+ "$schema": "https://opencode.ai/v2/cli.json",
69
+ "plugins": [
70
+ "opencode-workflow-guard@1.13.2"
71
+ ]
72
+ }
73
+ ```
74
+
75
+ or `~/.config/opencode/tui.json` with the `plugin` array on OpenCode 1.x:
76
+
65
77
  ```json
66
78
  {
67
79
  "$schema": "https://opencode.ai/tui.json",
68
80
  "plugin": [
69
- "opencode-workflow-guard@1.7.2"
81
+ "opencode-workflow-guard@1.13.2"
70
82
  ]
71
83
  }
72
84
  ```
73
85
 
74
- The version above is illustrative; replace it with the version you intend to install. The package exposes separate server and TUI entrypoints. In `tui.json`, configure the package spec; OpenCode resolves its exported `./tui` entrypoint automatically. Do not place `workflow-guard-ui.ts` under `plugins/`; the server loader rejects TUI-only modules.
86
+ The version above is illustrative; replace it with the version you intend to install. The package exposes separate server and TUI entrypoints. In the terminal-client config, configure the package spec; OpenCode resolves its exported `./tui` entrypoint automatically. Do not place `workflow-guard-ui.ts` under `plugins/`; the server loader rejects TUI-only modules.
87
+
88
+ On OpenCode 2, `plugin check` and `plugin update` cover both server and TUI-only package plugins.
75
89
 
76
90
  The TUI badge uses OpenCode's Solid slot API (`@opentui/solid`). It is a runtime `dependency` of this package, so npm installs it automatically alongside `opencode-workflow-guard` - no extra install is needed.
77
91
 
@@ -127,7 +141,20 @@ Or set `WORKFLOW_GUARD_REQUIRE_REVIEW=0` in your environment. Conversely, `WORKF
127
141
 
128
142
  ## Recommended Companion Configuration
129
143
 
130
- For defense in depth, pair `opencode-workflow-guard` with OpenCode's native permission rules in `opencode.json` (see [OpenCode Permissions](https://opencode.ai/docs/permissions/)):
144
+ For defense in depth, pair `opencode-workflow-guard` with OpenCode's native permission rules (see [OpenCode Permissions](https://opencode.ai/docs/permissions/)). OpenCode 2 uses one ordered `permissions` array (`bash` actions are named `shell` in V2):
145
+
146
+ ```json
147
+ {
148
+ "$schema": "https://opencode.ai/config.json",
149
+ "permissions": [
150
+ { "action": "shell", "resource": "git push *main*", "effect": "deny" },
151
+ { "action": "shell", "resource": "git push *master*", "effect": "deny" },
152
+ { "action": "shell", "resource": "opencode auth*", "effect": "deny" }
153
+ ]
154
+ }
155
+ ```
156
+
157
+ OpenCode 1.x groups rules by tool under the singular `permission` key:
131
158
 
132
159
  ```json
133
160
  {
package/docs/policies.md CHANGED
@@ -9,7 +9,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
9
9
  ## Policy Reference
10
10
 
11
11
  ### 1. Task Breakdown & Lifecycle (`todowrite`)
12
- - **Pre-edit Gate:** All file-editing tools (`edit`, `write`, `apply_patch`) are blocked until the session has an active task list in OpenCode's native todo system (`todowrite`, persisted at `GET /session/:id/todo`).
12
+ - **Pre-edit Gate:** All file-editing tools (`edit`, `write`, `apply_patch`) are blocked while the session's effective todo list is known and contains no active task. On OpenCode 1.x the list comes from the native todo system (`todowrite`, persisted at `GET /session/:id/todo`). On OpenCode 2.x there is no todo endpoint and no builtin `todowrite`, so the list is reconstructed from the newest applied `todowrite` tool call in the session history (ACP agents may supply that tool). The gate distinguishes a **definitively empty** list (a `todowrite` part exists and reports no active task → still blocked) from an **undeterminable** one (no `todowrite` part exists at all, i.e. no todo capability, or the history cannot be read → the gate fails open). This keeps builtin-only V2 sessions usable instead of deadlocking every edit forever.
13
13
  - **Flexible Task Execution:** Tasks can be completed in any order as work concludes without artificial sequential blockers, maintaining maximum pair-programming DX.
14
14
  - **No Silent Deletion:** Each `todowrite` update replaces the complete list. Active tasks must remain in subsequent updates until they are explicitly marked `completed` or `cancelled`.
15
15
  - **No Silent Early Exit:** When an owning session becomes `session.idle` with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. This enforces the session's existing lifecycle state: Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts and the budget resets on genuine user input. User-initiated interrupts (Esc) surface as `MessageAbortedError` and suspend automatic continuation for that session until genuine user input arrives, mirroring the ralph `user_stopped` semantics. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead; genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required; question and permission interactions wait inside the active run rather than producing an idle completion.
@@ -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
 
@@ -15,8 +15,8 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
15
15
  ### 2. Edits Blocked: `Blocked: no active todo item`
16
16
 
17
17
  - **Symptoms:** The agent attempts an edit (`edit`, `write`, `apply_patch`) and receives a block message directing it to create a task list.
18
- - **Root Cause:** Policy 1 blocks file-editing tools until the session's native todo list (`GET /session/:id/todo`) contains at least one task with status `pending` or `in_progress`.
19
- - **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits will block again until a new breakdown is created for subsequent work.
18
+ - **Root Cause:** Policy 1 blocks file-editing tools while the session's **effective** todo list is known and contains no task with status `pending` or `in_progress`. On OpenCode 1.x that list is the native todo state (`GET /session/:id/todo`). On OpenCode 2.x there is no todo endpoint and no builtin `todowrite`, so the list is reconstructed from the newest applied `todowrite` tool call in the session history (some ACP agents supply that tool). A list is only treated as empty when a `todowrite` part actually exists; when the session has no `todowrite` capability at all, the gate fails open rather than blocking every edit forever.
19
+ - **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits will block again until a new breakdown is created for subsequent work. If an agent on OpenCode 2.x is blocked here despite having no todo tool available, that is a bug - report it rather than working around it through shell writes.
20
20
 
21
21
  ---
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.13.2",
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 = "";
@@ -19,7 +19,8 @@ import { Plugin } from "@opencode/plugin";
19
19
  import { Message } from "@opencode/ai";
20
20
  import { z } from "zod";
21
21
  import { join, resolve } from "node:path";
22
- import type { TodoItem, TodoSdkClient } from "./types.ts";
22
+ import type { TodoSdkClient } from "./types.ts";
23
+ import { scanSessionTodos, toolPartName } from "./v2-todo.ts";
23
24
  import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
24
25
  import {
25
26
  setWorkspaceRoot,
@@ -66,44 +67,9 @@ type V2Context = Plugin.Context;
66
67
 
67
68
  // ── V1 SDK client adapter ─────────────────────────────────────────────────────
68
69
 
69
- /**
70
- * V2 tool parts carry the tool name in `tool` (the V1 field was `name`); ACP
71
- * agents may still report either shape, so accept both.
72
- */
73
- function toolPartName(part: { tool?: unknown; name?: unknown }): string | undefined {
74
- if (typeof part.tool === "string") return part.tool;
75
- if (typeof part.name === "string") return part.name;
76
- return undefined;
77
- }
78
-
79
- /**
80
- * Reconstruct the current todo list for a session from the session message
81
- * history (V2 exposes no dedicated todo endpoint). The newest applied
82
- * `todowrite` tool call's input is the current state.
83
- */
84
- async function fetchTodosFromContext(ctx: V2Context, sessionID: string): Promise<TodoItem[] | undefined> {
85
- try {
86
- const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
87
- for (let i = messages.length - 1; i >= 0; i--) {
88
- const content = (messages[i] as { content?: unknown })?.content;
89
- if (!Array.isArray(content)) continue;
90
- for (let j = content.length - 1; j >= 0; j--) {
91
- const part = content[j] as { type?: string; tool?: unknown; name?: unknown; state?: { status?: string; input?: { todos?: unknown } } };
92
- if (part?.type !== "tool") continue;
93
- const toolName = toolPartName(part);
94
- if (toolName === undefined) continue;
95
- if (!/^todowrite$/i.test(toolName)) continue;
96
- const status = part.state?.status;
97
- if (status === "pending" || status === "streaming" || status === "running") continue;
98
- const todos = part.state?.input?.todos;
99
- if (Array.isArray(todos)) return todos as TodoItem[];
100
- }
101
- }
102
- return [];
103
- } catch {
104
- return undefined;
105
- }
106
- }
70
+ // V2 has no todo endpoint; `scanSessionTodos` reconstructs one session's list
71
+ // from message history and distinguishes "empty" from "unknown" (no todo
72
+ // capability) so builtin-only sessions are not gated forever. See v2-todo.ts.
107
73
 
108
74
  function createV2SdkClient(ctx: V2Context): TodoSdkClient {
109
75
  // V2 has no server-side log API or TUI surface, so app.log degrades to
@@ -119,7 +85,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
119
85
  },
120
86
  },
121
87
  session: {
122
- todo: async ({ path }) => ({ data: await fetchTodosFromContext(ctx, path.id) }),
88
+ todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id) }),
123
89
  get: async ({ path }) => {
124
90
  const session = await ctx.session.get({ sessionID: path.id });
125
91
  return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * V2 todo reconstruction shared by the plugin entrypoint and its V1 SDK
3
+ * adapter.
4
+ *
5
+ * OpenCode V2 exposes no todo endpoint, and its builtin tool registry has no
6
+ * `todowrite` (the V1 name). Todo state therefore only exists when an ACP agent
7
+ * supplies its own `todowrite` tool. This module reads that state from session
8
+ * message history and distinguishes "definitively empty" from "cannot
9
+ * determine", which the policy layer relies on to decide whether to enforce the
10
+ * active-todo gate.
11
+ */
12
+
13
+ import type { TodoItem } from "./types.ts";
14
+
15
+ /** Minimal session-context surface needed to read message history. */
16
+ export interface TodoScanContext {
17
+ session: {
18
+ context: (input: { sessionID: string }) => Promise<unknown>;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * V2 tool parts carry the tool name in `tool` (the V1 field was `name`); ACP
24
+ * agents may still report either shape, so accept both.
25
+ */
26
+ export function toolPartName(part: { tool?: unknown; name?: unknown }): string | undefined {
27
+ if (typeof part.tool === "string") return part.tool;
28
+ if (typeof part.name === "string") return part.name;
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * Reconstruct one session's current todo list from its message history. The
34
+ * newest applied `todowrite` tool call's input is the current state.
35
+ *
36
+ * Returns:
37
+ * - `TodoItem[]` when a `todowrite` part defines the list, including `[]` when
38
+ * the newest applied call emptied it;
39
+ * - `undefined` when todo state is unknown: the history cannot be read, or the
40
+ * session contains no `todowrite` part at all (no todo capability).
41
+ *
42
+ * Callers must treat `undefined` as "cannot determine" (fail open) and `[]` as
43
+ * "definitively no active todos" (enforce). Collapsing the two is what
44
+ * deadlocks builtin-only V2 sessions, which can never create a todo.
45
+ */
46
+ export async function scanSessionTodos(
47
+ ctx: TodoScanContext,
48
+ sessionID: string,
49
+ ): Promise<TodoItem[] | undefined> {
50
+ try {
51
+ const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
52
+ if (!Array.isArray(messages)) return undefined;
53
+ let sawTodoWrite = false;
54
+ for (let i = messages.length - 1; i >= 0; i--) {
55
+ const content = (messages[i] as { content?: unknown })?.content;
56
+ if (!Array.isArray(content)) continue;
57
+ for (let j = content.length - 1; j >= 0; j--) {
58
+ const part = content[j] as { type?: string; tool?: unknown; name?: unknown; state?: { status?: string; input?: { todos?: unknown } } };
59
+ if (part?.type !== "tool") continue;
60
+ const toolName = toolPartName(part);
61
+ if (toolName === undefined || !/^todowrite$/i.test(toolName)) continue;
62
+ sawTodoWrite = true;
63
+ const status = part.state?.status;
64
+ if (status === "pending" || status === "streaming" || status === "running") continue;
65
+ const todos = part.state?.input?.todos;
66
+ if (Array.isArray(todos)) return todos as TodoItem[];
67
+ }
68
+ }
69
+ // A `todowrite` part exists but none is applied yet (e.g. still
70
+ // streaming): the list is in flux, report it as empty rather than
71
+ // unknown so the gate stays closed until the write settles.
72
+ return sawTodoWrite ? [] : undefined;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
@@ -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 {
@@ -62,13 +62,23 @@ export async function effectiveTodosWithOwner(
62
62
  if (!sessionID) return undefined;
63
63
  const seen = new Set<string>();
64
64
  let current: string | undefined = sessionID;
65
+ let sawUnknown = false;
65
66
  while (current && !seen.has(current)) {
66
67
  seen.add(current);
67
68
  const todos = await fetchSessionTodos(current);
68
- if (todos === undefined) return undefined;
69
- if (todos.length > 0) return { todos, ownerSessionID: current };
69
+ if (todos === undefined) {
70
+ // This link has no determinable list (no todo capability, read
71
+ // error, or no endpoint). Do not treat it as empty and do not stop
72
+ // the walk: a parent may still own an applicable list, and keeping
73
+ // the walk going preserves evidence attribution to that owner. An
74
+ // unknown link only fails open if no ancestor provides a list.
75
+ sawUnknown = true;
76
+ } else if (todos.length > 0) {
77
+ return { todos, ownerSessionID: current };
78
+ }
70
79
  current = await fetchParentSessionID(current);
71
80
  }
81
+ if (sawUnknown) return undefined;
72
82
  return { todos: [] };
73
83
  }
74
84
 
package/test/test.mts CHANGED
@@ -115,6 +115,8 @@ 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
117
  import { WorkflowGuardTui, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
118
+ import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
119
+ import { effectiveTodosWithOwner } from "../src/policies/todo.ts";
118
120
 
119
121
  let pass = 0;
120
122
  let fail = 0;
@@ -244,6 +246,95 @@ check("missing client fails open", !(await call("edit", { filePath: join(root, "
244
246
  check("missing sessionID fails open", !(await call("edit", { filePath: join(root, "a.ts"), content: "x" })));
245
247
  setSdkClient(fakeClient);
246
248
 
249
+ console.log("- Policy 1: V2 todo reconstruction (no todo capability vs empty list) -");
250
+ const todoPart = (todos: unknown, status = "completed") => ({
251
+ content: [{ type: "tool", tool: "todowrite", state: { status, input: { todos } } }],
252
+ });
253
+ const ctxWith = (messages: unknown) => ({ session: { context: async () => messages } });
254
+
255
+ check(
256
+ "V2: history with no todowrite part reports unknown (no todo capability)",
257
+ (await scanSessionTodos(ctxWith([{ content: [{ type: "tool", tool: "edit", state: { status: "completed" } }] }]), "s-v2")) === undefined,
258
+ );
259
+ const scanned = await scanSessionTodos(ctxWith([todoPart([item("a", "pending")])]), "s-v2");
260
+ check("V2: completed todowrite defines the list", Array.isArray(scanned) && scanned.length === 1 && scanned[0]!.content === "a");
261
+ const newest = await scanSessionTodos(ctxWith([todoPart([item("old")]), todoPart([item("new", "in_progress")])]), "s-v2");
262
+ check("V2: newest applied todowrite wins", Array.isArray(newest) && newest[0]!.content === "new");
263
+ const emptied = await scanSessionTodos(ctxWith([todoPart([])]), "s-v2");
264
+ check("V2: emptied todowrite reports a definitive empty list", Array.isArray(emptied) && emptied.length === 0);
265
+ check("V2: read rejection reports unknown", (await scanSessionTodos({ session: { context: async () => { throw new Error("boom"); } } }, "s-v2")) === undefined);
266
+ const legacy = await scanSessionTodos(ctxWith([{ content: [{ type: "tool", name: "todowrite", state: { status: "completed", input: { todos: [item("legacy")] } } }] }]), "s-v2");
267
+ check("V2: legacy name field is still accepted", Array.isArray(legacy) && legacy[0]!.content === "legacy");
268
+ const inFlight = await scanSessionTodos(ctxWith([todoPart([item("x")], "pending")]), "s-v2");
269
+ check("V2: in-flight todowrite reports an empty list, not unknown", Array.isArray(inFlight) && inFlight.length === 0);
270
+ check("V2: toolPartName prefers tool and falls back to name", toolPartName({ tool: "shell" }) === "shell" && toolPartName({ name: "bash" }) === "bash" && toolPartName({}) === undefined);
271
+
272
+ console.log("- Policy 1: parent-chain walk tolerates unknown links (V2) -");
273
+ const walkTodos = new Map<string, TestTodo[] | undefined>();
274
+ const walkParents = new Map<string, string | undefined>();
275
+ setSdkClient({
276
+ session: {
277
+ todo: async ({ path }: { path: { id: string } }) => ({ data: walkTodos.get(path.id) }),
278
+ get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: walkParents.get(path.id) } }),
279
+ },
280
+ });
281
+ walkTodos.set("v2-child-inherit", undefined);
282
+ walkTodos.set("v2-parent-owner", [item("parent-task", "pending")]);
283
+ walkParents.set("v2-child-inherit", "v2-parent-owner");
284
+ const inherited = await effectiveTodosWithOwner("v2-child-inherit");
285
+ check(
286
+ "V2: unknown child inherits a parent-owned list (owner attribution preserved)",
287
+ inherited?.ownerSessionID === "v2-parent-owner" && inherited?.todos[0]?.content === "parent-task",
288
+ );
289
+ walkTodos.set("v2-child-unknown", undefined);
290
+ walkParents.set("v2-child-unknown", undefined);
291
+ check("V2: unknown-only chain fails open", (await effectiveTodosWithOwner("v2-child-unknown")) === undefined);
292
+ walkTodos.set("v2-child-empty", []);
293
+ walkTodos.set("v2-parent-unknown", undefined);
294
+ walkParents.set("v2-child-empty", "v2-parent-unknown");
295
+ check("V2: empty child with unknown ancestor fails open", (await effectiveTodosWithOwner("v2-child-empty")) === undefined);
296
+ walkTodos.set("v2-cycle-a", undefined);
297
+ walkTodos.set("v2-cycle-b", undefined);
298
+ walkParents.set("v2-cycle-a", "v2-cycle-b");
299
+ walkParents.set("v2-cycle-b", "v2-cycle-a");
300
+ check("V2: cycle of unknown links terminates and fails open", (await effectiveTodosWithOwner("v2-cycle-a")) === undefined);
301
+ walkTodos.set("v2-child-all-empty", []);
302
+ walkTodos.set("v2-parent-all-empty", []);
303
+ walkParents.set("v2-child-all-empty", "v2-parent-all-empty");
304
+ walkParents.set("v2-parent-all-empty", undefined);
305
+ const allEmpty = await effectiveTodosWithOwner("v2-child-all-empty");
306
+ check("V2: all-empty chain stays definitively empty (enforce)", Array.isArray(allEmpty?.todos) && allEmpty!.todos.length === 0);
307
+ // Seam: the V2 adapter delegates to scanSessionTodos, so drive that exact path
308
+ // into the edit gate. A builtin-only session (history with no todowrite part)
309
+ // must allow edits; a session whose newest todowrite emptied the list is still
310
+ // gated.
311
+ const scanCtx = (messages: unknown) => ({ session: { context: async () => messages } });
312
+ setSdkClient({
313
+ session: {
314
+ todo: async ({ path }: { path: { id: string } }) => ({
315
+ data: await scanSessionTodos(scanCtx([{ content: [{ type: "tool", tool: "edit", state: { status: "completed" } }] }]), path.id),
316
+ }),
317
+ get: async () => ({ data: {} }),
318
+ },
319
+ });
320
+ check(
321
+ "V2 builtin-only session (scanner reports unknown) allows edits",
322
+ !(await call("edit", { filePath: join(root, "a.ts"), content: "x" }, { sessionID: "s-v2-builtin" })),
323
+ );
324
+ setSdkClient({
325
+ session: {
326
+ todo: async ({ path }: { path: { id: string } }) => ({
327
+ data: await scanSessionTodos(scanCtx([todoPart([])]), path.id),
328
+ }),
329
+ get: async () => ({ data: {} }),
330
+ },
331
+ });
332
+ check(
333
+ "V2 session whose todowrite emptied the list is still gated",
334
+ blocked(await call("edit", { filePath: join(root, "a.ts"), content: "x" }, { sessionID: "s-v2-emptied" })),
335
+ );
336
+ setSdkClient(fakeClient);
337
+
247
338
  console.log("- Policy 2: push to main/master -");
248
339
  check("block git push origin main", blocked(await shell("git push origin main")));
249
340
  check("block git push origin master", blocked(await shell("git push origin master")));
@@ -448,6 +539,11 @@ check("allow gh issue create mentioning .opencode paths (not tamper)", !(await s
448
539
  check("compound collaboration + tamper still blocked", blocked(await shell(`gh issue create --repo example/proj --title t && echo x > open${"code.json"}`)));
449
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`)));
450
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'`)));
451
547
 
452
548
  console.log("- Policy 6: tamper via edit tools (path protection) -");
453
549
  check("block edit of project opencode.json", blocked(await call("edit", { filePath: join(root, "opencode.json"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));