opencode-workflow-guard 1.7.2 → 1.8.1

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/README.md CHANGED
@@ -1,10 +1,12 @@
1
- # opencode-workflow-guard
1
+ # OpenCode Workflow Guard
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/opencode-workflow-guard.svg)](https://www.npmjs.com/package/opencode-workflow-guard)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Tests](https://img.shields.io/badge/tests-415%2B%20passing-brightgreen.svg)](test/test.mts)
6
6
 
7
- OpenCode plugin enforcing workflow discipline, agent focus, and deterministic safety boundaries through **hard plugin hooks**, not prompt instructions that LLMs can ignore.
7
+ Deterministic policy and enforcement layer for OpenCode. Workflow Guard enforces workflow and safety invariants through **hard plugin hooks**, not prompt instructions that LLMs can ignore.
8
+
9
+ Workflow Guard is deliberately **not an agent harness**: it can constrain actions, record and explain decisions, and supply bounded context, but it does not plan, prioritize, delegate, or autonomously sequence the agent's work. Bounded continuation may resume an existing session with unfinished owned todos; it never chooses the next task or creates new work.
8
10
 
9
11
  ---
10
12
 
@@ -12,26 +14,18 @@ OpenCode plugin enforcing workflow discipline, agent focus, and deterministic sa
12
14
 
13
15
  ### 1. Installation
14
16
 
15
- Run the setup command to register both the server guard and TUI companion globally:
17
+ Install the current published version globally with OpenCode's plugin installer:
16
18
 
17
19
  ```bash
18
- npx opencode-workflow-guard setup
20
+ VERSION=$(npm view opencode-workflow-guard version)
21
+ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
19
22
  ```
20
23
 
21
- For server-only setup, add the package to your project's `opencode.json` or global `~/.config/opencode/opencode.json`:
22
-
23
- ```json
24
- {
25
- "$schema": "https://opencode.ai/config.json",
26
- "plugin": [
27
- "opencode-workflow-guard"
28
- ]
29
- }
30
- ```
24
+ *Requires OpenCode >= 1.18.* OpenCode detects the package's server and TUI targets and updates both global configs. Use the same command after a release to upgrade; the explicit version gives OpenCode a fresh package-cache key. Restart OpenCode after installation. See [docs/installation.md](docs/installation.md) for details and links to the OpenCode plugin documentation.
31
25
 
32
- *Requires OpenCode >= 1.18.* Restart OpenCode after setup. See [docs/installation.md](docs/installation.md) for full setup options.
26
+ For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion shows a `Workflow Guard` shield in the prompt bar and reflects blocked state without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
33
27
 
34
- For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. Do not place the TUI module under the server `plugins/` directory.
28
+ ![Workflow Guard status badge in the OpenCode TUI](docs/assets/workflow-guard-tui-badge.png)
35
29
 
36
30
  ### 2. Verification
37
31
 
@@ -71,70 +65,6 @@ For complete policy specifications and override rules, see [docs/policies.md](do
71
65
 
72
66
  ---
73
67
 
74
- ## Custom Tools Registered in OpenCode
75
-
76
- | Tool | Purpose |
77
- |---|---|
78
- | `guard_next_tasks` | Load `TODO.md`, or fall back to repository roadmap/plan/task Markdown when deciding what to work on next |
79
- | `guard_status` | Inspect active guardrails, current branch protection, and verification/review status |
80
- | `guard_why` | Simulate and explain whether a specific tool call or command would be blocked |
81
- | `guard_audit` | View recent audit log entries recorded in `workflow-guard.jsonl` |
82
- | `guard_review_rubric` | Generate the 5-axis secondary review rubric for the current branch diff |
83
- | `record_review` | Record a secondary reviewer subagent's verdict evaluated against the review rubric |
84
- | `guard_review_followups` | List durable local P2/P3 review findings that remain open across sessions |
85
- | `guard_review_followup_resolve` | Resolve a durable review finding after its underlying issue is fixed and verified |
86
- | `guard_worktree_create` | Create an isolated git worktree directory for concurrent subagent execution |
87
- | `guard_worktree_cleanup` | Snapshot-commit remaining changes and remove an isolated worktree directory |
88
- | `learning_profile` | Inspect the evidence-based local learner profile (when learning mode is enabled) |
89
- | `learning_checkpoint` | Rank high-value Socratic opportunities while respecting the session intervention budget |
90
- | `learning_record` | Persist concise evidence observed during a real learning interaction |
91
- | `project_memory_search` | Search current durable project knowledge in the local index |
92
- | `project_memory_record` | Record a durable fact, decision, constraint, or lesson with provenance |
93
- | `project_memory_export` | Explicitly promote selected records to repo-local human-readable JSONL |
94
- | `project_memory_import` | Import promoted repo-local knowledge into the local index |
95
-
96
- ### Experimental Socratic Learning
97
-
98
- Learning mode is opt-in and advisory: it never blocks tool calls or weakens deterministic guardrails. Enable it with `learning: true` in `.opencode/workflow-guard.json[c]` or through `/guard-options`; `WORKFLOW_GUARD_LEARNING=1` in the user environment also enables it regardless of project configuration. `maxLearningInterventions` defaults to 3 per session and can be set to `0` to suppress checkpoints.
99
-
100
- The learner profile is global to the local user and follows the XDG data convention: `$XDG_DATA_HOME/opencode/workflow-guard/learner-profile.json`, or `~/.local/share/opencode/workflow-guard/learner-profile.json` when `XDG_DATA_HOME` is unset. It stores distilled concept evidence, session/project provenance, and progression (`exposed` through `critique`), not conversation transcripts or numeric grades. Unobserved concepts remain unknown rather than being labeled knowledge gaps. Back up or delete this file independently from project repositories as desired.
101
-
102
- ### Project Memory
103
-
104
- Project memory keeps durable working knowledge between sessions without treating conversation history as project truth. It is enabled by default and can be disabled with `projectMemory: false` or through `/guard-options`. Facts, decisions, constraints, and lessons are stored in a local SQLite/FTS5 index under `$XDG_DATA_HOME/opencode/workflow-guard/project-memory/`, or `~/.local/share/opencode/workflow-guard/project-memory/` when `XDG_DATA_HOME` is unset. Git repositories are identified from their common Git directory, so linked worktrees share the same local project index.
105
-
106
- The local index is private working memory. Nothing from it is committed automatically. `project_memory_export` promotes only the explicitly selected current record IDs to `.opencode/memory/project-memory.jsonl`, a human-readable portable representation that another clone can import. The plugin adds `.opencode/memory/` to the clone-local `.git/info/exclude` by default; teams that deliberately want to version promoted knowledge can remove that exclusion or force-add the JSONL file. SQLite databases themselves should not be committed.
107
-
108
- Memory is supporting context, not authority. Superseded records are excluded from normal retrieval, recording and plugin imports reject content recognized by Workflow Guard's secret detector, and compaction injects at most eight recent current local records. Portable repository records are never injected automatically; they are available through explicit memory search after import. A local record tied to repository paths and a Git commit is omitted from compaction when those paths have committed, staged, or unstaged changes after that commit. Always prefer current repository state when it conflicts with historical memory.
109
-
110
- ---
111
-
112
- ## Repository Tree
113
-
114
- ```
115
- opencode-workflow-guard/
116
- ├── src/ # Production plugin sources
117
- │ ├── workflow-guard.ts # Server plugin entrypoint (hook orchestrator & public exports)
118
- │ ├── workflow-guard-ui.ts # Optional TUI companion prompt badge
119
- │ ├── policies/ # Modular policy implementations (task gate, git, secrets, boundary, ...)
120
- │ └── lib/ # Engine services (state, verify, audit, review, worktree, utils, types)
121
- ├── test/ # Test suites
122
- │ ├── test.mts # 415+ in-memory unit & adversarial tests
123
- │ └── test-e2e.mts # npm tarball resolution + live runtime OpenCode loader tests
124
- ├── docs/ # In-depth documentation & guides
125
- │ ├── policies.md # Complete 24-policy reference & overrides
126
- │ ├── installation.md # Setup options, worktrees & permissions
127
- │ ├── troubleshooting.md # Error diagnosis & bypass explanations
128
- │ └── testing.md # Test architecture & coverage matrix
129
- ├── .changeset/ # Fragment-based versioning & release changelogs
130
- ├── ACKNOWLEDGEMENTS.md # Open-source community credits & inspirations
131
- ├── CONTRIBUTING.md # Development guide, test patterns & PR rules
132
- ├── CHANGELOG.md # Version history
133
- └── SECURITY.md # Vulnerability disclosure policy
134
- ```
135
-
136
- ---
137
-
138
68
  ## License
139
69
 
140
70
  [MIT](LICENSE)
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { dirname, join } from "node:path";
6
6
  import { parse, modify, applyEdits } from "jsonc-parser";
7
7
 
8
- const SERVER_SPEC = "opencode-workflow-guard";
9
- const TUI_SPEC = "opencode-workflow-guard";
8
+ const PACKAGE_NAME = "opencode-workflow-guard";
9
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+ const SERVER_SPEC = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
11
+ const TUI_SPEC = SERVER_SPEC;
10
12
  const LEGACY_TUI_SPEC = "opencode-workflow-guard/tui";
11
13
 
12
14
  function configHome() {
@@ -16,9 +18,19 @@ function configHome() {
16
18
  function existingConfig(base, name) {
17
19
  const json = join(base, name + ".json");
18
20
  const jsonc = join(base, name + ".jsonc");
21
+ if (existsSync(json) && existsSync(jsonc)) throw new Error(`Cannot choose between both ${json} and ${jsonc}; remove the unused config first.`);
19
22
  return existsSync(json) ? json : existsSync(jsonc) ? jsonc : json;
20
23
  }
21
24
 
25
+ function restoreConfig(path, previous) {
26
+ if (previous === undefined) rmSync(path, { force: true });
27
+ else writeFileSync(path, previous);
28
+ }
29
+
30
+ function matchesPackageSpec(entry, spec) {
31
+ const value = Array.isArray(entry) ? entry[0] : entry;
32
+ return typeof value === "string" && (value === spec || value.startsWith(`${spec}@`));
33
+ }
22
34
  function preparePluginUpdate(path, schema, spec, legacySpec) {
23
35
  const source = existsSync(path) ? readFileSync(path, "utf8") : `{
24
36
  "$schema": "${schema}"
@@ -32,17 +44,18 @@ function preparePluginUpdate(path, schema, spec, legacySpec) {
32
44
  throw new Error(`Cannot update ${path}: "plugin" must be an array`);
33
45
  }
34
46
  const plugins = config.plugin ?? [];
35
- const matches = (entry, target) => entry === target || (Array.isArray(entry) && entry[0] === target);
36
- if (!legacySpec) {
37
- if (plugins.some((entry) => matches(entry, spec))) return undefined;
38
- return applyEdits(source, modify(source, ["plugin", plugins.length], spec, {
47
+ const hasSpec = plugins.some((entry) => matchesPackageSpec(entry, PACKAGE_NAME));
48
+ const updatedPlugins = legacySpec
49
+ ? plugins.filter((entry) => !matchesPackageSpec(entry, legacySpec))
50
+ : plugins;
51
+ if (updatedPlugins.length !== plugins.length) {
52
+ if (!hasSpec) updatedPlugins.push(spec);
53
+ return applyEdits(source, modify(source, ["plugin"], updatedPlugins, {
39
54
  formattingOptions: { insertSpaces: true, tabSize: 2 },
40
55
  }));
41
56
  }
42
- const updatedPlugins = plugins.filter((entry) => !matches(entry, legacySpec));
43
- if (!updatedPlugins.some((entry) => matches(entry, spec))) updatedPlugins.push(spec);
44
- if (updatedPlugins.length === plugins.length && updatedPlugins.every((entry, index) => entry === plugins[index])) return undefined;
45
- return applyEdits(source, modify(source, ["plugin"], updatedPlugins, {
57
+ if (hasSpec) return undefined;
58
+ return applyEdits(source, modify(source, ["plugin", plugins.length], spec, {
46
59
  formattingOptions: { insertSpaces: true, tabSize: 2 },
47
60
  }));
48
61
  }
@@ -57,9 +70,21 @@ if (process.argv[2] !== "setup" || process.argv.length > 3) {
57
70
  const tuiPath = existingConfig(base, "tui");
58
71
  const serverUpdate = preparePluginUpdate(serverPath, "https://opencode.ai/config.json", SERVER_SPEC);
59
72
  const tuiUpdate = preparePluginUpdate(tuiPath, "https://opencode.ai/tui.json", TUI_SPEC, LEGACY_TUI_SPEC);
73
+ const serverBefore = existsSync(serverPath) ? readFileSync(serverPath, "utf8") : undefined;
74
+ const tuiBefore = existsSync(tuiPath) ? readFileSync(tuiPath, "utf8") : undefined;
60
75
  mkdirSync(base, { recursive: true });
61
- if (serverUpdate !== undefined) writeFileSync(serverPath, serverUpdate);
62
- if (tuiUpdate !== undefined) writeFileSync(tuiPath, tuiUpdate);
76
+ try {
77
+ if (serverUpdate !== undefined) writeFileSync(serverPath, serverUpdate);
78
+ if (tuiUpdate !== undefined) writeFileSync(tuiPath, tuiUpdate);
79
+ } catch (error) {
80
+ try {
81
+ restoreConfig(serverPath, serverBefore);
82
+ restoreConfig(tuiPath, tuiBefore);
83
+ } catch (rollbackError) {
84
+ throw new Error(`Setup failed and config rollback also failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause: error });
85
+ }
86
+ throw error;
87
+ }
63
88
  console.log(`Workflow Guard configured in ${serverPath} and ${tuiPath}.`);
64
89
  console.log("Restart OpenCode to load both plugins.");
65
90
  } catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.7.2",
