opencode-workflow-guard 1.13.1 → 1.13.2

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
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,6 +66,16 @@ type V2Context = Plugin.Context;
66
66
 
67
67
  // ── V1 SDK client adapter ─────────────────────────────────────────────────────
68
68
 
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
+
69
79
  /**
70
80
  * Reconstruct the current todo list for a session from the session message
71
81
  * history (V2 exposes no dedicated todo endpoint). The newest applied
@@ -78,9 +88,11 @@ async function fetchTodosFromContext(ctx: V2Context, sessionID: string): Promise
78
88
  const content = (messages[i] as { content?: unknown })?.content;
79
89
  if (!Array.isArray(content)) continue;
80
90
  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;
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;
84
96
  const status = part.state?.status;
85
97
  if (status === "pending" || status === "streaming" || status === "running") continue;
86
98
  const todos = part.state?.input?.todos;
@@ -137,6 +149,18 @@ const EDIT_SUFFIX =
137
149
  const SUBAGENT_SUFFIX =
138
150
  "\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
151
 
152
+ // Builtin tool description enrichment. Candidates are matched against the
153
+ // host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
154
+ // candidates are harmless no-ops.
155
+ //
156
+ // V2.0.10 builtin surface (docs + binary): read, glob, grep, edit, write,
157
+ // patch, shell, webfetch, websearch, question, skill, subagent, execute.
158
+ // - There is no builtin `todowrite` (the V1 name). Todowrite tool parts seen
159
+ // in V2 sessions come from ACP agents that supply their own tools; those
160
+ // are not in the server registry, so this enrichment cannot reach them.
161
+ // The candidate is kept for hosts that do register a todowrite tool.
162
+ // - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
163
+ // candidates for the same reason.
140
164
  const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
141
165
  { candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: "verification evidence" },
142
166
  { candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: "Workflow Guard requirement" },
@@ -514,7 +538,7 @@ async function handleV2Event(
514
538
  type: "tool",
515
539
  sessionID,
516
540
  callID: part.id,
517
- tool: part.name,
541
+ tool: toolPartName(part),
518
542
  state: {
519
543
  status,
520
544
  error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
@@ -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
- const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/g;
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
- if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
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,
@@ -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
- commands: [{
110
- id: "workflow-guard.project-options",
111
- title: "Workflow Guard: Project Options",
112
- description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
113
- group: "Workflow Guard",
114
- palette: true,
115
- slash: { name: "guard-options" },
116
- async run() {
117
- const root = optionsRoot();
118
- for (;;) {
119
- const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
120
- const choice = await ctx.ui.dialog.select<ProjectToggle>({
121
- title: "Workflow Guard Project Options",
122
- options: TOGGLE_OPTIONS.map((option) => ({
123
- title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
124
- value: option.key,
125
- description: option.description,
126
- })),
127
- });
128
- if (!choice) break;
129
- try {
130
- const enabled = !readProjectOption(root, choice);
131
- const path = writeProjectOption(root, choice, enabled);
132
- ctx.ui.toast.show({
133
- variant: "success",
134
- title: "Workflow Guard",
135
- message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
136
- });
137
- } catch (error) {
138
- ctx.ui.toast.show({
139
- variant: "error",
140
- title: "Workflow Guard",
141
- message: error instanceof Error ? error.message : String(error),
142
- });
143
- break;
144
- }
145
- }
146
- },
147
- }],
148
- bindings: [],
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
@@ -537,6 +537,10 @@ check("inspection command with mutation keywords in grep argument is not blocked
537
537
  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
538
  check("grep searching for mutation command name is not blocked", !(await call("bash", { command: 'grep -rn "mkdir" src/' }, { sessionID: "s-empty" })));
539
539
  check("stderr redirect to /dev/null is not a file mutation", !(await call("bash", { command: "ls missing 2>/dev/null" }, { sessionID: "s-empty" })));
540
+ 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" })));
541
+ 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" })));
542
+ 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" })));
543
+ check("real redirect alongside numeric comparison still blocked", blocked(await call("bash", { command: 'echo "x > 5" > src/a.ts' }, { sessionID: "s-empty" })));
540
544
  check("touch outside workspace is blocked", blocked(await call("bash", { command: "touch /tmp/wg-outside-touch" }, { sessionID: "s-active" })));
541
545
  check("mkdir outside workspace is blocked", blocked(await call("bash", { command: "mkdir /tmp/wg-outside-dir" }, { sessionID: "s-active" })));
542
546
  check("single-file rm outside workspace is blocked", blocked(await call("bash", { command: "rm /tmp/wg-outside-file" }, { sessionID: "s-active" })));