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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.3",
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.mts",
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 db = new Database(join(directory, `${projectId.replace(/[^a-zA-Z0-9._-]/g, "_")}.sqlite`));
70
- db.exec(`
71
- PRAGMA journal_mode = WAL;
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
- return { db, projectId, close: () => db.close() };
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" || word === "exec" || word === "eval" || word === "nohup") {
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
- * on important guard events (policy blocks, verification completion).
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 {
@@ -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*/, "").replace(/^Blocked:\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 {}
@@ -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 { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification, escapeAppleScriptString };
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