thumbgate 1.29.2 → 1.30.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.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/adapters/claude/.mcp.json +2 -2
  4. package/adapters/forge/forge.yaml +3 -3
  5. package/adapters/mcp/server-stdio.js +78 -7
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +7 -5
  8. package/config/mcp-allowlists.json +26 -2
  9. package/config/post-deploy-marketing-pages.json +26 -1
  10. package/package.json +38 -7
  11. package/public/architecture.html +130 -0
  12. package/public/assets/diagrams/agent-integration.png +0 -0
  13. package/public/assets/diagrams/before-after.svg +21 -0
  14. package/public/assets/diagrams/decision.svg +36 -0
  15. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  16. package/public/assets/diagrams/loop.svg +34 -0
  17. package/public/assets/diagrams/plugin-topology.png +0 -0
  18. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  19. package/public/assets/diagrams/stack.svg +18 -0
  20. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  21. package/public/case-studies.html +151 -0
  22. package/public/eval-scorecard.html +195 -0
  23. package/public/eval-scorecard.json +18 -0
  24. package/public/evaluations.html +168 -0
  25. package/public/index.html +4 -3
  26. package/public/numbers.html +2 -2
  27. package/public/whitepaper.html +189 -0
  28. package/scripts/activation-quickstart.js +1 -0
  29. package/scripts/agent-outcome-monitor.js +71 -1
  30. package/scripts/billing.js +3 -1
  31. package/scripts/claude-feedback-sync.js +3 -2
  32. package/scripts/cli-feedback.js +13 -7
  33. package/scripts/cross-encoder-reranker.js +3 -0
  34. package/scripts/feedback-aggregate.js +5 -2
  35. package/scripts/feedback-loop.js +244 -182
  36. package/scripts/gates-engine.js +81 -4
  37. package/scripts/generate-case-study-outreach.js +253 -0
  38. package/scripts/generate-eval-scorecard.js +276 -0
  39. package/scripts/growth-campaigns.js +183 -0
  40. package/scripts/jsonl-watcher.js +1 -0
  41. package/scripts/lesson-inference.js +23 -4
  42. package/scripts/lesson-retrieval.js +71 -4
  43. package/scripts/lesson-search.js +26 -3
  44. package/scripts/mcp-config.js +26 -5
  45. package/scripts/mcp-oauth.js +37 -2
  46. package/scripts/model-eval.js +308 -0
  47. package/scripts/parallel-workflow-orchestrator.js +86 -22
  48. package/scripts/published-cli.js +11 -1
  49. package/scripts/refresh-proof-pack.js +261 -0
  50. package/scripts/risk-scorer.js +144 -15
  51. package/scripts/statusline-local-stats.js +1 -1
  52. package/scripts/thumbgate-bench.js +13 -0
  53. package/scripts/tool-kpi-tracker.js +124 -0
  54. package/scripts/tool-registry.js +49 -1
  55. package/src/api/server.js +230 -86
@@ -146,10 +146,18 @@ const SELF_PROTECT_HARD_FLOOR_GATE_IDS = new Set([
146
146
  'self-protect-env-override',
147
147
  'self-protect-hooks-disable',
148
148
  ]);
149
+ // An expired task-scope lease gets its OWN gate id so it can be exempted from the two downgrade
150
+ // paths without touching ordinary task-scope denials. Without this the fail-closed guarantee is
151
+ // cosmetic: applyEnforcementPosture turns denials into warnings by default, and applyDailyBlockCap
152
+ // does the same for capped free-tier users — so an edit under a lapsed lease would execute anyway.
153
+ // A lease that stops binding when you are busy or over quota is not a lease.
154
+ const TASK_SCOPE_LEASE_EXPIRED_GATE_ID = 'task-scope-lease-expired';
155
+
149
156
  const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
150
157
  'secret-exfiltration',
151
158
  'security-vuln-scan',
152
159
  'slopsquat-guard',
160
+ TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
153
161
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
154
162
  ]);
155
163
  // Issue #2782 (reported by Andy Martin, 2026-07-08): after the free-tier daily
@@ -161,6 +169,7 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
161
169
  // map directly to CLAUDE.md's own hard-block list and must never be subject
162
170
  // to the daily cap discount, regardless of tier or strict-mode setting.
163
171
  const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
172
+ TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
164
173
  'force-push',
165
174
  'git-reset-hard',
166
175
  'git-clean-force',
@@ -425,6 +434,25 @@ function clampTtlMs(value, fallbackMs) {
425
434
  return Math.min(Math.max(numeric, 60 * 1000), 24 * 60 * 60 * 1000);
426
435
  }
