wogiflow 2.29.4 → 2.29.6

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.
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Mechanical Deferral Authorization Gate (wf-f9912af6)
5
+ *
6
+ * Prevents the AI from silently writing `status: deferred*` to review/audit
7
+ * findings. The CLAUDE.md "Review-Findings Anti-Deferral" rule was honor-
8
+ * system; this gate makes it mechanical.
9
+ *
10
+ * Pattern: PreToolUse intercepts Write/Edit/Bash to .workflow/state/last-review.json
11
+ * (and last-audit.json). It compares the new content against the prior on-disk
12
+ * content. If any finding's status transitions INTO a `deferred*` state and no
13
+ * valid authorization marker is present, the write is blocked.
14
+ *
15
+ * Authorization comes from one of:
16
+ * - User's UserPromptSubmit message contains explicit defer phrases
17
+ * ("defer X", "fix critical only", "ship as-is", etc.) → classifier
18
+ * writes deferral-authorization.json
19
+ * - Explicit CLI: `node scripts/flow-defer-auth.js grant ...`
20
+ *
21
+ * Negative intent ("fix everything", "no deferrals", "I don't want tech debt")
22
+ * writes a no-defer-pin.json that HARD-BLOCKS deferrals for the current turn,
23
+ * overriding any auth marker.
24
+ *
25
+ * Fail-open: any parse error, missing config, or unexpected exception falls
26
+ * through (allow the write). The block path is for confirmed deferral attempts
27
+ * with no auth — every other case allows the write.
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { PATHS } = require('../../flow-utils');
33
+ const { safeJsonParse } = require('../../flow-io');
34
+
35
+ const AUTH_FILE = 'deferral-authorization.json';
36
+ const NO_DEFER_PIN_FILE = 'no-defer-pin.json';
37
+ const BLOCK_LOG_FILE = 'deferral-block-log.json';
38
+ const DEFAULT_TTL_SECONDS = 600; // 10 minutes
39
+ const TARGET_BASENAMES = new Set(['last-review.json', 'last-audit.json']);
40
+
41
+ function getAuthPath() { return path.join(PATHS.state, AUTH_FILE); }
42
+ function getNoDeferPinPath() { return path.join(PATHS.state, NO_DEFER_PIN_FILE); }
43
+ function getBlockLogPath() { return path.join(PATHS.state, BLOCK_LOG_FILE); }
44
+
45
+ function isGateEnabled(config) {
46
+ const cfg = config?.deferralGate;
47
+ if (cfg === false) return false;
48
+ if (cfg && typeof cfg === 'object' && cfg.enabled === false) return false;
49
+ return true;
50
+ }
51
+
52
+ function getAuthTtlSeconds(config) {
53
+ const v = config?.deferralGate?.authTtlSeconds;
54
+ if (typeof v === 'number' && v > 0 && Number.isFinite(v)) return v;
55
+ return DEFAULT_TTL_SECONDS;
56
+ }
57
+
58
+ /**
59
+ * Match deferral-style status values. Conservative: any string starting with
60
+ * "deferred" (case-insensitive), plus common synonyms.
61
+ */
62
+ const DEFERRAL_STATUS_RX = /^(?:deferred(?:[-_].*)?|wont-?fix|won-?t-?fix|skipped|dismissed-low-priority)$/i;
63
+
64
+ function isDeferralStatus(status) {
65
+ return typeof status === 'string' && DEFERRAL_STATUS_RX.test(status.trim());
66
+ }
67
+
68
+ /**
69
+ * Identify findings whose status transitions INTO a deferral state.
70
+ * Pre-existing deferrals (same finding, same status) are grandfathered.
71
+ *
72
+ * @param {Object|null} prevContent - parsed prior file content, or null if file didn't exist
73
+ * @param {Object} newContent - parsed new file content
74
+ * @returns {Array<{id: string, prevStatus: string|null, newStatus: string}>}
75
+ */
76
+ function detectDeferralChanges(prevContent, newContent) {
77
+ const changes = [];
78
+ const newFindings = Array.isArray(newContent?.findings) ? newContent.findings : [];
79
+ const prevByIdMap = new Map();
80
+ if (prevContent && Array.isArray(prevContent.findings)) {
81
+ for (const f of prevContent.findings) {
82
+ if (f && typeof f.id === 'string') prevByIdMap.set(f.id, f);
83
+ }
84
+ }
85
+ for (const f of newFindings) {
86
+ if (!f || typeof f.id !== 'string' || !isDeferralStatus(f.status)) continue;
87
+ const prev = prevByIdMap.get(f.id);
88
+ const prevStatus = prev?.status || null;
89
+ if (prevStatus && isDeferralStatus(prevStatus)) continue; // grandfathered
90
+ changes.push({ id: f.id, prevStatus, newStatus: f.status });
91
+ }
92
+ return changes;
93
+ }
94
+
95
+ function loadAuth() {
96
+ const auth = safeJsonParse(getAuthPath(), null);
97
+ if (!auth || typeof auth !== 'object') return null;
98
+ // Expiry check
99
+ if (auth.expiresAt) {
100
+ const exp = Date.parse(auth.expiresAt);
101
+ if (Number.isFinite(exp) && exp < Date.now()) return null;
102
+ }
103
+ return auth;
104
+ }
105
+
106
+ function loadNoDeferPin() {
107
+ const pin = safeJsonParse(getNoDeferPinPath(), null);
108
+ if (!pin || typeof pin !== 'object') return null;
109
+ if (pin.expiresAt) {
110
+ const exp = Date.parse(pin.expiresAt);
111
+ if (Number.isFinite(exp) && exp < Date.now()) return null;
112
+ }
113
+ return pin;
114
+ }
115
+
116
+ function clearAuth() {
117
+ try { fs.unlinkSync(getAuthPath()); } catch (_err) { /* fine if absent */ }
118
+ }
119
+
120
+ function clearNoDeferPin() {
121
+ try { fs.unlinkSync(getNoDeferPinPath()); } catch (_err) { /* fine if absent */ }
122
+ }
123
+
124
+ function writeAuth({ scope = 'all', source = 'unspecified', grantedBy = 'user-prompt', ttlSec, config } = {}) {
125
+ try {
126
+ const ttl = Number.isFinite(ttlSec) ? ttlSec : getAuthTtlSeconds(config);
127
+ const now = Date.now();
128
+ const payload = {
129
+ version: 1,
130
+ grantedAt: new Date(now).toISOString(),
131
+ expiresAt: new Date(now + ttl * 1000).toISOString(),
132
+ scope,
133
+ grantedBy,
134
+ source: typeof source === 'string' ? source.slice(0, 1000) : 'unspecified'
135
+ };
136
+ fs.mkdirSync(path.dirname(getAuthPath()), { recursive: true });
137
+ const tmp = `${getAuthPath()}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
138
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
139
+ fs.renameSync(tmp, getAuthPath());
140
+ return payload;
141
+ } catch (err) {
142
+ if (process.env.DEBUG) console.error(`[deferral-gate] writeAuth failed: ${err.message}`);
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function writeNoDeferPin({ source = 'unspecified', ttlSec = 1800 } = {}) {
148
+ try {
149
+ const now = Date.now();
150
+ const payload = {
151
+ version: 1,
152
+ pinnedAt: new Date(now).toISOString(),
153
+ expiresAt: new Date(now + ttlSec * 1000).toISOString(),
154
+ source: typeof source === 'string' ? source.slice(0, 1000) : 'unspecified'
155
+ };
156
+ fs.mkdirSync(path.dirname(getNoDeferPinPath()), { recursive: true });
157
+ const tmp = `${getNoDeferPinPath()}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
158
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
159
+ fs.renameSync(tmp, getNoDeferPinPath());
160
+ // Clear any auth — negative intent overrides positive
161
+ clearAuth();
162
+ return payload;
163
+ } catch (err) {
164
+ if (process.env.DEBUG) console.error(`[deferral-gate] writeNoDeferPin failed: ${err.message}`);
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Authorization check — given a list of finding IDs being deferred, does the
171
+ * current auth marker cover ALL of them?
172
+ *
173
+ * @param {Array<{id: string}>} deferralChanges
174
+ * @returns {{ authorized: boolean, reason: string }}
175
+ */
176
+ function isAuthorized(deferralChanges) {
177
+ // No-defer pin overrides everything
178
+ const pin = loadNoDeferPin();
179
+ if (pin) {
180
+ return { authorized: false, reason: `no-defer-pin active (pinned at ${pin.pinnedAt}, source: ${pin.source})` };
181
+ }
182
+
183
+ const auth = loadAuth();
184
+ if (!auth) return { authorized: false, reason: 'no-auth-marker' };
185
+ if (auth.scope === 'all') return { authorized: true, reason: 'auth-scope-all' };
186
+ if (Array.isArray(auth.scope)) {
187
+ const authedSet = new Set(auth.scope);
188
+ const uncovered = deferralChanges.filter(c => !authedSet.has(c.id)).map(c => c.id);
189
+ if (uncovered.length === 0) return { authorized: true, reason: 'auth-covers-all-findings' };
190
+ return { authorized: false, reason: `auth-missing-findings: ${uncovered.join(', ')}` };
191
+ }
192
+ return { authorized: false, reason: 'auth-malformed-scope' };
193
+ }
194
+
195
+ function consumeAuth(deferralChanges) {
196
+ // Auth is single-use: once a deferral write succeeds, the marker is removed
197
+ // to prevent reuse on subsequent unrelated deferrals.
198
+ clearAuth();
199
+ }
200
+
201
+ function logBlock({ filePath, changes, reason }) {
202
+ try {
203
+ const logPath = getBlockLogPath();
204
+ const existing = safeJsonParse(logPath, { entries: [] });
205
+ if (!Array.isArray(existing.entries)) existing.entries = [];
206
+ existing.entries.push({
207
+ blockedAt: new Date().toISOString(),
208
+ filePath,
209
+ findingIds: changes.map(c => c.id),
210
+ reason
211
+ });
212
+ // Keep only last 100 entries
213
+ if (existing.entries.length > 100) {
214
+ existing.entries = existing.entries.slice(-100);
215
+ }
216
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
217
+ fs.writeFileSync(logPath, JSON.stringify(existing, null, 2));
218
+ } catch (_err) { /* best effort */ }
219
+ }
220
+
221
+ function isTargetFile(filePath) {
222
+ if (!filePath || typeof filePath !== 'string') return false;
223
+ const base = path.basename(filePath);
224
+ return TARGET_BASENAMES.has(base);
225
+ }
226
+
227
+ /**
228
+ * Validate a Write/Edit operation against the deferral gate.
229
+ *
230
+ * @param {string} filePath - path being written/edited
231
+ * @param {string|Object} newContentRaw - new file content (string from Write/Edit, or pre-parsed object)
232
+ * @param {Object} config
233
+ * @returns {{ blocked: boolean, message?: string }}
234
+ */
235
+ function checkWriteGate(filePath, newContentRaw, config) {
236
+ try {
237
+ if (!isGateEnabled(config)) return { blocked: false };
238
+ if (!isTargetFile(filePath)) return { blocked: false };
239
+
240
+ let newContent;
241
+ if (typeof newContentRaw === 'string') {
242
+ try { newContent = JSON.parse(newContentRaw); } catch (_err) { return { blocked: false }; }
243
+ } else if (newContentRaw && typeof newContentRaw === 'object') {
244
+ newContent = newContentRaw;
245
+ } else {
246
+ return { blocked: false };
247
+ }
248
+
249
+ // Load prior content from disk
250
+ const prevContent = fs.existsSync(filePath) ? safeJsonParse(filePath, null) : null;
251
+
252
+ const changes = detectDeferralChanges(prevContent, newContent);
253
+ if (changes.length === 0) return { blocked: false };
254
+
255
+ const authResult = isAuthorized(changes);
256
+ if (authResult.authorized) {
257
+ consumeAuth(changes);
258
+ return { blocked: false };
259
+ }
260
+
261
+ logBlock({ filePath, changes, reason: authResult.reason });
262
+ return {
263
+ blocked: true,
264
+ message: buildBlockMessage(filePath, changes, authResult.reason),
265
+ deferralCount: changes.length
266
+ };
267
+ } catch (err) {
268
+ if (process.env.DEBUG) console.error(`[deferral-gate] checkWriteGate error (fail-open): ${err.message}`);
269
+ return { blocked: false };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Validate a Bash command against the deferral gate. Two-stage:
275
+ * Stage 1: does the command mention any target file basename?
276
+ * Stage 2: does the command also mention `deferred` literal substring?
277
+ * If both → fail SAFE (block) unless we can parse the content and prove auth.
278
+ *
279
+ * For v1 we don't deep-parse the bash command; we conservatively block any
280
+ * Bash that touches a target file AND contains a deferral status literal,
281
+ * pointing the AI at the Write/Edit path (which can be properly inspected).
282
+ */
283
+ function checkBashGate(command, config) {
284
+ try {
285
+ if (!isGateEnabled(config)) return { blocked: false };
286
+ if (typeof command !== 'string' || !command) return { blocked: false };
287
+
288
+ const mentionsTarget = /last-(review|audit)\.json/.test(command);
289
+ if (!mentionsTarget) return { blocked: false };
290
+
291
+ // Heuristic: only block when the command appears to MUTATE the file
292
+ // (writeFileSync, redirection >, sed -i, etc.). Pure reads (cat, jq, grep)
293
+ // are allowed.
294
+ const mutates = /(?:writeFileSync|>\s*[^&|]|>>\s*[^&|]|sed\s+-i|tee\s+|fs\.write|rename(?:Sync)?)/.test(command);
295
+ if (!mutates) return { blocked: false };
296
+
297
+ const mentionsDeferral = /\bdeferred[-_a-zA-Z0-9]*\b|"status"\s*:\s*"(deferred|wont-?fix|skipped|dismissed)/i.test(command);
298
+ if (!mentionsDeferral) return { blocked: false };
299
+
300
+ // We can't easily extract and validate the new content from arbitrary bash.
301
+ // Check auth: if the user has authorized deferrals, allow. Otherwise block.
302
+ const authResult = isAuthorized([{ id: 'unspecified' }]);
303
+ if (authResult.authorized) return { blocked: false };
304
+
305
+ logBlock({ filePath: '(bash)', changes: [{ id: 'unparsed-bash' }], reason: `bash-mutates-target-without-auth: ${authResult.reason}` });
306
+ return {
307
+ blocked: true,
308
+ message:
309
+ `Deferral-gate: this Bash command writes to last-review.json or last-audit.json AND ` +
310
+ `contains a deferral status literal, but no deferral authorization is active.\n\n` +
311
+ `Reason: ${authResult.reason}\n\n` +
312
+ `Options:\n` +
313
+ ` 1. Mark findings as 'fixed' instead of 'deferred' (after actually fixing them).\n` +
314
+ ` 2. Get explicit user authorization. If the user has just told you to defer, run:\n` +
315
+ ` node scripts/flow-defer-auth.js grant --scope=all --reason="<user phrase>"\n` +
316
+ ` 3. Use the Write tool with structured JSON content — that path is properly validated\n` +
317
+ ` and will allow the write if status changes are not deferrals.\n\n` +
318
+ `Reminder: CLAUDE.md "Review-Findings Anti-Deferral" — the user decides what to defer, not you.`
319
+ };
320
+ } catch (err) {
321
+ if (process.env.DEBUG) console.error(`[deferral-gate] checkBashGate error (fail-open): ${err.message}`);
322
+ return { blocked: false };
323
+ }
324
+ }
325
+
326
+ function buildBlockMessage(filePath, changes, reason) {
327
+ const findingList = changes.map(c => ` - ${c.id}: ${c.prevStatus || '(new)'} → ${c.newStatus}`).join('\n');
328
+ return (
329
+ `Deferral-gate BLOCKED: write to ${path.basename(filePath)} introduces ${changes.length} ` +
330
+ `deferral${changes.length === 1 ? '' : 's'} without authorization.\n\n` +
331
+ `Findings being deferred:\n${findingList}\n\n` +
332
+ `Reason: ${reason}\n\n` +
333
+ `CLAUDE.md "Review-Findings Anti-Deferral": "Never silently convert a finding to ` +
334
+ `'deferred' without the user explicitly saying 'defer X.'"\n\n` +
335
+ `Options:\n` +
336
+ ` 1. Fix the findings instead — mark status: 'fixed' after actually fixing them.\n` +
337
+ ` 2. Ask the user explicitly: "Finding X requires ~Y min. Ship / fix / defer? Your call."\n` +
338
+ ` 3. If the user already authorized deferrals (e.g., picked option 4 in /wogi-review),\n` +
339
+ ` record that explicitly:\n` +
340
+ ` node scripts/flow-defer-auth.js grant --scope=all --reason="<verbatim user phrase>"\n` +
341
+ ` OR for specific findings:\n` +
342
+ ` node scripts/flow-defer-auth.js grant --findings=F5,F6 --reason="..."\n\n` +
343
+ `If a 'no-defer-pin' is active, the user has explicitly forbidden deferrals — fix the ` +
344
+ `findings or surface them as user-decision items.`
345
+ );
346
+ }
347
+
348
+ module.exports = {
349
+ // Core checks
350
+ checkWriteGate,
351
+ checkBashGate,
352
+
353
+ // Auth API (used by classifier + CLI helper)
354
+ loadAuth,
355
+ loadNoDeferPin,
356
+ writeAuth,
357
+ writeNoDeferPin,
358
+ clearAuth,
359
+ clearNoDeferPin,
360
+ consumeAuth,
361
+ isAuthorized,
362
+
363
+ // Detection helpers
364
+ detectDeferralChanges,
365
+ isDeferralStatus,
366
+ isTargetFile,
367
+
368
+ // Diagnostics
369
+ getAuthPath,
370
+ getNoDeferPinPath,
371
+ getBlockLogPath,
372
+ isGateEnabled,
373
+ getAuthTtlSeconds,
374
+
375
+ // Constants
376
+ TARGET_BASENAMES,
377
+ DEFAULT_TTL_SECONDS,
378
+ DEFERRAL_STATUS_RX
379
+ };