4
- "description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
3
+ "version": "1.8.1",
4
+ "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/workflow-guard.ts",
@@ -88,11 +88,50 @@ function withStoreLock<T>(workspace: string, action: () => T): T {
88
88
  const path = metadataPath(workspace);
89
89
  mkdirSync(dirname(path), { recursive: true });
90
90
  const lockPath = `${path}.lock`;
91
+ const reclaimPath = `${lockPath}.reclaim`;
91
92
  let lock: number;
92
93
  try {
93
94
  lock = openSync(lockPath, "wx", 0o600);
94
95
  } catch {
95
- throw new Error("Recovery checkpoint metadata is busy; refusing an unsafe concurrent update.");
96
+ let reclaim: number;
97
+ try {
98
+ reclaim = openSync(reclaimPath, "wx", 0o600);
99
+ } catch {
100
+ let staleReclaim = false;
101
+ try {
102
+ const owner = Number(readFileSync(reclaimPath, "utf8").trim());
103
+ if (Number.isInteger(owner) && owner > 0) {
104
+ try { process.kill(owner, 0); } catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") staleReclaim = true;
106
+ }
107
+ }
108
+ } catch {}
109
+ if (!staleReclaim) throw new Error("Recovery checkpoint metadata is busy; refusing an unsafe concurrent update.");
110
+ try {
111
+ unlinkSync(reclaimPath);
112
+ reclaim = openSync(reclaimPath, "wx", 0o600);
113
+ } catch {
114
+ throw new Error("Recovery checkpoint metadata is busy; refusing an unsafe concurrent update.");
115
+ }
116
+ }
117
+ try {
118
+ writeFileSync(reclaim, String(process.pid));
119
+ let stale = false;
120
+ try {
121
+ const owner = Number(readFileSync(lockPath, "utf8").trim());
122
+ if (Number.isInteger(owner) && owner > 0) {
123
+ try { process.kill(owner, 0); } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") stale = true;
125
+ }
126
+ }
127
+ } catch {}
128
+ if (!stale) throw new Error("Recovery checkpoint metadata is busy; refusing an unsafe concurrent update.");
129
+ unlinkSync(lockPath);
130
+ lock = openSync(lockPath, "wx", 0o600);
131
+ } finally {
132
+ closeSync(reclaim);
133
+ try { unlinkSync(reclaimPath); } catch {}
134
+ }
96
135
  }