427
436
 
437
+ // Default lease when a caller asks for one without saying how long. clampTtlMs floors at 60s.
438
+ const TASK_SCOPE_LEASE_MS = 15 * 60 * 1000;
439
+
440
+ /**
441
+ * A task scope with no `expiresAt` is permanent — that is the historical contract and every
442
+ * existing scope on disk has it. Only a scope that explicitly took a lease can expire.
443
+ */
444
+ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
445
+ if (!taskScope || typeof taskScope !== 'object') return false;
446
+ // `expiresAt: null` means permanent, and it MUST be checked before the numeric coercion:
447
+ // Number(null) is 0, not NaN, so a null deadline would otherwise read as "expired in 1970".
448
+ // Combined with fail-closed enforcement that would revoke authority from every permanent
449
+ // scope the moment this shipped. Caught by tests/task-scope-lease.test.js.
450
+ if (taskScope.expiresAt == null) return false;
451
+ const deadline = Number(taskScope.expiresAt);
452
+ if (!Number.isFinite(deadline)) return false;
453
+ return nowMs >= deadline;
454
+ }
455
+
428
456
  function loadGovernanceState() {
429
457
  const raw = loadJSON(module.exports.GOVERNANCE_STATE_PATH);
430
458
  const state = {
@@ -438,6 +466,12 @@ function loadGovernanceState() {
438
466
  : null,
439
467
  };
440
468
  const now = Date.now();
469
+ // Annotate rather than delete. A vanished scope is indistinguishable from one never set, and
470
+ // the difference matters: "your lease lapsed, renew it" is a different instruction from
471
+ // "you never declared a scope".
472
+ if (state.taskScope) {
473
+ state.taskScope = { ...state.taskScope, expired: isTaskScopeExpired(state.taskScope, now) };
474
+ }
441
475
  const activeApprovals = state.protectedApprovals.filter((entry) => {
442
476
  if (!entry || typeof entry !== 'object') return false;
443
477
  if (!entry.timestamp || !entry.expiresAt) return false;
@@ -485,6 +519,11 @@ function setTaskScope(scopeInput = {}) {
485
519
  ? scopeInput.protectedPaths
486
520
  : DEFAULT_PROTECTED_FILE_GLOBS
487
521
  ), repoPath);
522
+ // Optional LEASE. Without ttlMs the scope is permanent, which is the historical behaviour
523
+ // and stays byte-identical. With ttlMs the scope becomes time-bounded authority: "write under
524
+ // ./src for 90 seconds" rather than a standing approval that never says when it stops.
525
+ const scopeNow = Date.now();
526
+ const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
488
527
  const taskScope = {
489
528
  taskId: String(scopeInput.taskId || '').trim() || null,
490
529
  summary: String(scopeInput.summary || '').trim() || null,
@@ -493,7 +532,9 @@ function setTaskScope(scopeInput = {}) {
493
532
  localOnly: scopeInput.localOnly === true,
494
533
  repoPath,
495
534
  createdAt: new Date().toISOString(),
496
- timestamp: Date.now(),
535
+ timestamp: scopeNow,
536
+ leaseMs,
537
+ expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
497
538
  };
498
539
  const state = loadGovernanceState();
499
540
  state.taskScope = taskScope;
@@ -1921,8 +1962,23 @@ function formatFileList(files, limit = 5) {
1921
1962
  return `${items.slice(0, limit).join(', ')} (+${items.length - limit} more)`;
1922
1963
  }
1923
1964
 
1924
- function buildTaskScopeViolation(taskScope, affectedFiles) {
1965
+ function buildTaskScopeViolation(taskScope, affectedFiles, nowMs = Date.now()) {
1925
1966
  if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return null;
1967
+ // EXPIRY FAILS CLOSED, and that direction is the whole point.
1968
+ //
1969
+ // A task scope is a restriction, so simply dropping it on expiry would make the agent MORE
1970
+ // powerful the moment its lease ran out — expiry would remove a boundary instead of removing
1971
+ // authority. A lease has to mean the opposite: while it is live you may work in these paths,
1972
+ // and when it lapses the authority is gone until it is renewed.
1973
+ if (taskScope && isTaskScopeExpired(taskScope, nowMs)) {
1974
+ return {
1975
+ reasonCode: 'expired_task_scope',
1976
+ outsideFiles: affectedFiles.slice(),
1977
+ allowedPaths: Array.isArray(taskScope.allowedPaths) ? taskScope.allowedPaths.slice() : [],
1978
+ summary: taskScope.summary || null,
1979
+ expiresAt: taskScope.expiresAt || null,
1980
+ };
1981
+ }
1926
1982
  if (!taskScope || !Array.isArray(taskScope.allowedPaths) || taskScope.allowedPaths.length === 0) {
1927
1983
  return {
1928
1984
  reasonCode: 'missing_task_scope',
@@ -1995,6 +2051,11 @@ function buildBranchGovernanceViolation(governanceState, toolInput = {}, affecte
1995
2051
  function buildGateMessage(gate, matchDetails) {
1996
2052
  if (matchDetails && matchDetails.taskScopeViolation) {
1997
2053
  const violation = matchDetails.taskScopeViolation;
2054
+ if (violation.reasonCode === 'expired_task_scope') {
2055
+ const lapsed = violation.expiresAt ? new Date(violation.expiresAt).toISOString() : 'unknown time';
2056
+ return `The task-scope lease expired at ${lapsed}, so its authority no longer applies. `
2057
+ + `Renew it with set_task_scope (allowed paths were: ${formatFileList(violation.allowedPaths)}).`;
2058
+ }
1998
2059
  if (violation.reasonCode === 'missing_task_scope') {
1999
2060
  return `No task scope is declared for this high-risk action. Affected files: ${formatFileList(violation.outsideFiles)}.`;
2000
2061
  }
@@ -2760,7 +2821,13 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2760
2821
  });
2761
2822
 
2762
2823
  if (gate.action === 'block') {
2763
- const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
2824
+ // Expired leases report under their own gate id so neither the enforcement posture nor
2825
+ // the daily block cap can quietly turn this denial into a warning.
2826
+ const gateId = matchDetails && matchDetails.taskScopeViolation
2827
+ && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
2828
+ ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
2829
+ : gate.id;
2830
+ const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
2764
2831
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2765
2832
  const cappedResult = applyDailyBlockCap(denyResult);
2766
2833
  if (cappedResult) {
@@ -2973,7 +3040,13 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
2973
3040
  const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
2974
3041
 
2975
3042
  if (gate.action === 'block') {
2976
- const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
3043
+ // Expired leases report under their own gate id so neither the enforcement posture nor
3044
+ // the daily block cap can quietly turn this denial into a warning.
3045
+ const gateId = matchDetails && matchDetails.taskScopeViolation
3046
+ && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
3047
+ ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
3048
+ : gate.id;
3049
+ const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
2977
3050
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2978
3051
  const cappedResult = applyDailyBlockCap(denyResult);
2979
3052
  if (cappedResult) {
@@ -3968,6 +4041,10 @@ module.exports = {
3968
4041
  loadGovernanceState,
3969
4042
  saveGovernanceState,
3970
4043
  setTaskScope,
4044
+ isTaskScopeExpired,
4045
+ TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
4046
+ applyEnforcementPosture,
4047
+ buildTaskScopeViolation,
3971
4048
  setBranchGovernance,
3972
4049
  approveProtectedAction,
3973
4050
  breakGlassEmergency,
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * generate-case-study-outreach.js — buyer outreach pack from dogfood case studies.
6
+ *
7
+ * No fabricated customer logos. Packs are built from public case-study anchors
8
+ * with first-party UTMs for the cash path.
9
+ *
10
+ * node scripts/generate-case-study-outreach.js --case=sudo-evasion
11
+ * node scripts/generate-case-study-outreach.js --case=sudo-evasion --json
12
+ * node scripts/generate-case-study-outreach.js --case=sudo-evasion --write
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
19
+ const DEFAULT_OUT_DIR = path.join(PROJECT_ROOT, 'docs', 'proof', 'outreach');
20
+
21
+ const CASES = Object.freeze({
22
+ 'sudo-evasion': {
23
+ id: 'sudo-evasion',
24
+ title: 'A guardrail you could walk past with sudo',
25
+ anchor: 'sudo-evasion',
26
+ problem: 'Catastrophic PreToolUse gates matched the happy path but missed wrappers like `sudo rm -rf ~`.',
27
+ metric: '62 evasion holes → 0 on the published npm artifact',
28
+ result: 'Canonicalization + an adversarial grid (14 commands × 9 transforms) closed the class; CI + 6-hourly published-artifact jobs keep it closed.',
29
+ buyerPain: 'Your coding agent can re-spell a blocked command and walk past a regex denylist.',
30
+ ctaPrimary: 'diagnostic',
31
+ proofLinks: {
32
+ caseStudyPath: '/case-studies#sudo-evasion',
33
+ scorecardPath: '/eval-scorecard',
34
+ whitepaperPath: '/whitepaper',
35
+ diagnosticPath: '/diagnostic',
36
+ proPath: '/checkout/pro',
37
+ },
38
+ },
39
+ 'fail-open': {
40
+ id: 'fail-open',
41
+ title: 'Production failure: a firewall enforcing nothing',
42
+ anchor: 'fail-open',
43
+ problem: 'A missing PreToolUse hook binary fails open — the product looked fine while blocking nothing.',
44
+ metric: 'Silent-gate canary + published-artifact deny checks',
45
+ result: 'Enforcement restored and verified with known-dangerous commands; silence is now treated as a P0 class.',
46
+ buyerPain: 'Green uptime does not mean your agent firewall is still firing.',
47
+ ctaPrimary: 'diagnostic',
48
+ proofLinks: {
49
+ caseStudyPath: '/case-studies#fail-open',
50
+ scorecardPath: '/eval-scorecard',
51
+ whitepaperPath: '/whitepaper',
52
+ diagnosticPath: '/diagnostic',
53
+ proPath: '/checkout/pro',
54
+ },
55
+ },
56
+ });
57
+
58
+ function parseArgs(argv = process.argv.slice(2)) {
59
+ const args = {
60
+ caseId: 'sudo-evasion',
61
+ json: false,
62
+ write: false,
63
+ help: false,
64
+ outDir: DEFAULT_OUT_DIR,
65
+ baseUrl: 'https://thumbgate.ai',
66
+ };
67
+ for (const arg of argv) {
68
+ if (arg === '--json') args.json = true;
69
+ else if (arg === '--write') args.write = true;
70
+ else if (arg === '--help' || arg === '-h') args.help = true;
71
+ else if (arg.startsWith('--case=')) args.caseId = arg.slice('--case='.length);
72
+ else if (arg.startsWith('--out-dir=')) args.outDir = path.resolve(arg.slice('--out-dir='.length));
73
+ else if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length).replace(/\/$/, '');
74
+ }
75
+ return args;
76
+ }
77
+
78
+ function printHelp() {
79
+ console.log(`Usage: node scripts/generate-case-study-outreach.js --case=<id> [--write] [--json]
80
+
81
+ Cases: ${Object.keys(CASES).join(', ')}
82
+ `);
83
+ }
84
+
85
+ function withUtm(baseUrl, pathAndHash, campaign, content) {
86
+ const [pathname, hash = ''] = pathAndHash.split('#');
87
+ const url = new URL(pathname, baseUrl);
88
+ url.searchParams.set('utm_source', 'case_study_outreach');
89
+ url.searchParams.set('utm_medium', content);
90
+ url.searchParams.set('utm_campaign', campaign);
91
+ url.searchParams.set('cta_id', `${campaign}_${content}`);
92
+ const hashPart = hash ? `#${hash}` : '';
93
+ return `${url.toString()}${hashPart}`;
94
+ }
95
+
96
+ function buildPack(caseDef, options = {}) {
97
+ const baseUrl = options.baseUrl || 'https://thumbgate.ai';
98
+ const campaign = `case_${caseDef.id.replace(/-/g, '_')}`;
99
+ const links = {
100
+ caseStudy: withUtm(baseUrl, caseDef.proofLinks.caseStudyPath, campaign, 'case_study'),
101
+ scorecard: withUtm(baseUrl, caseDef.proofLinks.scorecardPath, campaign, 'scorecard'),
102
+ whitepaper: withUtm(baseUrl, caseDef.proofLinks.whitepaperPath, campaign, 'whitepaper'),
103
+ diagnostic: withUtm(baseUrl, caseDef.proofLinks.diagnosticPath, campaign, 'diagnostic'),
104
+ pro: withUtm(baseUrl, `${caseDef.proofLinks.proPath}`, campaign, 'pro'),
105
+ };
106
+
107
+ const linkedin = [
108
+ caseDef.buyerPain,
109
+ '',
110
+ `We dogfooded this on ThumbGate itself: ${caseDef.metric}.`,
111
+ caseDef.result,
112
+ '',
113
+ `Full write-up (no fabricated logos): ${links.caseStudy}`,
114
+ `Live bench scorecard: ${links.scorecard}`,
115
+ '',
116
+ `If one repeated AI-agent failure is already costing you, the $499 Diagnostic installs one hard gate with regression proof: ${links.diagnostic}`,
117
+ ].join('\n');
118
+
119
+ const email = [
120
+ `Subject: Your agent can walk past a regex denylist`,
121
+ '',
122
+ `Hi —`,
123
+ '',
124
+ caseDef.buyerPain,
125
+ '',
126
+ `Concrete proof from our own product loop (not a customer logo page):`,
127
+ `- ${caseDef.metric}`,
128
+ `- ${caseDef.result}`,
129
+ '',
130
+ `Case study: ${links.caseStudy}`,
131
+ `Scorecard: ${links.scorecard}`,
132
+ `White paper: ${links.whitepaper}`,
133
+ '',
134
+ `If you want this on one painful workflow this week: ${links.diagnostic}`,
135
+ `Self-serve Pro: ${links.pro}`,
136
+ '',
137
+ `— Igor`,
138
+ ].join('\n');
139
+
140
+ const reddit = [
141
+ `**Problem:** ${caseDef.problem}`,
142
+ '',
143
+ `**What we measured:** ${caseDef.metric}`,
144
+ '',
145
+ `**What fixed it:** ${caseDef.result}`,
146
+ '',
147
+ `Public case study (dogfood, not a fake logo wall): ${links.caseStudy}`,
148
+ `Bench scorecard: ${links.scorecard}`,
149
+ ].join('\n');
150
+
151
+ const markdown = [
152
+ `# Outreach pack — ${caseDef.title}`,
153
+ '',
154
+ `Case id: \`${caseDef.id}\``,
155
+ '',
156
+ '## Links (tracked)',
157
+ '',
158
+ `- Case study: ${links.caseStudy}`,
159
+ `- Scorecard: ${links.scorecard}`,
160
+ `- White paper: ${links.whitepaper}`,
161
+ `- Diagnostic $499: ${links.diagnostic}`,
162
+ `- Pro: ${links.pro}`,
163
+ '',
164
+ '## LinkedIn',
165
+ '',
166
+ linkedin,
167
+ '',
168
+ '## Email',
169
+ '',
170
+ '```',
171
+ email,
172
+ '```',
173
+ '',
174
+ '## Reddit / forum',
175
+ '',
176
+ reddit,
177
+ '',
178
+ '## Honesty',
179
+ '',
180
+ 'First-party dogfood narrative only. Do not imply third-party customer endorsement.',
181
+ '',
182
+ ].join('\n');
183
+
184
+ return {
185
+ caseId: caseDef.id,
186
+ title: caseDef.title,
187
+ links,
188
+ channels: {
189
+ linkedin,
190
+ email,
191
+ reddit,
192
+ },
193
+ markdown,
194
+ };
195
+ }
196
+
197
+ function generate(options = {}) {
198
+ const caseId = options.caseId || 'sudo-evasion';
199
+ const caseDef = CASES[caseId];
200
+ if (!caseDef) {
201
+ throw new Error(`Unknown case id: ${caseId}. Known: ${Object.keys(CASES).join(', ')}`);
202
+ }
203
+ const pack = buildPack(caseDef, { baseUrl: options.baseUrl });
204
+ let outPath = null;
205
+ if (options.write) {
206
+ const outDir = options.outDir || DEFAULT_OUT_DIR;
207
+ fs.mkdirSync(outDir, { recursive: true });
208
+ outPath = path.join(outDir, `case-study-outreach-${caseId}.md`);
209
+ fs.writeFileSync(outPath, pack.markdown, 'utf8');
210
+ }
211
+ return { ...pack, outPath };
212
+ }
213
+
214
+ function main(argv = process.argv.slice(2)) {
215
+ const args = parseArgs(argv);
216
+ if (args.help) {
217
+ printHelp();
218
+ return 0;
219
+ }
220
+ const result = generate(args);
221
+ if (args.json) {
222
+ console.log(JSON.stringify({
223
+ caseId: result.caseId,
224
+ title: result.title,
225
+ links: result.links,
226
+ channels: result.channels,
227
+ outPath: result.outPath,
228
+ }, null, 2));
229
+ } else if (result.outPath) {
230
+ console.log(`Wrote ${result.outPath}`);
231
+ } else {
232
+ console.log(result.markdown);
233
+ }
234
+ return 0;
235
+ }
236
+
237
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
238
+ try {
239
+ process.exitCode = main();
240
+ } catch (err) {
241
+ console.error(err.message || err);
242
+ process.exitCode = 1;
243
+ }
244
+ }
245
+
246
+ module.exports = {
247
+ CASES,
248
+ parseArgs,
249
+ withUtm,
250
+ buildPack,
251
+ generate,
252
+ main,
253
+ };