opencode-workflow-guard 1.8.3 → 1.8.5
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/package.json +2 -2
- package/src/lib/project-memory.ts +126 -9
- package/src/lib/shell.ts +16 -1
- package/src/policies/shell-safety.ts +24 -6
- package/src/workflow-guard-ui.ts +23 -3
- package/src/workflow-guard.ts +3 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.5",
|
|
4
4
|
"description": "Deterministic workflow and safety policy enforcement for OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"workflow"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node test/test.
|
|
37
|
+
"test": "node test/run-test.mjs",
|
|
38
38
|
"test:install": "node test/test-e2e.mts",
|
|
39
39
|
"test:all": "npm run typecheck && npm test && npm run test:install",
|
|
40
40
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { execFile, spawnSync } from "node:child_process";
|
|
5
5
|
import { Database, type SqliteDatabase } from "./sqlite.ts";
|
|
@@ -7,13 +7,27 @@ import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, Projec
|
|
|
7
7
|
|
|
8
8
|
const KINDS = new Set<ProjectMemoryKind>(["fact", "decision", "constraint", "lesson"]);
|
|
9
9
|
const SOURCES = new Set<ProjectMemorySource>(["user", "file", "git", "tool", "agent", "portable"]);
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const OBSOLETE_RETENTION_MS = 90 * DAY_MS;
|
|
12
|
+
const MAINTENANCE_INTERVAL_MS = DAY_MS;
|
|
13
|
+
const STORAGE_LIMIT_BYTES = 100 * 1024 * 1024;
|
|
14
|
+
const STORAGE_TARGET_BYTES = 80 * 1024 * 1024;
|
|
10
15
|
|
|
11
16
|
export interface ProjectMemoryStore {
|
|
12
17
|
db: SqliteDatabase;
|
|
13
18
|
projectId: string;
|
|
19
|
+
path: string;
|
|
20
|
+
activeMarker?: string;
|
|
14
21
|
close(): void;
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
interface MaintenanceOptions {
|
|
25
|
+
force?: boolean;
|
|
26
|
+
now?: number;
|
|
27
|
+
storageLimitBytes?: number;
|
|
28
|
+
storageTargetBytes?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export function getProjectMemoryDir(): string {
|
|
18
32
|
const base = process.env.XDG_DATA_HOME || join(process.env.HOME || process.cwd(), ".local", "share");
|
|
19
33
|
return join(base, "opencode", "workflow-guard", "project-memory");
|
|
@@ -66,14 +80,20 @@ export async function isProjectMemoryFreshAsync(memory: ProjectMemoryRecord, roo
|
|
|
66
80
|
export function openProjectMemory(projectId: string, directory = getProjectMemoryDir()): ProjectMemoryStore {
|
|
67
81
|
if (!projectId || projectId.length > 200) throw new Error("Invalid project memory identity.");
|
|
68
82
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
const path = join(directory, `${projectId.replace(/[^a-zA-Z0-9._-]/g, "_")}.sqlite`);
|
|
84
|
+
const activeMarker = `${path}.active.${process.pid}.${randomUUID()}`;
|
|
85
|
+
const releaseLock = acquireDatabaseLock(directory);
|
|
86
|
+
let db: SqliteDatabase | undefined;
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(activeMarker, "", { mode: 0o600 });
|
|
89
|
+
db = new Database(path);
|
|
90
|
+
db.exec(`
|
|
72
91
|
PRAGMA busy_timeout = 2000;
|
|
92
|
+
PRAGMA journal_mode = WAL;
|
|
73
93
|
CREATE TABLE IF NOT EXISTS memories (
|
|
74
94
|
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
|
|
75
95
|
source TEXT NOT NULL, created_at INTEGER NOT NULL, session_id TEXT, commit_sha TEXT,
|
|
76
|
-
paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current'
|
|
96
|
+
paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current', superseded_at INTEGER
|
|
77
97
|
);
|
|
78
98
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content);
|
|
79
99
|
CREATE TABLE IF NOT EXISTS review_followups (
|
|
@@ -81,8 +101,105 @@ export function openProjectMemory(projectId: string, directory = getProjectMemor
|
|
|
81
101
|
reviewer TEXT NOT NULL, created_at INTEGER NOT NULL, resolved_at INTEGER, session_id TEXT,
|
|
82
102
|
commit_sha TEXT, paths TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open'
|
|
83
103
|
);
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
`);
|
|
105
|
+
const memoryColumns = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>;
|
|
106
|
+
if (!memoryColumns.some((column) => column.name === "superseded_at")) {
|
|
107
|
+
db.exec("ALTER TABLE memories ADD COLUMN superseded_at INTEGER");
|
|
108
|
+
db.exec("UPDATE memories SET superseded_at = created_at WHERE status = 'superseded'");
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
try { db?.close(); } finally { rmSync(activeMarker, { force: true }); }
|
|
112
|
+
throw error;
|
|
113
|
+
} finally {
|
|
114
|
+
releaseLock();
|
|
115
|
+
}
|
|
116
|
+
if (!db) throw new Error("Project memory database failed to initialize.");
|
|
117
|
+
const store: ProjectMemoryStore = { db, projectId, path, activeMarker, close: () => { try { db.close(); } finally { rmSync(activeMarker, { force: true }); } } };
|
|
118
|
+
try { maintainProjectMemoryStorage(store, directory); } catch {}
|
|
119
|
+
return store;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function acquireDatabaseLock(directory: string): () => void {
|
|
123
|
+
const lock = new Database(join(directory, ".coordination"));
|
|
124
|
+
try {
|
|
125
|
+
lock.exec("PRAGMA busy_timeout = 10000; BEGIN IMMEDIATE");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
lock.close();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
return () => { try { lock.exec("COMMIT"); } finally { lock.close(); } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function databaseDiskUsage(path: string): number {
|
|
134
|
+
let size = 0;
|
|
135
|
+
for (const suffix of ["", "-wal", "-shm"]) try { size += statSync(path + suffix).size; } catch {}
|
|
136
|
+
return size;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function databaseIsActive(path: string, directory: string): boolean {
|
|
140
|
+
const prefix = `${path.slice(directory.length + 1)}.active.`;
|
|
141
|
+
let active = false;
|
|
142
|
+
for (const name of readdirSync(directory).filter((entry) => entry.startsWith(prefix))) {
|
|
143
|
+
const pid = Number(name.slice(prefix.length).split(".", 1)[0]);
|
|
144
|
+
if (!Number.isInteger(pid) || pid <= 0) { active = true; continue; }
|
|
145
|
+
try { process.kill(pid, 0); active = true; } catch (error) {
|
|
146
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(join(directory, name), { force: true });
|
|
147
|
+
else active = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return active;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function maintainProjectMemoryStorage(store: ProjectMemoryStore, directory = getProjectMemoryDir(), options: MaintenanceOptions = {}): void {
|
|
154
|
+
const now = options.now ?? Date.now();
|
|
155
|
+
const maintenanceMarker = `${store.path}.maintenance`;
|
|
156
|
+
const releaseMaintenanceLock = acquireDatabaseLock(directory);
|
|
157
|
+
try {
|
|
158
|
+
let lastMaintained = 0;
|
|
159
|
+
try { lastMaintained = statSync(maintenanceMarker).mtimeMs; } catch {}
|
|
160
|
+
if (!options.force && now - lastMaintained < MAINTENANCE_INTERVAL_MS) return;
|
|
161
|
+
|
|
162
|
+
const cutoff = now - OBSOLETE_RETENTION_MS;
|
|
163
|
+
store.db.exec("BEGIN IMMEDIATE");
|
|
164
|
+
try {
|
|
165
|
+
store.db.prepare("DELETE FROM memories_fts WHERE id IN (SELECT id FROM memories WHERE status = 'superseded' AND superseded_at < ?)").run(cutoff);
|
|
166
|
+
store.db.prepare("DELETE FROM memories WHERE status = 'superseded' AND superseded_at < ?").run(cutoff);
|
|
167
|
+
store.db.prepare("DELETE FROM review_followups WHERE status = 'resolved' AND resolved_at < ?").run(cutoff);
|
|
168
|
+
store.db.exec("COMMIT");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
store.db.exec("ROLLBACK");
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
store.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
174
|
+
store.db.exec("PRAGMA optimize");
|
|
175
|
+
store.db.exec("VACUUM");
|
|
176
|
+
try { utimesSync(store.path, now / 1000, now / 1000); } catch {}
|
|
177
|
+
writeFileSync(maintenanceMarker, "", { mode: 0o600 });
|
|
178
|
+
try { utimesSync(maintenanceMarker, now / 1000, now / 1000); } catch {}
|
|
179
|
+
} finally {
|
|
180
|
+
releaseMaintenanceLock();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const limit = options.storageLimitBytes ?? STORAGE_LIMIT_BYTES;
|
|
184
|
+
const target = Math.min(limit, options.storageTargetBytes ?? STORAGE_TARGET_BYTES);
|
|
185
|
+
const candidates = readdirSync(directory)
|
|
186
|
+
.filter((name) => name.endsWith(".sqlite"))
|
|
187
|
+
.map((name) => join(directory, name))
|
|
188
|
+
.filter((path) => path !== store.path)
|
|
189
|
+
.flatMap((path) => { try { const stat = statSync(path); return [{ path, size: databaseDiskUsage(path), mtimeMs: stat.mtimeMs }]; } catch { return []; } });
|
|
190
|
+
let total = candidates.reduce((sum, item) => sum + item.size, databaseDiskUsage(store.path));
|
|
191
|
+
if (total <= limit) return;
|
|
192
|
+
for (const candidate of candidates.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
|
|
193
|
+
const releaseCandidateLock = acquireDatabaseLock(directory);
|
|
194
|
+
try {
|
|
195
|
+
if (databaseIsActive(candidate.path, directory)) continue;
|
|
196
|
+
for (const suffix of ["", "-wal", "-shm", ".maintenance"]) rmSync(candidate.path + suffix, { force: true });
|
|
197
|
+
} finally {
|
|
198
|
+
releaseCandidateLock();
|
|
199
|
+
}
|
|
200
|
+
total -= candidate.size;
|
|
201
|
+
if (total <= target) break;
|
|
202
|
+
}
|
|
86
203
|
}
|
|
87
204
|
|
|
88
205
|
function followupRowToRecord(row: Record<string, unknown>): ReviewFollowup {
|
|
@@ -147,8 +264,8 @@ export function recordProjectMemory(store: ProjectMemoryStore, input: ProjectMem
|
|
|
147
264
|
const createdAt = Date.now();
|
|
148
265
|
store.db.exec("BEGIN IMMEDIATE");
|
|
149
266
|
try {
|
|
150
|
-
if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded' WHERE id = ? AND project_id = ?").run(input.supersedes, store.projectId);
|
|
151
|
-
store.db.prepare("INSERT INTO memories VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current')").run(
|
|
267
|
+
if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded', superseded_at = ? WHERE id = ? AND project_id = ?").run(createdAt, input.supersedes, store.projectId);
|
|
268
|
+
store.db.prepare("INSERT INTO memories (id, project_id, kind, content, source, created_at, session_id, commit_sha, paths, supersedes, status, superseded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', NULL)").run(
|
|
152
269
|
id, store.projectId, input.kind, input.content.trim(), input.source, createdAt,
|
|
153
270
|
input.sessionID ?? null, input.commit ?? null, JSON.stringify(input.paths ?? []), input.supersedes ?? null,
|
|
154
271
|
);
|
package/src/lib/shell.ts
CHANGED
|
@@ -242,7 +242,22 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
242
242
|
continue;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
if (word === "command"
|
|
245
|
+
if (word === "command") {
|
|
246
|
+
i++;
|
|
247
|
+
while (i < words.length && words[i] === "-p") i++;
|
|
248
|
+
if (words[i] === "--") i++;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (word === "exec") {
|
|
252
|
+
i++;
|
|
253
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
254
|
+
const option = words[i++]!;
|
|
255
|
+
if (option === "--") break;
|
|
256
|
+
if (option === "-a" && i < words.length) i++;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (word === "nohup") {
|
|
246
261
|
i++;
|
|
247
262
|
continue;
|
|
248
263
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { splitShellSegments, unwrapShellWords } from "../lib/shell.ts";
|
|
2
4
|
|
|
3
5
|
// AI agents frequently run dangerous package manager flags (e.g. npm audit fix --force,
|
|
4
6
|
// npm install -g, pip install --force-reinstall, agent-driven publish) that introduce
|
|
@@ -48,11 +50,6 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
48
50
|
name: "interactive text editor",
|
|
49
51
|
advice: "Use the edit, write, or apply_patch tools instead of interactive terminal editors.",
|
|
50
52
|
},
|
|
51
|
-
{
|
|
52
|
-
regex: /\b(?:less|more|most)\b/i,
|
|
53
|
-
name: "terminal pager",
|
|
54
|
-
advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
|
|
55
|
-
},
|
|
56
53
|
{
|
|
57
54
|
regex: /\bsudo\b/i,
|
|
58
55
|
name: "sudo with password prompt",
|
|
@@ -86,6 +83,7 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
86
83
|
];
|
|
87
84
|
|
|
88
85
|
const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
|
|
86
|
+
const PAGER_ADVICE = "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.";
|
|
89
87
|
// Process monitoring tokens are matched on whitespace/segment boundaries so that
|
|
90
88
|
// an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
|
|
91
89
|
// or a quoted argument like `echo "top"` is not mistaken for the `top` command.
|
|
@@ -105,9 +103,29 @@ function checkProcessMonitorCommand(command: string): { isInteractive: boolean;
|
|
|
105
103
|
return { isInteractive: false };
|
|
106
104
|
}
|
|
107
105
|
|
|
106
|
+
function containsPagerCommand(command: string, depth = 0): boolean {
|
|
107
|
+
if (depth >= 16) return false;
|
|
108
|
+
for (const segment of splitShellSegments(command)) {
|
|
109
|
+
const words = unwrapShellWords(segment);
|
|
110
|
+
while (words[0] === "--") words.shift();
|
|
111
|
+
const executable = basename(words[0] ?? "");
|
|
112
|
+
if (/^(?:less|more|most)$/i.test(executable)) return true;
|
|
113
|
+
if (executable === "eval" && words.length > 1 && containsPagerCommand(words.slice(1).join(" "), depth + 1)) return true;
|
|
114
|
+
if (executable === "busybox" && /^(?:less|more)$/i.test(words[1] ?? "")) return true;
|
|
115
|
+
if (/^(?:ba|z|da|k)?sh$/i.test(executable)) {
|
|
116
|
+
const commandFlag = words.findIndex((word, index) => index > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
|
|
117
|
+
if (commandFlag >= 0 && words[commandFlag + 1] && containsPagerCommand(words[commandFlag + 1]!, depth + 1)) return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
109
124
|
const monitor = checkProcessMonitorCommand(command);
|
|
110
125
|
if (monitor.isInteractive) return monitor;
|
|
126
|
+
if (containsPagerCommand(command)) {
|
|
127
|
+
return { isInteractive: true, name: "terminal pager", advice: PAGER_ADVICE };
|
|
128
|
+
}
|
|
111
129
|
for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
|
|
112
130
|
if (regex.test(command)) {
|
|
113
131
|
return { isInteractive: true, name, advice };
|
|
@@ -129,7 +147,7 @@ export function escapeAppleScriptString(str: string): string {
|
|
|
129
147
|
|
|
130
148
|
/**
|
|
131
149
|
* Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
|
|
132
|
-
*
|
|
150
|
+
* for events that explicitly require the user's attention.
|
|
133
151
|
* Non-blocking, fails gracefully.
|
|
134
152
|
*/
|
|
135
153
|
export function sendDesktopNotification(title: string, message: string): void {
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -31,9 +31,9 @@ function text(props: Record<string, unknown>, children: Child[]) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const BADGE_ACTIVE = "Workflow Guard 🛡️";
|
|
34
|
+
const BLOCKED_BADGE_DURATION_MS = 5_000;
|
|
34
35
|
let lastBlockedReason: string | undefined;
|
|
35
36
|
const sessionBlockedReasons = new Map<string, string>();
|
|
36
|
-
const badgeNodes = new Map<string, BaseRenderable>();
|
|
37
37
|
|
|
38
38
|
export function readProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning"): boolean {
|
|
39
39
|
const path = projectConfigPath(root);
|
|
@@ -86,12 +86,15 @@ export function formatBadge(sessionID?: string): { text: string; isBlocked: bool
|
|
|
86
86
|
if (!reason) {
|
|
87
87
|
return { text: BADGE_ACTIVE, isBlocked: false };
|
|
88
88
|
}
|
|
89
|
-
const cleanReason = reason.replace(/^\[workflow-guard\]\s
|
|
89
|
+
const cleanReason = reason.replace(/^\[workflow-guard\]\s*/i, "").replace(/^Blocked(?::|\s+)\s*/i, "");
|
|
90
90
|
const shortReason = cleanReason.length > 30 ? cleanReason.slice(0, 27) + "..." : cleanReason;
|
|
91
91
|
return { text: `[Workflow Guard: Blocked: ${shortReason}]`, isBlocked: true };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
95
|
+
const badgeNodes = new Map<string, BaseRenderable>();
|
|
96
|
+
const blockedBadgeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
97
|
+
|
|
95
98
|
api.keymap.registerLayer({
|
|
96
99
|
commands: [{
|
|
97
100
|
name: "workflow-guard.project-options",
|
|
@@ -125,18 +128,34 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
125
128
|
bindings: [],
|
|
126
129
|
});
|
|
127
130
|
|
|
131
|
+
function scheduleBadgeReset(sessionID?: string): void {
|
|
132
|
+
const key = sessionID ?? "home";
|
|
133
|
+
const existing = blockedBadgeTimers.get(key);
|
|
134
|
+
if (existing) clearTimeout(existing);
|
|
135
|
+
blockedBadgeTimers.set(key, setTimeout(() => {
|
|
136
|
+
blockedBadgeTimers.delete(key);
|
|
137
|
+
if (sessionID) sessionBlockedReasons.delete(sessionID);
|
|
138
|
+
else lastBlockedReason = undefined;
|
|
139
|
+
const node = badgeNodes.get(key);
|
|
140
|
+
if (node) {
|
|
141
|
+
setProp(node, "content", BADGE_ACTIVE);
|
|
142
|
+
setProp(node, "fg", api.theme.current.success);
|
|
143
|
+
}
|
|
144
|
+
}, BLOCKED_BADGE_DURATION_MS));
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
try {
|
|
129
148
|
api.event.on("tui.toast.show", (e) => {
|
|
130
149
|
const title = e.properties.title;
|
|
131
150
|
const msg = e.properties.message;
|
|
132
151
|
if (title === "Workflow Guard Blocked" && typeof msg === "string") {
|
|
133
|
-
lastBlockedReason = msg;
|
|
134
152
|
const route = api.route.current;
|
|
135
153
|
const sessionID =
|
|
136
154
|
route.name === "session" && typeof route.params?.sessionID === "string"
|
|
137
155
|
? route.params.sessionID
|
|
138
156
|
: undefined;
|
|
139
157
|
if (sessionID) sessionBlockedReasons.set(sessionID, msg);
|
|
158
|
+
else lastBlockedReason = msg;
|
|
140
159
|
const key = sessionID ?? "home";
|
|
141
160
|
const node = badgeNodes.get(key);
|
|
142
161
|
if (node) {
|
|
@@ -144,6 +163,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
144
163
|
setProp(node, "content", badge.text);
|
|
145
164
|
setProp(node, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
|
|
146
165
|
}
|
|
166
|
+
scheduleBadgeReset(sessionID);
|
|
147
167
|
}
|
|
148
168
|
});
|
|
149
169
|
} catch {}
|
package/src/workflow-guard.ts
CHANGED
|
@@ -122,6 +122,7 @@ export {
|
|
|
122
122
|
ensureProjectMemoryExcluded,
|
|
123
123
|
isProjectMemoryFresh,
|
|
124
124
|
openProjectMemory,
|
|
125
|
+
maintainProjectMemoryStorage,
|
|
125
126
|
recordProjectMemory,
|
|
126
127
|
searchProjectMemory,
|
|
127
128
|
getRecentProjectMemory,
|
|
@@ -270,7 +271,6 @@ export { branchHasDocumentationChange };
|
|
|
270
271
|
import {
|
|
271
272
|
checkInteractiveTtyCommand,
|
|
272
273
|
checkPackageHygiene,
|
|
273
|
-
sendDesktopNotification,
|
|
274
274
|
escapeAppleScriptString,
|
|
275
275
|
} from "./policies/shell-safety.ts";
|
|
276
276
|
|
|
@@ -278,7 +278,8 @@ import { checkCompletionClaims } from "./policies/completion.ts";
|
|
|
278
278
|
|
|
279
279
|
export { checkCompletionClaims };
|
|
280
280
|
|
|
281
|
-
export {
|
|
281
|
+
export { sendDesktopNotification } from "./policies/shell-safety.ts";
|
|
282
|
+
export { checkInteractiveTtyCommand, checkPackageHygiene, escapeAppleScriptString };
|
|
282
283
|
|
|
283
284
|
function logObservation(client: unknown, message: string): void {
|
|
284
285
|
try {
|
|
@@ -322,9 +323,6 @@ export async function guardToolCall(
|
|
|
322
323
|
}
|
|
323
324
|
|
|
324
325
|
async function emitBlockFeedback(message: string): Promise<void> {
|
|
325
|
-
try {
|
|
326
|
-
sendDesktopNotification("Workflow Guard Blocked", message);
|
|
327
|
-
} catch {}
|
|
328
326
|
await showBlockToast(message);
|
|
329
327
|
}
|
|
330
328
|
|