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 +1 -0
- package/docs/troubleshooting.md +18 -0
- package/package.json +1 -1
- package/src/lib/v2-plugin.ts +28 -4
- package/src/policies/boundary.ts +10 -2
- package/src/workflow-guard-ui.ts +58 -42
- package/test/test.mts +4 -0
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
@@ -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?:
|
|
82
|
-
if (part?.type !== "tool"
|
|
83
|
-
|
|
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
|
|
541
|
+
tool: toolPartName(part),
|
|
518
542
|
state: {
|
|
519
543
|
status,
|
|
520
544
|
error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
|
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/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
|
@@ -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" })));
|