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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +37 -2
- package/config/gate-templates.json +72 -0
- package/config/gates/default.json +13 -0
- package/config/merge-quality-checks.json +4 -1
- package/config/model-candidates.json +75 -0
- package/package.json +27 -11
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/auto-promote-gates.js +178 -27
- package/scripts/cli-progress.js +111 -0
- package/scripts/dashboard-limits.js +27 -0
- package/scripts/dashboard.js +57 -6
- package/scripts/gate-stats.js +2 -2
- package/scripts/gates-engine.js +58 -4
- package/server.json +2 -2
- package/src/api/server.js +80 -34
package/public/numbers.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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,
|
|
213
|
-
//
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
:
|
|
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 =
|
|
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 =
|
|
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(
|
|
347
|
-
trigger: `auto:${
|
|
348
|
-
// Derived from
|
|
349
|
-
pattern
|
|
432
|
+
id: patternToGateId(String(g.key || 'unknown')),
|
|
433
|
+
trigger: `auto:${g.key || 'unknown'}`,
|
|
434
|
+
// Derived from executable action OR known surface class — never raw prose.
|
|
435
|
+
pattern,
|
|
350
436
|
action,
|
|
351
437
|
message: suggestedMessage,
|
|
352
438
|
severity,
|
|
353
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
};
|
package/scripts/dashboard.js
CHANGED
|
@@ -97,13 +97,61 @@ function buildUnavailableOrgDashboard(windowHours) {
|
|
|
97
97
|
// Data readers
|
|
98
98
|
// ---------------------------------------------------------------------------
|
|
99
99
|
|
|
100
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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) {
|
package/scripts/gate-stats.js
CHANGED
|
@@ -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.
|
package/scripts/gates-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2354
|
-
//
|
|
2355
|
-
|
|
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,
|