opencode-workflow-guard 1.13.1 → 1.13.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/docs/installation.md +33 -6
- package/docs/policies.md +2 -1
- package/docs/troubleshooting.md +20 -2
- package/package.json +1 -1
- package/src/lib/v2-plugin.ts +19 -29
- package/src/lib/v2-todo.ts +76 -0
- package/src/policies/boundary.ts +10 -2
- package/src/policies/todo.ts +12 -2
- package/src/workflow-guard-ui.ts +58 -42
- package/test/test.mts +95 -0
package/docs/installation.md
CHANGED
|
@@ -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
|
-
"
|
|
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"
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -72,6 +72,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
72
72
|
### 8. Workspace Boundary Guard
|
|
73
73
|
- File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations (redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`/`git am`) are validated to ensure detected targets cannot escape the current workspace root via `../` traversal, symlinks, absolute paths, or shell expansions (`~`, `~user`, `$HOME`). Unresolvable `$VARIABLE` references fail closed. This is command-level enforcement, not filesystem sandboxing; arbitrary programs and dynamically computed paths require the OS isolation described under Known Limits.
|
|
74
74
|
- `tee` flag parsing generically skips option flags (`-a`, `--append`, `-ai`, `--`) and validates all specified target files against the boundary.
|
|
75
|
+
- Embedded non-shell comparisons are not mutations: a bare `>` whose target is purely numeric (`WHERE count > 5`, awk `$3 > 100`) and `>=`/`==` operators are treated as comparison operands, not redirects. Real redirects are unaffected (`> src/a.ts`, `>>`, `&>`, `>&`, fd targets like `/dev/null` still follow their existing rules).
|
|
75
76
|
- `mv` sources are validated too: moving a file from outside the workspace (or a protected settings/plugin file to an innocuous name) is blocked, since `mv` mutates the source.
|
|
76
77
|
- External repository git writes (`git -C /other-repo commit`, `git --git-dir=/other-repo/.git push`, `GIT_DIR=/other-repo/.git git push`, `GIT_WORK_TREE=...`) are also confined to the workspace.
|
|
77
78
|
- The boundary has **no override**: `WORKFLOW_GUARD_ALLOW_LIVE=1` covers live-system commands only and never weakens the workspace confinement.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -82,3 +82,21 @@ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
|
|
|
82
82
|
OpenCode documents `opencode plugin <module>` as installing a plugin and updating its config, with `--global` for global config and `--force` to replace an existing plugin version: https://opencode.ai/docs/cli/#plugin. Its plugin documentation also describes npm plugins as OpenCode-managed, cached installations: https://opencode.ai/docs/plugins/#how-plugins-are-installed.
|
|
83
83
|
|
|
84
84
|
After the command reports `Detected server + tui targets`, confirm it reports replacements/additions for both the OpenCode and TUI config files, then restart OpenCode. Manual cache deletion should not be necessary.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### 10. TUI companion crashes with `Keymap.Provider is missing` (OpenCode V2)
|
|
89
|
+
|
|
90
|
+
- **Symptoms:** On opencode v2, the TUI companion plugin fails during setup with `Keymap.Provider is missing` and does not load.
|
|
91
|
+
- **Root Cause:** `ctx.keymap.layer` resolves a Solid context (`useContext(KeymapContext)`) and only works inside the TUI's `Keymap.Provider` render tree; the API documents layers as "owned by the calling component". Plugin `setup()` runs as a plain async call outside that tree, so registering a layer there always throws.
|
|
92
|
+
- **Solution:** Fixed in 1.13.2: the layer is registered from an `app`-slot claim (`ctx.ui.slot({ append: "app", render: () => { ...; return null } })`). Slot renders execute in a reactive scope under the provider, and the same session-panel pattern applies to any plugin API that requires component ownership. If you maintain a TUI plugin that registers keymap layers, commands, or reactive state, register them from a slot render (or a rendered component), never from `setup()`.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### 11. Reading tool parts from V2 sessions (integrators)
|
|
97
|
+
|
|
98
|
+
- **Symptoms:** Code that parses recorded tool calls (message parts, the `part` DB table, or `session.message.content.updated` events) finds no tool name on OpenCode V2, or sees tool names that do not exist in OpenCode's builtin tool list.
|
|
99
|
+
- **Root Cause:** Two independent things:
|
|
100
|
+
1. V2 message parts record the tool name in the `tool` field; V1 used `name`. Code reading `part.name` silently gets `undefined` on V2.
|
|
101
|
+
2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite` (the V1 name), and the V1 `task` subagent tool was renamed to `subagent`. `todowrite`/`bash` tool parts observed in V2 sessions come from ACP agents (for example pi) that supply their own tools; those tools are not in the server-side registry and cannot be reached by `ctx.tool.transform`.
|
|
102
|
+
- **Solution:** Read the tool name as `part.tool ?? part.name` and treat tool-part names as agent-reported, not as the builtin registry. `ToolEditor.update` ignores missing IDs, so enriching a tool that does not exist is a harmless no-op.
|
package/package.json
CHANGED
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -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 {
|
|
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,32 +67,9 @@ type V2Context = Plugin.Context;
|
|
|
66
67
|
|
|
67
68
|
// ── V1 SDK client adapter ─────────────────────────────────────────────────────
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
* `todowrite` tool call's input is the current state.
|
|
73
|
-
*/
|
|
74
|
-
async function fetchTodosFromContext(ctx: V2Context, sessionID: string): Promise<TodoItem[] | undefined> {
|
|
75
|
-
try {
|
|
76
|
-
const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
|
|
77
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
78
|
-
const content = (messages[i] as { content?: unknown })?.content;
|
|
79
|
-
if (!Array.isArray(content)) continue;
|
|
80
|
-
for (let j = content.length - 1; j >= 0; j--) {
|
|
81
|
-
const part = content[j] as { type?: string; name?: string; state?: { status?: string; input?: { todos?: unknown } } };
|
|
82
|
-
if (part?.type !== "tool" || typeof part.name !== "string") continue;
|
|
83
|
-
if (!/^todowrite$/i.test(part.name)) continue;
|
|
84
|
-
const status = part.state?.status;
|
|
85
|
-
if (status === "pending" || status === "streaming" || status === "running") continue;
|
|
86
|
-
const todos = part.state?.input?.todos;
|
|
87
|
-
if (Array.isArray(todos)) return todos as TodoItem[];
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return [];
|
|
91
|
-
} catch {
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
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.
|
|
95
73
|
|
|
96
74
|
function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
97
75
|
// V2 has no server-side log API or TUI surface, so app.log degrades to
|
|
@@ -107,7 +85,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
107
85
|
},
|
|
108
86
|
},
|
|
109
87
|
session: {
|
|
110
|
-
todo: async ({ path }) => ({ data: await
|
|
88
|
+
todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id) }),
|
|
111
89
|
get: async ({ path }) => {
|
|
112
90
|
const session = await ctx.session.get({ sessionID: path.id });
|
|
113
91
|
return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
|
|
@@ -137,6 +115,18 @@ const EDIT_SUFFIX =
|
|
|
137
115
|
const SUBAGENT_SUFFIX =
|
|
138
116
|
"\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
|
|
139
117
|
|
|
118
|
+
// Builtin tool description enrichment. Candidates are matched against the
|
|
119
|
+
// host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
|
|
120
|
+
// candidates are harmless no-ops.
|
|
121
|
+
//
|
|
122
|
+
// V2.0.10 builtin surface (docs + binary): read, glob, grep, edit, write,
|
|
123
|
+
// patch, shell, webfetch, websearch, question, skill, subagent, execute.
|
|
124
|
+
// - There is no builtin `todowrite` (the V1 name). Todowrite tool parts seen
|
|
125
|
+
// in V2 sessions come from ACP agents that supply their own tools; those
|
|
126
|
+
// are not in the server registry, so this enrichment cannot reach them.
|
|
127
|
+
// The candidate is kept for hosts that do register a todowrite tool.
|
|
128
|
+
// - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
|
|
129
|
+
// candidates for the same reason.
|
|
140
130
|
const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
|
|
141
131
|
{ candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: "verification evidence" },
|
|
142
132
|
{ candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: "Workflow Guard requirement" },
|
|
@@ -514,7 +504,7 @@ async function handleV2Event(
|
|
|
514
504
|
type: "tool",
|
|
515
505
|
sessionID,
|
|
516
506
|
callID: part.id,
|
|
517
|
-
tool: part
|
|
507
|
+
tool: toolPartName(part),
|
|
518
508
|
state: {
|
|
519
509
|
status,
|
|
520
510
|
error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
|
|
@@ -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
|
+
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -129,14 +129,22 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
129
129
|
|
|
130
130
|
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
131
|
const mutations: ShellMutation[] = [];
|
|
132
|
-
|
|
132
|
+
// The `(?!=)` lookahead after the op rejects comparison operators (`>=`,
|
|
133
|
+
// `==`) so they cannot match as a redirect op with `=` as its target.
|
|
134
|
+
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?(?!=))\s*["']?([^\s>&|;"']+)/g;
|
|
133
135
|
for (const redirectMatch of segment.matchAll(redirectRe)) {
|
|
134
136
|
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
135
137
|
const op = redirectMatch[1];
|
|
136
138
|
const target = redirectMatch[2];
|
|
137
139
|
// Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
|
|
138
140
|
const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
|
|
139
|
-
|
|
141
|
+
// Filter comparison operands from embedded non-shell syntax (SQL, awk,
|
|
142
|
+
// test expressions): `WHERE count > 5`, `x >= 10`. A bare `>` whose
|
|
143
|
+
// target is purely numeric is overwhelmingly a comparison operand, not
|
|
144
|
+
// a redirect into a numeric filename. `>>` and fd forms (`2>`) keep
|
|
145
|
+
// redirect semantics.
|
|
146
|
+
const isComparisonOperand = op === ">" && /^\d+$/.test(target);
|
|
147
|
+
if (!isFdDup && !isComparisonOperand && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
|
|
140
148
|
mutations.push({
|
|
141
149
|
kind: "redirect",
|
|
142
150
|
target,
|
package/src/policies/todo.ts
CHANGED
|
@@ -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)
|
|
69
|
-
|
|
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/src/workflow-guard-ui.ts
CHANGED
|
@@ -105,48 +105,64 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
|
105
105
|
|
|
106
106
|
const optionsRoot = () => ctx.location?.directory || process.cwd();
|
|
107
107
|
|
|
108
|
-
ctx.keymap.layer
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
108
|
+
// `ctx.keymap.layer` creates a layer owned by the calling component and only
|
|
109
|
+
// resolves inside the TUI's provider render tree; plugin setup() runs as a
|
|
110
|
+
// plain async call outside it. Slot renders execute in a reactive scope under
|
|
111
|
+
// the provider, so the layer is registered from an app-slot claim instead.
|
|
112
|
+
// The guard keeps repeated renders from stacking duplicate layers; layers and
|
|
113
|
+
// slot claims are disposed automatically on unload.
|
|
114
|
+
let keymapLayerRegistered = false;
|
|
115
|
+
ctx.ui.slot({
|
|
116
|
+
append: "app",
|
|
117
|
+
render: () => {
|
|
118
|
+
if (!keymapLayerRegistered) {
|
|
119
|
+
keymapLayerRegistered = true;
|
|
120
|
+
ctx.keymap.layer(() => ({
|
|
121
|
+
commands: [{
|
|
122
|
+
id: "workflow-guard.project-options",
|
|
123
|
+
title: "Workflow Guard: Project Options",
|
|
124
|
+
description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
|
|
125
|
+
group: "Workflow Guard",
|
|
126
|
+
palette: true,
|
|
127
|
+
slash: { name: "guard-options" },
|
|
128
|
+
async run() {
|
|
129
|
+
const root = optionsRoot();
|
|
130
|
+
for (;;) {
|
|
131
|
+
const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
|
|
132
|
+
const choice = await ctx.ui.dialog.select<ProjectToggle>({
|
|
133
|
+
title: "Workflow Guard Project Options",
|
|
134
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
135
|
+
title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
|
|
136
|
+
value: option.key,
|
|
137
|
+
description: option.description,
|
|
138
|
+
})),
|
|
139
|
+
});
|
|
140
|
+
if (!choice) break;
|
|
141
|
+
try {
|
|
142
|
+
const enabled = !readProjectOption(root, choice);
|
|
143
|
+
const path = writeProjectOption(root, choice, enabled);
|
|
144
|
+
ctx.ui.toast.show({
|
|
145
|
+
variant: "success",
|
|
146
|
+
title: "Workflow Guard",
|
|
147
|
+
message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
ctx.ui.toast.show({
|
|
151
|
+
variant: "error",
|
|
152
|
+
title: "Workflow Guard",
|
|
153
|
+
message: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}],
|
|
160
|
+
bindings: [],
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
150
166
|
|
|
151
167
|
ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
|
|
152
168
|
ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
|
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")));
|
|
@@ -537,6 +628,10 @@ check("inspection command with mutation keywords in grep argument is not blocked
|
|
|
537
628
|
check("git log with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'git log --grep="rm old files"' }, { sessionID: "s-empty" })));
|
|
538
629
|
check("grep searching for mutation command name is not blocked", !(await call("bash", { command: 'grep -rn "mkdir" src/' }, { sessionID: "s-empty" })));
|
|
539
630
|
check("stderr redirect to /dev/null is not a file mutation", !(await call("bash", { command: "ls missing 2>/dev/null" }, { sessionID: "s-empty" })));
|
|
631
|
+
check("SQL > comparison in query is not a file mutation", !(await call("bash", { command: 'sqlite3 app.db "SELECT id FROM events WHERE count > 5"' }, { sessionID: "s-empty" })));
|
|
632
|
+
check("SQL >= comparison in query is not a file mutation", !(await call("bash", { command: 'psql -c "SELECT 1 FROM metrics WHERE n >= 10"' }, { sessionID: "s-empty" })));
|
|
633
|
+
check("numeric comparison in grep pattern is not a file mutation", !(await call("bash", { command: "grep -E 'latency > 200' src/log.ts" }, { sessionID: "s-empty" })));
|
|
634
|
+
check("real redirect alongside numeric comparison still blocked", blocked(await call("bash", { command: 'echo "x > 5" > src/a.ts' }, { sessionID: "s-empty" })));
|
|
540
635
|
check("touch outside workspace is blocked", blocked(await call("bash", { command: "touch /tmp/wg-outside-touch" }, { sessionID: "s-active" })));
|
|
541
636
|
check("mkdir outside workspace is blocked", blocked(await call("bash", { command: "mkdir /tmp/wg-outside-dir" }, { sessionID: "s-active" })));
|
|
542
637
|
check("single-file rm outside workspace is blocked", blocked(await call("bash", { command: "rm /tmp/wg-outside-file" }, { sessionID: "s-active" })));
|