thumbgate 1.34.2 → 1.35.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.
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.34.2",
28
+ "softwareVersion": "1.35.0",
29
29
  "url": "https://thumbgate-production.up.railway.app/numbers",
30
30
  "dateModified": "2026-08-03",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-08-03 · Version 1.34.2</div>
205
+ <div class="freshness">Updated: 2026-08-03 · Version 1.35.0</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -206,13 +206,31 @@ function extractExecutableAction(entry) {
206
206
  const ctx = String(entry.context || entry.whatWentWrong || '').trim();
207
207
  if (ctx.length < 4) return null;
208
208
 
209
+ // Strong signal: known tool prefixes — accept immediately.
210
+ const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
211
+ if (known && ctx.length <= 240) return ctx;
212
+
213
+ // Reject agent-narration prose before the weak "looks like a command" heuristic.
214
+ // "Agent (grok) auto-sent …" was previously accepted because it starts with a
215
+ // single token + spaces, then became an inert force-gate pattern (AGENT-259).
216
+ if (/^(?:agent|the|user|ceo|claude|grok|codex|gemini|assistant|operator)\b/i.test(ctx)) {
217
+ return null;
218
+ }
219
+ if (/\b(?:without\s+(?:human\s+)?review|thumbs?-?down|auto-sent|always-approve)\b/i.test(ctx)) {
220
+ return null;
221
+ }
222
+ // Parenthetical asides ("Agent (grok, always-approve)") are prose, not argv.
223
+ if (/\([^)]{0,40}\b(?:grok|claude|codex|always-approve|agent)\b/i.test(ctx)) {
224
+ return null;
225
+ }
226
+
209
227
  // Looks like a shell / CLI invocation (not free-form prose).
210
228
  const looksExecutable = /^(?:sudo\s+)?(?:~\/|\.\/|\/)?(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+(?:\s|$)/.test(ctx)
211
229
  && /\s|^[a-z0-9._+-]+(?:\s|$)/i.test(ctx)
212
- && !/\s+(?:broke|failed|wrong|should|never|please|the agent)\b/i.test(ctx.slice(0, 80));
213
- // Strong signal: known tool prefixes
214
- const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
215
- if (known || (looksExecutable && /[\s-]/.test(ctx) && ctx.length <= 200)) {
230
+ && !/\s+(?:broke|failed|wrong|should|never|please|the agent|auto-sent|emailed)\b/i.test(ctx.slice(0, 100));
231
+ // Weak path: require a flag or path-like token so "Agent wrote a bad summary" fails.
232
+ const hasCliShape = /(?:\s-{1,2}[A-Za-z][\w-]*|\/[A-Za-z0-9._-]{2,}|\.(?:js|ts|py|sh|json|yml|yaml)\b)/.test(ctx);
233
+ if (looksExecutable && hasCliShape && ctx.length <= 200) {
216
234
  return ctx;
217
235
  }
218
236
  return null;
@@ -307,6 +325,64 @@ function contextToPattern(context) {
307
325
  return raw.slice(0, 120).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
326
  }
309
327
 
328
+ /**
329
+ * Known incident classes that appear as English prose in force-gate / feedback
330
+ * but map to a deterministic tool-call surface. Without this, force-promote of
331
+ * "agent emailed X without review" stores the sentence as a regex and never
332
+ * matches any tool call (AGENT-259, June 2026 + Aug 2026 recurrence).
333
+ */
334
+ function deriveSurfacePattern(context) {
335
+ const text = String(context || '');
336
+ if (!text.trim()) return null;
337
+
338
+ const emailish = /\b(gmail|e-?mail|smtp|sendmail|msmtp|mailx|nodemailer|smtplib|messages\/send|send_message|send_draft|send-mail|outbound\s+email|cold\s+outreach)\b/i.test(text);
339
+ const sendish = /\b(send|sent|sending|emailed|mail(ed)?)\b/i.test(text);
340
+ if (emailish && sendish) {
341
+ // Mirrors config/gates/default.json outbound-email-send + the live
342
+ // ~/.thumbgate/bin/outbound-email-guard.js surface set.
343
+ return '(?:(?:^|[_.])send[_-]?(?:message|mail|email|now|draft)s?\\b|emailer[_-]?messages?[_-]?send|users\\/[^/\\s"\']+\\/messages\\/send|\\bmessages\\/send\\b|messages\\s*\\(\\s*\\)\\s*\\.\\s*send\\s*\\(|\\bsendmail\\b|\\bmsmtp\\b|\\bsmtplib\\b|\\bnodemailer\\b)';
344
+ }
345
+
346
+ if (/\bforce[- ]?push\b|git\s+push\s+(?:-f|--force)\b/i.test(text)) {
347
+ return 'git\\s+push\\s+(--force|-f)';
348
+ }
349
+
350
+ if (/\b(rm\s+-rf\s+\/|sudo\s+rm\s+-rf)\b/i.test(text)) {
351
+ return '(?:sudo\\s+)?rm\\s+-rf\\s+/';
352
+ }
353
+
354
+ return null;
355
+ }
356
+
357
+ /**
358
+ * True when a gate pattern is essentially an English sentence — many words,
359
+ * no executable tokens — and therefore cannot fire against tool-call text.
360
+ */
361
+ function isInertProsePattern(pattern) {
362
+ if (!pattern || typeof pattern !== 'string') return true;
363
+ // Intentional multi-alternative surface matchers (email send, force-push classes).
364
+ if (/\(\?:/.test(pattern) && pattern.includes('|')) return false;
365
+ // Unescape common contextToPattern escapes so we can inspect the words.
366
+ const unescaped = pattern
367
+ .replace(/\\([.*+?^${}()|[\]\\])/g, '$1')
368
+ .replace(/\\\\/g, '\\');
369
+ const words = (unescaped.match(/[A-Za-z]{3,}/g) || []);
370
+ if (words.length < 6) return false;
371
+ // Short CLI / known surface tokens only — a long English sentence that merely
372
+ // *mentions* "gmail" is still inert (it will never equal a tool-call string).
373
+ const trimmed = unescaped.trim();
374
+ const looksLikeCli =
375
+ /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|bash|sh|zsh)\b/i.test(trimmed)
376
+ && trimmed.length <= 160;
377
+ const looksLikeSurfaceToken =
378
+ words.length <= 8
379
+ && /(?:send_message|send_draft|messages\/send|sendmail|force-push|git\s+push\s+(?:-f|--force))/i.test(trimmed);
380
+ if (looksLikeCli || looksLikeSurfaceToken) return false;
381
+ return true;
382
+ }
383
+
384
+
385
+
310
386
  /**
311
387
  * A gate that cannot match the very context that produced it is inert — it
312
388
  * shows up in the dashboard as an active blocking rule while enforcing nothing.
@@ -322,39 +398,51 @@ function gateMatchesOwnContext(gate, context) {
322
398
  }
323
399
 
324
400
  function buildGateRule(group, actionOverride) {
325
- const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
401
+ // Tests and callers may pass partial groups; never crash on missing fields.
402
+ const g = group && typeof group === 'object' ? group : {};
403
+ const action = actionOverride || (g.count === 'MANUAL' ? g.manualAction || 'block' : (g.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
326
404
  const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
327
- const executable = (group.latestExecutable || extractExecutableAction({ context: group.latestContext }) || group.latestContext || '').slice(0, 120);
328
- const context = executable;
329
- const kind = group.key.startsWith('diagnosis:')
405
+ const fromExecutable = (g.latestExecutable
406
+ || extractExecutableAction({ context: g.latestContext })
407
+ || '').slice(0, 120);
408
+ // Prefer real executable text; else a derived surface regex (email send, force-push).
409
+ // NEVER fall back to free-form English prose — that produced permanently inert gates.
410
+ const surfacePattern = g.surfacePattern || deriveSurfacePattern(g.latestContext || g.key || '');
411
+ const pattern = fromExecutable
412
+ ? contextToPattern(fromExecutable)
413
+ : (surfacePattern || null);
414
+ const context = (fromExecutable || g.latestContext || g.key || '').slice(0, 120);
415
+ const kind = String(g.key || '').startsWith('diagnosis:')
330
416
  ? 'repeated diagnosis'
331
- : group.key.startsWith('constraint:')
417
+ : String(g.key || '').startsWith('constraint:')
332
418
  ? 'repeated constraint violation'
333
- : 'repeated executable action';
419
+ : (fromExecutable ? 'repeated executable action' : 'derived surface guard');
334
420
 
335
- const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
421
+ const occurrencesText = g.count === 'MANUAL' ? 'manual' : `${g.count == null ? 0 : g.count} occurrences`;
336
422
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
337
423
 
338
424
  // TTL: auto-promoted rules expire after the configured window unless
339
425
  // refreshed by a fresh fire. Manual force-promote bypasses TTL — operator
340
426
  // says "permanent" by going through the force path.
341
427
  const nowMs = Date.now();
342
- const isManual = group.count === 'MANUAL';
428
+ const isManual = g.count === 'MANUAL';
343
429
  const expiresAt = isManual ? null : new Date(nowMs + getRuleTtlMs()).toISOString();
344
430
 
345
431
  return {
346
- id: patternToGateId(group.key),
347
- trigger: `auto:${group.key}`,
348
- // Derived from the executable action, NOT from tag keyssee contextToPattern.
349
- pattern: contextToPattern(executable),
432
+ id: patternToGateId(String(g.key || 'unknown')),
433
+ trigger: `auto:${g.key || 'unknown'}`,
434
+ // Derived from executable action OR known surface classnever raw prose.
435
+ pattern,
350
436
  action,
351
437
  message: suggestedMessage,
352
438
  severity,
353
- occurrences: group.count,
439
+ // Always numeric — string 'MANUAL' concatenated into gate-stats totals (0MANUAL…).
440
+ occurrences: g.count === 'MANUAL' ? 1 : Number(g.count) || 0,
354
441
  promotedAt: new Date().toISOString(),
355
442
  expiresAt,
356
443
  lastFiredAt: null,
357
- source: group.source || 'auto-promote',
444
+ source: g.source || 'auto-promote',
445
+ ...(surfacePattern && !fromExecutable ? { surfaceDerived: true } : {}),
358
446
  };
359
447
  }
360
448
 
@@ -430,21 +518,51 @@ function recordGateFire(data, gateId, now = Date.now()) {
430
518
 
431
519
  function forcePromote(context, action = 'block') {
432
520
  if (!context) throw new Error('context is required for force-promote');
521
+ let executable = extractExecutableAction({ context });
522
+ // If extractExecutableAction returned narration that would become inert prose,
523
+ // drop it and fall through to surface derivation (email send, force-push, …).
524
+ if (executable && isInertProsePattern(contextToPattern(executable))) {
525
+ executable = null;
526
+ }
527
+ const surfacePattern = deriveSurfacePattern(context);
528
+ // Prefer a known surface class over a weak "command-shaped" string when both exist
529
+ // and the command is not a known CLI prefix — prevents dual wrong-pattern promotion.
530
+ if (executable && surfacePattern && !/^(?:sudo\s+)?(?:kubectl|git|npm|npx|curl|python|python3|node|bash|sh|zsh|rm)\b/i.test(executable)) {
531
+ executable = null;
532
+ }
533
+ if (!executable && !surfacePattern) {
534
+ throw new Error(
535
+ 'force-promote refused: context is prose without a matchable tool surface. '
536
+ + 'Pass an executable command (e.g. "git push --force") or describe a known '
537
+ + 'class (email send / Gmail messages/send, force-push, rm -rf /).',
538
+ );
539
+ }
540
+
433
541
  const data = loadAutoGates();
434
542
  const gateId = patternToGateId(context);
435
-
543
+
436
544
  // Remove existing if any
437
545
  data.gates = data.gates.filter(g => g.id !== gateId);
438
-
546
+
439
547
  const gate = buildGateRule({
440
548
  key: context,
441
549
  latestContext: context,
550
+ latestExecutable: executable || '',
551
+ surfacePattern: surfacePattern || undefined,
442
552
  count: 'MANUAL',
443
553
  manualAction: action,
444
- source: 'force-promote'
554
+ source: 'force-promote',
445
555
  });
556
+
557
+ if (!gate.pattern || isInertProsePattern(gate.pattern)) {
558
+ throw new Error(
559
+ 'force-promote refused: derived pattern is inert prose and would never fire. '
560
+ + `pattern=${JSON.stringify(gate.pattern)}`,
561
+ );
562
+ }
563
+
446
564
  data.gates.unshift(gate);
447
-
565
+
448
566
  if (data.gates.length > MAX_AUTO_GATES) {
449
567
  data.gates = data.gates.slice(0, MAX_AUTO_GATES);
450
568
  }
@@ -454,12 +572,20 @@ function forcePromote(context, action = 'block') {
454
572
  gateId,
455
573
  context,
456
574
  action,
575
+ pattern: gate.pattern,
576
+ surfaceDerived: Boolean(gate.surfaceDerived),
457
577
  promotedAt: new Date().toISOString(),
458
- source: 'force-promote'
578
+ source: 'force-promote',
459
579
  });
460
580
 
461
581
  saveAutoGates(data);
462
- return { gateId, action, totalGates: data.gates.length };
582
+ return {
583
+ gateId,
584
+ action,
585
+ pattern: gate.pattern,
586
+ surfaceDerived: Boolean(gate.surfaceDerived),
587
+ totalGates: data.gates.length,
588
+ };
463
589
  }
464
590
 
465
591
  function promote(feedbackLogPath, options) {
@@ -472,10 +598,22 @@ function promote(feedbackLogPath, options) {
472
598
  // path rather than carrying a near-stale expiresAt.
473
599
  const { data: expiredData, expired } = expireGates(loadAutoGates());
474
600
  const data = expiredData;
475
- if (expired.length > 0) {
601
+ // Drop permanently inert prose gates left by older force-promote / promotion bugs.
602
+ const inertRemoved = [];
603
+ data.gates = (data.gates || []).filter((g) => {
604
+ if (g && isInertProsePattern(g.pattern)) {
605
+ inertRemoved.push(g);
606
+ return false;
607
+ }
608
+ return true;
609
+ });
610
+ if (expired.length > 0 || inertRemoved.length > 0) {
476
611
  saveAutoGates(data);
477
612
  }
478
- const promotions = expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt }));
613
+ const promotions = [
614
+ ...expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt })),
615
+ ...inertRemoved.map((e) => ({ type: 'quarantined-inert-prose', gateId: e.id, pattern: e.pattern })),
616
+ ];
479
617
 
480
618
  for (const group of Object.values(groups)) {
481
619
  if (group.count < WARN_THRESHOLD) continue;
@@ -520,7 +658,9 @@ function promote(feedbackLogPath, options) {
520
658
  // Never persist a gate that cannot match the context that produced it. Such a
521
659
  // gate renders in the dashboard as an active blocking rule while enforcing
522
660
  // nothing, which reads as "the agent learned" when it did not.
523
- if (!gateMatchesOwnContext(gate, group.latestContext)) {
661
+ // Surface-derived patterns (email send, force-push) intentionally do NOT match
662
+ // the English prose incident text — they match the tool surface instead.
663
+ if (!gate.surfaceDerived && !gateMatchesOwnContext(gate, group.latestContext)) {
524
664
  promotions.push({
525
665
  type: 'skipped-unmatchable',
526
666
  gateId: gate.id,
@@ -529,6 +669,15 @@ function promote(feedbackLogPath, options) {
529
669
  });
530
670
  continue;
531
671
  }
672
+ if (!gate.pattern || isInertProsePattern(gate.pattern)) {
673
+ promotions.push({
674
+ type: 'skipped-inert-prose',
675
+ gateId: gate.id,
676
+ reason: 'pattern is English prose and would never match a tool call',
677
+ occurrences: group.count,
678
+ });
679
+ continue;
680
+ }
532
681
 
533
682
  // Self-Harness stage 3: before a feedback rule goes live as a hard block,
534
683
  // regression-test it against prior allowed actions. If it would have blocked
@@ -619,6 +768,8 @@ module.exports = {
619
768
  patternToGateId,
620
769
  buildGateRule,
621
770
  contextToPattern,
771
+ deriveSurfacePattern,
772
+ isInertProsePattern,
622
773
  gateMatchesOwnContext,
623
774
  regressionCheck,
624
775
  getAuditTrailPath,
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Lightweight CLI progress for long-running commands (dashboard, cfo, north-star).
5
+ * TTY: spinner on stderr. Non-TTY / THUMBGATE_NO_PROGRESS=1: plain step lines.
6
+ */
7
+
8
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
9
+
10
+ function isProgressEnabled(stream = process.stderr, env = process.env) {
11
+ if (String(env.THUMBGATE_NO_PROGRESS || '').trim() === '1') return false;
12
+ if (String(env.CI || '').trim()) return false; // avoid noisy CI logs
13
+ return Boolean(stream && stream.isTTY);
14
+ }
15
+
16
+ function createCliProgress(options = {}) {
17
+ const stream = options.stream || process.stderr;
18
+ const env = options.env || process.env;
19
+ const enabled = options.enabled !== undefined
20
+ ? Boolean(options.enabled)
21
+ : isProgressEnabled(stream, env);
22
+
23
+ let label = '';
24
+ let frame = 0;
25
+ let timer = null;
26
+ let active = false;
27
+
28
+ function clearLine() {
29
+ if (!enabled || !stream.clearLine) return;
30
+ try {
31
+ stream.clearLine(0);
32
+ stream.cursorTo(0);
33
+ } catch { /* non-TTY fallback */ }
34
+ }
35
+
36
+ function paint() {
37
+ if (!enabled || !active) return;
38
+ const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
39
+ frame += 1;
40
+ clearLine();
41
+ stream.write(`${glyph} ${label}`);
42
+ }
43
+
44
+ function start(nextLabel) {
45
+ label = String(nextLabel || 'Working…');
46
+ active = true;
47
+ if (!enabled) {
48
+ stream.write(`[thumbgate] ${label}\n`);
49
+ return;
50
+ }
51
+ paint();
52
+ if (timer) clearInterval(timer);
53
+ timer = setInterval(paint, 80);
54
+ if (typeof timer.unref === 'function') timer.unref();
55
+ }
56
+
57
+ function update(nextLabel) {
58
+ label = String(nextLabel || label);
59
+ if (!enabled) {
60
+ stream.write(`[thumbgate] ${label}\n`);
61
+ return;
62
+ }
63
+ if (!active) {
64
+ start(label);
65
+ return;
66
+ }
67
+ paint();
68
+ }
69
+
70
+ function stop(finalLabel, { ok = true } = {}) {
71
+ if (timer) {
72
+ clearInterval(timer);
73
+ timer = null;
74
+ }
75
+ const text = finalLabel != null ? String(finalLabel) : label;
76
+ if (!enabled) {
77
+ if (text) stream.write(`[thumbgate] ${text}\n`);
78
+ active = false;
79
+ return;
80
+ }
81
+ clearLine();
82
+ if (text) {
83
+ const mark = ok ? '✓' : '✗';
84
+ stream.write(`${mark} ${text}\n`);
85
+ }
86
+ active = false;
87
+ }
88
+
89
+ function succeed(finalLabel) {
90
+ stop(finalLabel, { ok: true });
91
+ }
92
+
93
+ function fail(finalLabel) {
94
+ stop(finalLabel, { ok: false });
95
+ }
96
+
97
+ return {
98
+ enabled,
99
+ start,
100
+ update,
101
+ stop,
102
+ succeed,
103
+ fail,
104
+ };
105
+ }
106
+
107
+ module.exports = {
108
+ createCliProgress,
109
+ isProgressEnabled,
110
+ SPINNER_FRAMES,
111
+ };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared helpers for dashboard size / heap failures (prod feedback logs).
5
+ */
6
+
7
+ function isDashboardDataLimitError(err) {
8
+ const message = String(err && err.message ? err.message : err || '');
9
+ return /string longer than|Cannot create a string|ENOMEM|JavaScript heap|out of memory/i.test(message);
10
+ }
11
+
12
+ function formatDashboardLimitDetail(err, { phase = 'assembly' } = {}) {
13
+ const cause = err && err.message ? err.message : 'string/heap limit';
14
+ if (phase === 'stringify') {
15
+ return 'Dashboard JSON exceeded the runtime string limit. '
16
+ + 'Use a bounded feedback window or rotate oversized logs. '
17
+ + `Cause: ${cause}`;
18
+ }
19
+ return 'Feedback/memory logs exceeded the safe in-memory limit for dashboard assembly. '
20
+ + 'Logs are now tail-capped; if this persists, rotate oversized JSONL under the feedback dir. '
21
+ + `Cause: ${cause}`;
22
+ }
23
+
24
+ module.exports = {
25
+ isDashboardDataLimitError,
26
+ formatDashboardLimitDetail,
27
+ };
@@ -97,13 +97,61 @@ function buildUnavailableOrgDashboard(windowHours) {
97
97
  // Data readers
98
98
  // ---------------------------------------------------------------------------
99
99
 
100
- function readJSONL(filePath) {
100
+ // Prod feedback/memory logs can grow past V8's max string size (~512MB–1GB).
101
+ // Full-file readFileSync/stringify then throws:
102
+ // "Cannot create a string longer than 0x1fffffe8 characters"
103
+ // Cap dashboard JSONL ingestion to a recent tail so /v1/dashboard stays live.
104
+ const DEFAULT_JSONL_MAX_BYTES = 32 * 1024 * 1024; // 32 MiB tail
105
+ const DEFAULT_JSONL_MAX_ENTRIES = 100_000;
106
+
107
+ function readTextTail(filePath, maxBytes) {
108
+ const stats = fs.statSync(filePath);
109
+ const size = stats.size || 0;
110
+ if (size <= 0) return { text: '', truncated: false, size: 0 };
111
+ if (!maxBytes || size <= maxBytes) {
112
+ return { text: fs.readFileSync(filePath, 'utf-8'), truncated: false, size };
113
+ }
114
+ const fd = fs.openSync(filePath, 'r');
115
+ try {
116
+ const buffer = Buffer.alloc(maxBytes);
117
+ fs.readSync(fd, buffer, 0, maxBytes, size - maxBytes);
118
+ let text = buffer.toString('utf-8');
119
+ const firstNewline = text.indexOf('\n');
120
+ if (firstNewline >= 0) text = text.slice(firstNewline + 1);
121
+ return { text, truncated: true, size };
122
+ } finally {
123
+ fs.closeSync(fd);
124
+ }
125
+ }
126
+
127
+ function readJSONL(filePath, options = {}) {
101
128
  if (!fs.existsSync(filePath)) return [];
102
- const raw = fs.readFileSync(filePath, 'utf-8').trim();
103
- if (!raw) return [];
104
- return raw.split('\n').map((line) => {
105
- try { return JSON.parse(line); } catch { return null; }
106
- }).filter(Boolean);
129
+ const maxBytes = Number(options.maxBytes) > 0
130
+ ? Number(options.maxBytes)
131
+ : DEFAULT_JSONL_MAX_BYTES;
132
+ const maxEntries = Number(options.maxEntries) > 0
133
+ ? Number(options.maxEntries)
134
+ : DEFAULT_JSONL_MAX_ENTRIES;
135
+ let text;
136
+ try {
137
+ text = readTextTail(filePath, maxBytes).text;
138
+ } catch (err) {
139
+ // Never let a single bloated/unreadable log take down the whole dashboard.
140
+ return [];
141
+ }
142
+ if (!text || !text.trim()) return [];
143
+ const lines = text.split('\n');
144
+ const start = Math.max(0, lines.length - maxEntries);
145
+ const entries = [];
146
+ for (let i = start; i < lines.length; i += 1) {
147
+ const line = lines[i];
148
+ if (!line) continue;
149
+ try {
150
+ const parsed = JSON.parse(line);
151
+ if (parsed) entries.push(parsed);
152
+ } catch { /* skip bad line */ }
153
+ }
154
+ return entries;
107
155
  }
108
156
 
109
157
  function readJsonFile(filePath) {
@@ -2089,8 +2137,11 @@ module.exports = {
2089
2137
  computeSecretGuardStats,
2090
2138
  computeObservabilityStats,
2091
2139
  readJSONL,
2140
+ readTextTail,
2092
2141
  readJsonFile,
2093
2142
  collectAllFeedbackEntries,
2143
+ DEFAULT_JSONL_MAX_BYTES,
2144
+ DEFAULT_JSONL_MAX_ENTRIES,
2094
2145
  };
2095
2146
 
2096
2147
  if (require.main === module) {
@@ -39,10 +39,10 @@ function calculateStats() {
39
39
  // Count total blocks/warns from occurrences in auto-promoted gates
40
40
  const totalBlocked = autoGates
41
41
  .filter((g) => g.action === 'block')
42
- .reduce((sum, g) => sum + (g.occurrences || 0), 0);
42
+ .reduce((sum, g) => sum + (Number(g.occurrences) || 0), 0);
43
43
  const totalWarned = autoGates
44
44
  .filter((g) => g.action === 'warn')
45
- .reduce((sum, g) => sum + (g.occurrences || 0), 0);
45
+ .reduce((sum, g) => sum + (Number(g.occurrences) || 0), 0);
46
46
 
47
47
  // Top blocked gate. A configured block rule with zero occurrences is not a
48
48
  // "top blocker"; only recorded block events should appear here.
@@ -168,6 +168,9 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
168
168
  // applyEnforcementPosture, never demote spend blocks to warn-by-default.
169
169
  // Apollo $588 incident class (2026-08).
170
170
  'financial-control',
171
+ // Outbound email: irreversible delivery. Must never demote to warn-by-default
172
+ // even when THUMBGATE_STRICT_ENFORCEMENT is unset (AGENT-259 / District Cyber 2026-08-04).
173
+ 'outbound-email-send',
171
174
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
172
175
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
173
176
  ]);
@@ -188,6 +191,8 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
188
191
  'git-clean-force',
189
192
  'rm-rf-home-or-root',
190
193
  'financial-control',
194
+ // Never daily-cap discount agent email send (same class as force-push).
195
+ 'outbound-email-send',
191
196
  ]);
192
197
  const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
193
198
  const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
@@ -2274,8 +2279,52 @@ function checkWhenClause(when, constraints) {
2274
2279
  return true;
2275
2280
  }
2276
2281
 
2282
+
2283
+ /**
2284
+ * Surfaces a gate pattern can match against.
2285
+ *
2286
+ * Historically only `toolInput.command|file_path|path` was considered. That
2287
+ * made every MCP / non-Bash tool call invisible to pattern gates — including
2288
+ * Gmail `send_message`, Apollo emailer send, and any auto-promoted rule that
2289
+ * mentioned a tool by name. Those gates rendered as "active" with
2290
+ * lastFiredAt:null forever (AGENT-259).
2291
+ *
2292
+ * Surfaces, in order:
2293
+ * 1. Bash/command text (preserves existing `^` anchor behavior)
2294
+ * 2. bare tool name (MCP tools)
2295
+ * 3. tool name + command
2296
+ * 4. common URL/endpoint/action fields (HTTP-shaped tool inputs)
2297
+ *
2298
+ * Body/content is intentionally excluded to avoid false blocks when an agent
2299
+ * *edits code that mentions* a send endpoint.
2300
+ */
2301
+ function buildMatchSurfaces(toolName, toolInput = {}) {
2302
+ const name = String(toolName || '').trim();
2303
+ const command = String(toolInput.command || '').trim();
2304
+ const filePath = String(toolInput.file_path || toolInput.path || '').trim();
2305
+ const surfaces = [];
2306
+ const push = (s) => {
2307
+ const v = String(s || '').trim();
2308
+ if (v && !surfaces.includes(v)) surfaces.push(v);
2309
+ };
2310
+ push(command);
2311
+ push(filePath);
2312
+ push(name);
2313
+ if (name && command) push(`${name} ${command}`);
2314
+ if (name && filePath) push(`${name} ${filePath}`);
2315
+ for (const key of ['url', 'endpoint', 'method', 'action', 'path']) {
2316
+ if (toolInput[key] == null) continue;
2317
+ const v = String(toolInput[key]).slice(0, 400);
2318
+ push(v);
2319
+ if (name) push(`${name} ${v}`);
2320
+ }
2321
+ return surfaces;
2322
+ }
2323
+
2277
2324
  function matchGate(gate, toolName, toolInput = {}) {
2278
- let matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
2325
+ // Primary text for audit/reasoning: prefer command, then tool name (MCP).
2326
+ const matchSurfaces = buildMatchSurfaces(toolName, toolInput);
2327
+ let matchText = matchSurfaces[0] || String(toolName || '');
2279
2328
 
2280
2329
  // Claw/hybrid support: enrich matchText with claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
2281
2330
  const clawCtx = toolInput.clawContext || toolInput._claw || (toolInput.agentId ? {
@@ -2309,6 +2358,7 @@ function matchGate(gate, toolName, toolInput = {}) {
2309
2358
  }
2310
2359
 
2311
2360
  matchText = parts.filter(Boolean).join(' | ');
2361
+ if (!matchSurfaces.includes(matchText)) matchSurfaces.push(matchText);
2312
2362
  }
2313
2363
 
2314
2364
  const affected = extractAffectedFiles(toolName, toolInput);
@@ -2350,9 +2400,12 @@ function matchGate(gate, toolName, toolInput = {}) {
2350
2400
  if (gate.pattern) {
2351
2401
  try {
2352
2402
  const regex = new RegExp(gate.pattern);
2353
- // Match the original text or its git-canonical form, so `git -C <dir> push --force`
2354
- // is caught by the same pattern as `git push --force`.
2355
- if (!patternMatchesCommand(regex, matchText)) {
2403
+ // Match command text, tool name, and light payload surfaces. MCP tools
2404
+ // (e.g. Gmail send_message) have no `command` field without multi-surface
2405
+ // matching, every pattern gate against them is permanently inert.
2406
+ const surfaces = matchSurfaces.length > 0 ? matchSurfaces : [matchText];
2407
+ const anySurfaceMatch = surfaces.some((surface) => patternMatchesCommand(regex, surface));
2408
+ if (!anySurfaceMatch) {
2356
2409
  return { matched: false, matchText, affectedFiles };
2357
2410
  }
2358
2411
  if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
@@ -4264,6 +4317,7 @@ module.exports = {
4264
4317
  matchesGate,
4265
4318
  evaluateGates,
4266
4319
  evaluateGatesAsync,
4320
+ buildMatchSurfaces,
4267
4321
  extractAffectedFiles,
4268
4322
  parseGitPathspec,
4269
4323
  canonicalizeGitCommand,