opencode-workflow-guard 1.7.2 → 1.8.0
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 +4 -14
- package/bin/opencode-workflow-guard.mjs +39 -14
- package/package.json +1 -1
- package/src/lib/checkpoint.ts +40 -1
- package/src/lib/guard-dispatcher.ts +3 -3
- package/src/lib/tool-lifecycle.ts +4 -2
- package/src/lib/tool-outcomes.ts +1 -5
- package/src/lib/types.ts +3 -0
- package/src/lib/verify.ts +2 -2
- package/src/lib/worktree.ts +4 -4
- package/src/policies/boundary.ts +1 -1
- package/src/policies/destructive.ts +7 -2
- package/src/policies/git.ts +1 -1
- package/src/policies/interpreter.ts +12 -0
- package/src/workflow-guard-ui.ts +9 -8
- package/src/workflow-guard.ts +5 -5
package/README.md
CHANGED
|
@@ -12,24 +12,14 @@ OpenCode plugin enforcing workflow discipline, agent focus, and deterministic sa
|
|
|
12
12
|
|
|
13
13
|
### 1. Installation
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Install the current published version globally with OpenCode's plugin installer:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
|
|
18
|
+
VERSION=$(npm view opencode-workflow-guard version)
|
|
19
|
+
opencode plugin "opencode-workflow-guard@$VERSION" --global --force
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```json
|
|
24
|
-
{
|
|
25
|
-
"$schema": "https://opencode.ai/config.json",
|
|
26
|
-
"plugin": [
|
|
27
|
-
"opencode-workflow-guard"
|
|
28
|
-
]
|
|
29
|
-
}
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
*Requires OpenCode >= 1.18.* Restart OpenCode after setup. See [docs/installation.md](docs/installation.md) for full setup options.
|
|
22
|
+
*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.
|
|
33
23
|
|
|
34
24
|
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.
|
|
35
25
|
|
|
@@ -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
|
|
9
|
-
const
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
package/src/lib/checkpoint.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
60
|
-
|
|
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
|
}
|
package/src/lib/tool-outcomes.ts
CHANGED
|
@@ -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:
|
|
206
|
+
} catch (err: unknown) {
|
|
207
207
|
return resolve({
|
|
208
208
|
passed: false,
|
|
209
|
-
output: `(spawn failed: ${err
|
|
209
|
+
output: `(spawn failed: ${err instanceof Error ? err.message : "unknown error"})`,
|
|
210
210
|
durationMs: Date.now() - start,
|
|
211
211
|
});
|
|
212
212
|
}
|
package/src/lib/worktree.ts
CHANGED
|
@@ -106,8 +106,8 @@ export function createGitWorktree(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
return { success: true, worktreePath: targetPath };
|
|
109
|
-
} catch (e:
|
|
110
|
-
return { success: false, error: 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:
|
|
214
|
-
return { success: false, error: e
|
|
213
|
+
} catch (e: unknown) {
|
|
214
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
215
215
|
}
|
|
216
216
|
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -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,
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -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,
|
|
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
|
|
129
|
-
const title = e
|
|
130
|
-
const msg = e
|
|
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
|
|
144
|
-
setProp(node
|
|
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
|
},
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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);
|