97
136
  try {
98
137
  writeFileSync(lock, String(process.pid));
@@ -90,7 +90,7 @@ export function isReadOnlyRole(agent?: string): boolean {
90
90
 
91
91
  function logBlock(message: string): void {
92
92
  try {
93
- void (getSdkClient() as any)?.app?.log?.({
93
+ void getSdkClient()?.app?.log?.({
94
94
  body: {
95
95
  service: "workflow-guard",
96
96
  level: "warn",
@@ -140,7 +140,7 @@ export async function guardToolCallImpl(
140
140
  const record = asRecord(input);
141
141
  const rawTodos = record?.todos;
142
142
  if (Array.isArray(rawTodos)) {
143
- const newTodos = rawTodos as any[];
143
+ const newTodos = rawTodos;
144
144
  const existingTodos = context?.sessionID ? await fetchSessionTodos(context.sessionID) : undefined;
145
145
  const err = validateTodoLifecycle(newTodos, existingTodos);
146
146
  if (err) {
@@ -341,7 +341,7 @@ export async function guardToolCallImpl(
341
341
  return `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`;
342
342
  }
343
343
  }
344
- for (const payload of extractInterpreterPayload(command)) {
344
+ for (const payload of extractInterpreterPayload(raw)) {
345
345
  const outsidePath = outsideWritePathInPayload(payload, currentRoot);
346
346
  if (outsidePath) {
347
347
  logBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
@@ -56,8 +56,10 @@ export class ToolInvocationLifecycle {
56
56
 
57
57
  clearSession(sessionID: string): void {
58
58
  const prefix = `${sessionID}\0`;
59
- for (const key of this.startedAt.keys()) {
60
- if (key.startsWith(prefix)) this.startedAt.delete(key);
59
+ for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots]) {
60
+ for (const key of map.keys()) {
61
+ if (key.startsWith(prefix)) map.delete(key);
62
+ }
61
63
  }
62
64
  }
63
65
  }
@@ -24,7 +24,6 @@ export interface ToolOutcome {
24
24
  export class ToolOutcomeTracker {
25
25
  private readonly terminalCalls = new Map<string, Set<string>>();
26
26
  private readonly failures = new Map<string, { signature: string; count: number }>();
27
- private static readonly maxTerminalCallsPerSession = 1024;
28
27
 
29
28
  record(part: ToolOutcomePart): ToolOutcome | undefined {
30
29
  if (part.type !== "tool" || typeof part.sessionID !== "string" || typeof part.callID !== "string" || typeof part.tool !== "string") return undefined;
@@ -37,13 +36,10 @@ export class ToolOutcomeTracker {
37
36
  }
38
37
  if (terminalCalls.has(part.callID)) return undefined;
39
38
  terminalCalls.add(part.callID);
40
- if (terminalCalls.size > ToolOutcomeTracker.maxTerminalCallsPerSession) {
41
- terminalCalls.delete(terminalCalls.values().next().value!);
42
- }
43
39
 
44
40
  const start = part.state?.time?.start;
45
41
  const end = part.state?.time?.end;
46
- const durationMs = typeof start === "number" && typeof end === "number" ? Math.max(0, end - start) : undefined;
42
+ const durationMs = typeof start === "number" && Number.isFinite(start) && typeof end === "number" && Number.isFinite(end) ? Math.max(0, end - start) : undefined;
47
43
  if (status === "completed") {
48
44
  this.failures.delete(part.sessionID);
49
45
  return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs };
package/src/lib/types.ts CHANGED
@@ -4,6 +4,9 @@ export interface TodoItem {
4
4
  }
5
5
 
6
6
  export interface TodoSdkClient {
7
+ app?: {
8
+ log?: (opts: { body: { service: string; level: "debug" | "info" | "warn" | "error"; message: string } }) => Promise<unknown>;
9
+ };
7
10
  session?: {
8
11
  todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
9
12
  get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
package/src/lib/verify.ts CHANGED
@@ -203,10 +203,10 @@ export async function runVerify(
203
203
  env: getCleanEnv(),
204
204
  stdio: ["ignore", "pipe", "pipe"],
205
205
  });
206
- } catch (err: any) {
206
+ } catch (err: unknown) {
207
207
  return resolve({
208
208
  passed: false,
209
- output: `(spawn failed: ${err?.message ?? "unknown error"})`,
209
+ output: `(spawn failed: ${err instanceof Error ? err.message : "unknown error"})`,
210
210
  durationMs: Date.now() - start,
211
211
  });
212
212
  }
@@ -106,8 +106,8 @@ export function createGitWorktree(
106
106
  }
107
107
 
108
108
  return { success: true, worktreePath: targetPath };
109
- } catch (e: any) {
110
- return { success: false, error: e?.message ?? String(e) };
109
+ } catch (e: unknown) {
110
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
111
111
  }
112
112
  }
113
113
 
@@ -210,7 +210,7 @@ export function cleanupGitWorktree(
210
210
  }
211
211
 
212
212
  return { success: true };
213
- } catch (e: any) {
214
- return { success: false, error: e?.message ?? String(e) };
213
+ } catch (e: unknown) {
214
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
215
215
  }
216
216
  }
@@ -199,7 +199,7 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
199
199
  return undefined;
200
200
  }
201
201
 
202
- const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate"]);
202
+ const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate", "chmod", "chown", "chgrp"]);
203
203
  const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
204
204
 
205
205
  export function simpleFilesystemMutations(segment: string): ShellMutation[] {
@@ -101,10 +101,15 @@ function normalizedKubectlCommands(command: string): string {
101
101
  return normalized.join(" ; ");
102
102
  }
103
103
 
104
+ function normalizedShellCommands(command: string): string {
105
+ return splitShellSegments(command).map((segment) => shellWords(segment).join(" ")).join(" ; ");
106
+ }
107
+
104
108
  export function liveMutationIn(command: string): string | undefined {
105
- const optionNormalized = `${normalizedKubectlCommands(command)} ; ${command.replace(/\b(terraform|tofu)\s+(?:(?:-chdir(?:=\S+|\s+\S+)|-(?:no-color|version))\s+)*/g, "$1 ")}`;
109
+ const shellNormalized = normalizedShellCommands(command);
110
+ const optionNormalized = `${normalizedKubectlCommands(command)} ; ${shellNormalized.replace(/\b(terraform|tofu)\s+(?:(?:-chdir(?:=\S+|\s+\S+)|-(?:no-color|version))\s+)*/g, "$1 ")}`;
106
111
  for (const { re, what } of LIVE_MUTATION_PATTERNS) {
107
- if (re.test(command) || re.test(optionNormalized)) {
112
+ if (re.test(command) || re.test(shellNormalized) || re.test(optionNormalized)) {
108
113
  return what;
109
114
  }
110
115
  }
@@ -123,7 +123,7 @@ export function normalizeGitCommands(command: string): string {
123
123
  }
124
124
 
125
125
  export const GIT_WRITE_RE =
126
- /\bgit\s+(commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
126
+ /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
127
127
 
128
128
  export function currentGitBranch(root: string): string | undefined {
129
129
  const result = spawnSync("git", ["branch", "--show-current"], {
@@ -9,6 +9,16 @@ export function extractInterpreterPayload(segment: string): string[] {
9
9
  if (inlineMatch?.[1] || inlineMatch?.[2]) {
10
10
  payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
11
11
  }
12
+ const heredocHeader = segment.match(
13
+ /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\b[^\n]*<<(-?)\s*(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'";|&()<>\n]+))[^\n]*\n/i,
14
+ );
15
+ if (heredocHeader) {
16
+ const delimiter = heredocHeader[2] ?? heredocHeader[3] ?? heredocHeader[4];
17
+ const bodyStart = (heredocHeader.index ?? 0) + heredocHeader[0].length;
18
+ const lines = segment.slice(bodyStart).split("\n");
19
+ const terminator = lines.findIndex((line) => (heredocHeader[1] ? line.replace(/^\t+/, "") : line) === delimiter);
20
+ if (terminator >= 0) payloads.push(lines.slice(0, terminator).join("\n"));
21
+ }
12
22
  const psMatch = segment.match(
13
23
  /\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
14
24
  );
@@ -50,6 +60,8 @@ export function secretPathInPayload(payload: string): string | undefined {
50
60
  }
51
61
 
52
62
  const WRITE_TARGET_PATTERNS: RegExp[] = [
63
+ /\b(?:File|IO)\.(?:write|binwrite|delete|unlink|rename)\s*\(\s*["']([^"']+)["']/g,
64
+ /\bFile\.open\s*\(\s*["']([^"']+)["']\s*,\s*["'][^"']*[wa+][^"']*["']/g,
53
65
  /\b(?:writeFileSync|writeFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rmSync|rmdirSync|mkdirSync|renameSync|copyFileSync|cpSync|truncateSync)\s*\(\s*["']([^"']+)["']/g,
54
66
  /\b(?:renameSync|copyFileSync|cpSync)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
55
67
  /\b(?:os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["']([^"']+)["']/g,
@@ -1,4 +1,5 @@
1
1
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
2
+ import type { BaseRenderable } from "@opentui/core";
2
3
  import type { JSX } from "@opentui/solid";
3
4
  import { createElement, insert, setProp } from "@opentui/solid";
4
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -32,7 +33,7 @@ function text(props: Record<string, unknown>, children: Child[]) {
32
33
  const BADGE_ACTIVE = "Workflow Guard 🛡️";
33
34
  let lastBlockedReason: string | undefined;
34
35
  const sessionBlockedReasons = new Map<string, string>();
35
- const badgeNodes = new Map<string, unknown>();
36
+ const badgeNodes = new Map<string, BaseRenderable>();
36
37
 
37
38
  export function readProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning"): boolean {
38
39
  const path = projectConfigPath(root);
@@ -125,9 +126,9 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
125
126
  });
126
127
 
127
128
  try {
128
- api.event?.on?.("tui.toast.show" as any, (e: any) => {
129
- const title = e?.properties?.title ?? e?.title;
130
- const msg = e?.properties?.message ?? e?.message;
129
+ api.event.on("tui.toast.show", (e) => {
130
+ const title = e.properties.title;
131
+ const msg = e.properties.message;
131
132
  if (title === "Workflow Guard Blocked" && typeof msg === "string") {
132
133
  lastBlockedReason = msg;
133
134
  const route = api.route.current;
@@ -140,8 +141,8 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
140
141
  const node = badgeNodes.get(key);
141
142
  if (node) {
142
143
  const badge = formatBadge(sessionID);
143
- setProp(node as any, "content", badge.text);
144
- setProp(node as any, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
144
+ setProp(node, "content", badge.text);
145
+ setProp(node, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
145
146
  }
146
147
  }
147
148
  });
@@ -154,7 +155,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
154
155
  const theme = api.theme.current;
155
156
  const badge = formatBadge();
156
157
  const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
157
- badgeNodes.set("home", node);
158
+ badgeNodes.set("home", node as unknown as BaseRenderable);
158
159
  return node;
159
160
  },
160
161
  session_prompt_right() {
@@ -166,7 +167,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
166
167
  : undefined;
167
168
  const badge = formatBadge(sessionID);
168
169
  const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
169
- if (sessionID) badgeNodes.set(sessionID, node);
170
+ if (sessionID) badgeNodes.set(sessionID, node as unknown as BaseRenderable);
170
171
  return node;
171
172
  },
172
173
  },
@@ -19,13 +19,13 @@ import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
19
19
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
20
20
  import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
21
21
  import { createCustomTools } from "./lib/custom-tools.ts";
22
+ import type { TodoSdkClient } from "./lib/types.ts";
22
23
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
23
24
  export { extractReviewFollowups } from "./lib/custom-tools.ts";
24
25
 
25
26
  // ── Types ────────────────────────────────────────────────────────────────────
26
27
  export type {
27
28
  TodoItem,
28
- TodoSdkClient,
29
29
  ProjectConfig,
30
30
  VerifyResult,
31
31
  ReviewResult,
@@ -36,6 +36,7 @@ export type {
36
36
  LearningEvidence,
37
37
  LearningOpportunity,
38
38
  } from "./lib/types.ts";
39
+ export type { TodoSdkClient };
39
40
 
40
41
  // ── State & runtime context ──────────────────────────────────────────────────
41
42
  import {
@@ -281,7 +282,7 @@ export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotificatio
281
282
 
282
283
  function logObservation(client: unknown, message: string): void {
283
284
  try {
284
- void (client as any)?.app?.log?.({
285
+ void (client as TodoSdkClient | undefined)?.app?.log?.({
285
286
  body: { service: "workflow-guard", level: "info", message },
286
287
  });
287
288
  } catch {}
@@ -359,7 +360,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
359
360
  }
360
361
 
361
362
  try {
362
- await (ctx.client as any)?.app?.log?.({
363
+ await ctx.client.app.log({
363
364
  body: {
364
365
  service: "workflow-guard",
365
366
  level: "info",
@@ -529,7 +530,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
529
530
  }
530
531
  if (scrubbed.length > 0) {
531
532
  try {
532
- await (getSdkClient() as any)?.app?.log?.({
533
+ await getSdkClient()?.app?.log?.({
533
534
  body: {
534
535
  service: "workflow-guard",
535
536
  level: "warn",
@@ -638,7 +639,6 @@ export const WorkflowGuard: Plugin = async (ctx) => {
638
639
  if (event?.type === "session.idle") {
639
640
  const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
640
641
  if (typeof sessionID === "string") {
641
- toolOutcomes.clearSession(sessionID);
642
642
  releaseFileClaims(sessionID);
643
643
  clearReadFingerprints(sessionID);
644
644
  toolLifecycle.clearSession(sessionID);