thumbgate 1.26.8 → 1.27.2

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 (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/agentic-verify.txt +1 -0
  4. package/.well-known/llms.txt +2 -0
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +20 -9
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  9. package/adapters/mcp/server-stdio.js +28 -1
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bench/thumbgate-bench.json +2 -2
  12. package/bin/cli.js +132 -7
  13. package/bin/dashboard-cli.js +7 -0
  14. package/config/gate-classifier-routing.json +98 -0
  15. package/config/gate-templates.json +60 -0
  16. package/config/mcp-allowlists.json +8 -7
  17. package/config/model-candidates.json +71 -6
  18. package/package.json +26 -10
  19. package/public/chatgpt-app.html +330 -0
  20. package/public/codex-plugin.html +66 -14
  21. package/public/dashboard.html +203 -17
  22. package/public/index.html +79 -4
  23. package/public/learn.html +70 -0
  24. package/public/lessons.html +129 -6
  25. package/public/numbers.html +2 -2
  26. package/public/pricing.html +20 -2
  27. package/scripts/agent-operations-planner.js +621 -0
  28. package/scripts/agent-reward-model.js +53 -1
  29. package/scripts/ai-component-inventory.js +367 -0
  30. package/scripts/classifier-routing.js +130 -0
  31. package/scripts/cli-schema.js +26 -0
  32. package/scripts/dashboard-chat.js +64 -17
  33. package/scripts/feedback-sanitizer.js +105 -0
  34. package/scripts/gates-engine.js +258 -61
  35. package/scripts/hybrid-feedback-context.js +141 -7
  36. package/scripts/memory-scope-readiness.js +159 -0
  37. package/scripts/parallel-workflow-orchestrator.js +293 -0
  38. package/scripts/plausible-domain-config.js +86 -0
  39. package/scripts/plausible-server-events.js +4 -2
  40. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  41. package/scripts/qa-scenario-planner.js +136 -0
  42. package/scripts/repeat-metric.js +28 -12
  43. package/scripts/secret-fixture-tokens.js +61 -0
  44. package/scripts/secret-scanner.js +44 -5
  45. package/scripts/security-scanner.js +80 -0
  46. package/scripts/seo-gsd.js +53 -0
  47. package/scripts/thumbgate-bench.js +16 -1
  48. package/scripts/tool-registry.js +37 -0
  49. package/scripts/workflow-sentinel.js +189 -4
  50. package/src/api/server.js +276 -10
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const PRIMARY_PLAUSIBLE_DOMAIN = 'thumbgate.ai';
4
+ const FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN = 'thumbgate-production.up.railway.app';
5
+
6
+ function splitDomains(value) {
7
+ return String(value || '')
8
+ .split(/[\s,]+/)
9
+ .map((domain) => domain.trim().toLowerCase())
10
+ .filter(Boolean);
11
+ }
12
+
13
+ function normalizeDomain(value) {
14
+ const input = String(value || '').trim();
15
+ if (!input) return '';
16
+ try {
17
+ return new URL(input.includes('://') ? input : `https://${input}`).host.toLowerCase();
18
+ } catch {
19
+ return input.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
20
+ }
21
+ }
22
+
23
+ function getConfiguredRegisteredDomains(env = process.env) {
24
+ const configured = [
25
+ ...splitDomains(env.PLAUSIBLE_SITE_ID),
26
+ ...splitDomains(env.PLAUSIBLE_SITE_IDS),
27
+ ...splitDomains(env.THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS),
28
+ ...splitDomains(env.PLAUSIBLE_REGISTERED_DOMAINS),
29
+ ].map(normalizeDomain).filter(Boolean);
30
+
31
+ return [...new Set([
32
+ FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
33
+ ...configured,
34
+ ])];
35
+ }
36
+
37
+ function isPlausibleDomainRegistered(domain, env = process.env) {
38
+ const normalized = normalizeDomain(domain);
39
+ if (!normalized) return false;
40
+ return getConfiguredRegisteredDomains(env).includes(normalized);
41
+ }
42
+
43
+ function resolvePlausibleDataDomain({ host = '', env = process.env } = {}) {
44
+ const explicit = normalizeDomain(env.THUMBGATE_PLAUSIBLE_DOMAIN);
45
+ if (explicit) return explicit;
46
+
47
+ const normalizedHost = normalizeDomain(host);
48
+ if (isPlausibleDomainRegistered(normalizedHost, env)) {
49
+ return normalizedHost;
50
+ }
51
+
52
+ return FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN;
53
+ }
54
+
55
+ function analyzePlausibleDomainCoverage({
56
+ emittedDomains = [],
57
+ registeredDomains = [],
58
+ primaryDomain = PRIMARY_PLAUSIBLE_DOMAIN,
59
+ } = {}) {
60
+ const emitted = [...new Set(emittedDomains.map(normalizeDomain).filter(Boolean))];
61
+ const registered = [...new Set(registeredDomains.map(normalizeDomain).filter(Boolean))];
62
+ const registeredSet = new Set(registered);
63
+ const missingEmittedDomains = emitted.filter((domain) => !registeredSet.has(domain));
64
+ const primaryRegistered = registeredSet.has(normalizeDomain(primaryDomain));
65
+
66
+ return {
67
+ ok: missingEmittedDomains.length === 0 && primaryRegistered,
68
+ emittedDomains: emitted,
69
+ registeredDomains: registered,
70
+ missingEmittedDomains,
71
+ primaryDomain: normalizeDomain(primaryDomain),
72
+ primaryRegistered,
73
+ severity: missingEmittedDomains.length > 0 || !primaryRegistered ? 'critical' : 'ok',
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ PRIMARY_PLAUSIBLE_DOMAIN,
79
+ FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
80
+ splitDomains,
81
+ normalizeDomain,
82
+ getConfiguredRegisteredDomains,
83
+ isPlausibleDomainRegistered,
84
+ resolvePlausibleDataDomain,
85
+ analyzePlausibleDomainCoverage,
86
+ };
@@ -24,8 +24,10 @@
24
24
  */
25
25
 
26
26
  const https = require('node:https');
27
+ const {
28
+ resolvePlausibleDataDomain,
29
+ } = require('./plausible-domain-config');
27
30
 
28
- const DEFAULT_PLAUSIBLE_DOMAIN = 'thumbgate.ai';
29
31
  const PLAUSIBLE_ENDPOINT = 'https://plausible.io/api/event';
30
32
  const REQUEST_TIMEOUT_MS = 2_000;
31
33
 
@@ -40,7 +42,7 @@ function isPlausibleDisabled() {
40
42
  }
41
43
 
42
44
  function getPlausibleDomain() {
43
- return process.env.THUMBGATE_PLAUSIBLE_DOMAIN || DEFAULT_PLAUSIBLE_DOMAIN;
45
+ return resolvePlausibleDataDomain();
44
46
  }
45
47
 
46
48
  /**
@@ -39,9 +39,23 @@ function normalizeOptions(options = {}) {
39
39
  ...splitCsv(options.documents),
40
40
  ...splitCsv(options['document-ids']),
41
41
  ]);
42
+ const sourcePointers = unique([
43
+ ...splitCsv(options['source-pointers']),
44
+ ...splitCsv(options.pointers),
45
+ ...splitCsv(options.sources),
46
+ ]);
42
47
  const candidateImages = Number.isFinite(Number(options['candidate-images']))
43
48
  ? Number(options['candidate-images'])
44
49
  : null;
50
+ const extractedEntities = Number.isFinite(Number(options['extracted-entities']))
51
+ ? Number(options['extracted-entities'])
52
+ : 0;
53
+ const extractedRelations = Number.isFinite(Number(options['extracted-relations']))
54
+ ? Number(options['extracted-relations'])
55
+ : 0;
56
+ const promotionThreshold = Number.isFinite(Number(options['promotion-threshold']))
57
+ ? Number(options['promotion-threshold'])
58
+ : 3;
45
59
 
46
60
  return {
47
61
  ragTool: String(options['rag-tool'] || options.tool || 'proxy-pointer-rag').trim() || 'proxy-pointer-rag',
@@ -49,10 +63,15 @@ function normalizeOptions(options = {}) {
49
63
  sectionIds,
50
64
  imagePointers,
51
65
  documentIds,
66
+ sourcePointers,
52
67
  candidateImages,
68
+ extractedEntities,
69
+ extractedRelations,
70
+ promotionThreshold,
53
71
  crossDocumentPolicy: String(options['cross-doc-policy'] || options['cross-document-policy'] || '').trim().toLowerCase(),
54
72
  visionFilter: normalizeBoolean(options['vision-filter']),
55
73
  visualClaims: normalizeBoolean(options['visual-claims']),
74
+ pointerFirst: normalizeBoolean(options['pointer-first']) || normalizeBoolean(options['proxy-pointer']),
56
75
  };
57
76
  }
58
77
 
@@ -72,6 +91,14 @@ function gateApplicability(template, options) {
72
91
  return false;
73
92
  }
74
93
 
94
+ function hasExtractionSprawl(options) {
95
+ const extractedFacts = options.extractedEntities + options.extractedRelations;
96
+ if (extractedFacts === 0) return false;
97
+ if (options.pointerFirst) return true;
98
+ if (options.sourcePointers.length === 0) return true;
99
+ return extractedFacts > options.sourcePointers.length * Math.max(2, options.promotionThreshold);
100
+ }
101
+
75
102
  function buildSignalSummary(options) {
76
103
  const signals = [];
77
104
  if (options.treePath || options.sectionIds.length > 0) {
@@ -110,6 +137,19 @@ function buildSignalSummary(options) {
110
137
  risk: 'answers that describe image content may need a vision-model sanity check',
111
138
  });
112
139
  }
140
+ if (hasExtractionSprawl(options)) {
141
+ signals.push({
142
+ id: 'entity_relation_sprawl',
143
+ label: 'Entity/relation extraction sprawl',
144
+ values: unique([
145
+ `${options.extractedEntities} extracted entities`,
146
+ `${options.extractedRelations} extracted relations`,
147
+ `${options.sourcePointers.length} source pointers`,
148
+ `promotion threshold ${options.promotionThreshold}`,
149
+ ]),
150
+ risk: 'eager graph extraction can create stale aliases, weak edges, and unauditable memory; keep source pointers first and promote relations only after repeated retrieval value',
151
+ });
152
+ }
113
153
  return signals;
114
154
  }
115
155
 
@@ -139,11 +179,12 @@ function buildProxyPointerRagGuardrailsPlan(rawOptions = {}, templatesPath) {
139
179
  templates: recommendedTemplates,
140
180
  nextActions: [
141
181
  'Preserve document hierarchy, section IDs, and image file paths during ingestion.',
182
+ 'Store source pointers before extracting entities or relations; promote a relation only after repeated retrieval value and source verification.',
142
183
  'Pass section-tree and image-pointer metadata into the agent before it answers with visuals.',
143
184
  'Enable the recommended Document RAG Safety templates as pre-action gates.',
144
185
  'Use a vision filter only for high-impact answers that make claims about visual content.',
145
186
  ],
146
- exampleCommand: 'npx thumbgate proxy-pointer-rag-guardrails --tree-path=.rag/tree.json --image-pointers=paper-1/figures/fig2.png --documents=paper-1 --visual-claims --json',
187
+ exampleCommand: 'npx thumbgate proxy-pointer-rag-guardrails --tree-path=.rag/tree.json --source-pointers=lesson/fb_123,tool/run_456 --extracted-entities=120 --extracted-relations=80 --pointer-first --json',
147
188
  };
148
189
  }
149
190
 
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+
6
+ const RUNTIME_PATTERNS = [
7
+ { pattern: /^public\/.*\.(html|css|js)$/i, surface: 'browser', reason: 'public UI asset changed' },
8
+ { pattern: /^src\/api\//i, surface: 'api', reason: 'API route or server behavior changed' },
9
+ { pattern: /^bin\//i, surface: 'cli', reason: 'CLI entrypoint changed' },
10
+ { pattern: /^scripts\/(dashboard|pro-local-dashboard|.*gate|.*scanner|.*reward|.*routing).*\.js$/i, surface: 'agent-runtime', reason: 'agent runtime or gate behavior changed' },
11
+ { pattern: /^adapters\//i, surface: 'agent-adapter', reason: 'agent adapter changed' },
12
+ { pattern: /^plugins\//i, surface: 'plugin', reason: 'plugin install path changed' },
13
+ { pattern: /^package\.json$/i, surface: 'package', reason: 'package manifest changed' },
14
+ ];
15
+
16
+ const SKIP_PATTERNS = [
17
+ /^README\.md$/i,
18
+ /^docs\//i,
19
+ /^reports\//i,
20
+ /^proof\//i,
21
+ /^tests\/.*\.test\.js$/i,
22
+ /^\.claude\/implementation-notes\//i,
23
+ ];
24
+
25
+ function normalizeFiles(files = []) {
26
+ return Array.from(new Set(files
27
+ .map((file) => String(file || '').trim().replace(/^\.?\//, ''))
28
+ .filter(Boolean)));
29
+ }
30
+
31
+ function classifyFile(file) {
32
+ for (const entry of RUNTIME_PATTERNS) {
33
+ if (entry.pattern.test(file)) return { ...entry, file };
34
+ }
35
+ for (const pattern of SKIP_PATTERNS) {
36
+ if (pattern.test(file)) return { surface: 'skip', reason: 'no runtime impact', file };
37
+ }
38
+ return { surface: 'focused', reason: 'unknown runtime impact; run focused checks', file };
39
+ }
40
+
41
+ function parseChangedFilesFromDiff(diff = '') {
42
+ const files = [];
43
+ for (const line of String(diff || '').split('\n')) {
44
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
45
+ if (match) files.push(match[2]);
46
+ }
47
+ return normalizeFiles(files);
48
+ }
49
+
50
+ function planQaScenario(input = {}) {
51
+ const files = normalizeFiles(input.files || parseChangedFilesFromDiff(input.diff || ''));
52
+ const classifications = files.map(classifyFile);
53
+ const surfaces = Array.from(new Set(classifications.map((entry) => entry.surface)));
54
+ const runtimeChanges = classifications.filter((entry) => entry.surface !== 'skip');
55
+ const skipOnly = files.length > 0 && runtimeChanges.length === 0;
56
+
57
+ const recommendedRunner = chooseRunner(surfaces, input);
58
+ const userScenario = buildUserScenario(runtimeChanges, input);
59
+ return {
60
+ name: 'thumbgate-user-impact-qa-scenario',
61
+ status: skipOnly ? 'skip' : 'actionable',
62
+ files,
63
+ classifications,
64
+ recommendedRunner,
65
+ userScenario,
66
+ commands: buildCommands(recommendedRunner, runtimeChanges),
67
+ regressionPolicy: skipOnly
68
+ ? 'skip durable QA; no runtime-impact files changed'
69
+ : 'if the QA agent finds a deterministic failure, convert it into a focused regression test before opening a fix PR',
70
+ transientFailurePolicy: 'doctor the browser/computer-use runner once, retry once, then label as infrastructure-flaky instead of product-regression',
71
+ };
72
+ }
73
+
74
+ function chooseRunner(surfaces, input = {}) {
75
+ if (input.forceComputerUse || surfaces.includes('plugin') || surfaces.includes('agent-adapter')) return 'computer-use-qa';
76
+ if (surfaces.includes('browser') || surfaces.includes('api')) return 'browser-qa';
77
+ if (surfaces.includes('cli') || surfaces.includes('agent-runtime') || surfaces.includes('package')) return 'focused-node-qa';
78
+ if (surfaces.every((surface) => surface === 'skip')) return 'skip';
79
+ return 'focused-node-qa';
80
+ }
81
+
82
+ function buildUserScenario(runtimeChanges, input = {}) {
83
+ if (runtimeChanges.length === 0) return 'No user-impact scenario required; changed files are docs, tests, reports, or proof artifacts only.';
84
+ const surfaces = Array.from(new Set(runtimeChanges.map((entry) => entry.surface)));
85
+ if (surfaces.includes('browser') || surfaces.includes('api')) {
86
+ return 'Open the affected page as a user, perform the primary CTA or dashboard action, verify visible state changes, then check the related API response.';
87
+ }
88
+ if (surfaces.includes('plugin') || surfaces.includes('agent-adapter')) {
89
+ return 'Install or reload the affected agent integration, run one thumbs-up and one thumbs-down capture, then verify the next risky action is gated.';
90
+ }
91
+ if (surfaces.includes('cli')) {
92
+ return 'Run the changed CLI command with --help and one realistic command path, then verify exit code, JSON output, and no stale command copy.';
93
+ }
94
+ return input.scenario || 'Run the focused test for the changed runtime surface, then verify the behavior with one realistic operator workflow.';
95
+ }
96
+
97
+ function buildCommands(runner, runtimeChanges) {
98
+ if (runner === 'skip') return [];
99
+ const commands = ['npm test -- --test-concurrency=1'];
100
+ if (runner === 'browser-qa') commands.push('npx playwright test tests/e2e --project=chromium');
101
+ if (runner === 'computer-use-qa') commands.push('node scripts/qa-scenario-planner.js --doctor-runner');
102
+ if (runtimeChanges.some((entry) => entry.surface === 'package')) commands.push('npm pack --dry-run');
103
+ return commands;
104
+ }
105
+
106
+ function parseArgs(argv = process.argv.slice(2)) {
107
+ const args = {};
108
+ for (const arg of argv) {
109
+ if (arg === '--json') args.json = true;
110
+ else if (arg === '--doctor-runner') args.doctorRunner = true;
111
+ else if (arg.startsWith('--files=')) args.files = arg.slice('--files='.length).split(',');
112
+ else if (arg.startsWith('--diff-file=')) args.diff = fs.readFileSync(arg.slice('--diff-file='.length), 'utf8');
113
+ else if (arg.startsWith('--scenario=')) args.scenario = arg.slice('--scenario='.length);
114
+ }
115
+ return args;
116
+ }
117
+
118
+ if (require.main === module) {
119
+ const args = parseArgs();
120
+ if (args.doctorRunner) {
121
+ console.log('QA runner doctor: verify browser/computer-use target, screenshot capture, and network reachability before blaming product code.');
122
+ process.exit(0);
123
+ }
124
+ const report = planQaScenario(args);
125
+ if (args.json) console.log(JSON.stringify(report, null, 2));
126
+ else {
127
+ console.log(`${report.status.toUpperCase()}: ${report.userScenario}`);
128
+ for (const command of report.commands) console.log(`- ${command}`);
129
+ }
130
+ }
131
+
132
+ module.exports = {
133
+ classifyFile,
134
+ parseChangedFilesFromDiff,
135
+ planQaScenario,
136
+ };
@@ -7,10 +7,10 @@
7
7
  // does NOT write to disk; it is a pure function over gates-engine.loadStats().
8
8
  //
9
9
  // The headline number is stats.recurringBlocks — incremented by recordStat()
10
- // in gates-engine.js every time the SAME gateId fires twice within one session
11
- // bucket. That is exactly "a pre-action gate fire that stopped a tool call the
12
- // agent had already been blocked on", i.e. a repeat attempt prevented before it
13
- // could round-trip and execute.
10
+ // in gates-engine.js every time the same gate blocks/warns the same sanitized
11
+ // action fingerprint within one session bucket. That is "a pre-action gate fire
12
+ // that stopped a tool call the agent had already been blocked on", rather than
13
+ // merely "the same noisy gate fired again."
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
16
  const gatesEngine = require('./gates-engine');
@@ -18,12 +18,12 @@ const gatesEngine = require('./gates-engine');
18
18
  /**
19
19
  * Derive a per-gate { firstBlocks, repeatBlocks } split from the raw stats.
20
20
  *
21
- * recordStat() records, per session bucket, which gates have fired
22
- * (stats.sessionFiredGates[sessionKey][gateId] === true). The FIRST fire of a
23
- * gate in a bucket marks the flag; every subsequent fire in that same bucket
24
- * increments stats.recurringBlocks. So for each gate:
25
- * firstBlocks = number of distinct session buckets the gate fired in
26
- * repeatBlocks = (total block+warn events for the gate) - firstBlocks
21
+ * Modern stats record, per session bucket, which sanitized action fingerprints
22
+ * each gate fired on:
23
+ * stats.sessionFiredActions[sessionKey][gateId][fingerprint] === true
24
+ *
25
+ * firstBlocks is the count of distinct first action fingerprints. Legacy stats
26
+ * without fingerprints fall back to the old per-session-gate split.
27
27
  *
28
28
  * total block+warn events come from stats.byGate[id] (blocked + warned), which
29
29
  * recordStat() also maintains. repeatBlocks is clamped to >= 0 to stay robust
@@ -34,15 +34,30 @@ const gatesEngine = require('./gates-engine');
34
34
  */
35
35
  function computeByGateSplit(stats) {
36
36
  const byGate = {};
37
+ const sessionFiredActions = (stats && stats.sessionFiredActions) || {};
37
38
  const sessionFiredGates = (stats && stats.sessionFiredGates) || {};
38
39
  const rawByGate = (stats && stats.byGate) || {};
39
40
 
40
- // Count distinct session buckets each gate fired in => firstBlocks.
41
+ // Count distinct action fingerprints each gate fired on => firstBlocks.
41
42
  const firstBlocksByGate = {};
43
+ const gatesWithActionStats = new Set();
44
+ for (const sessionKey of Object.keys(sessionFiredActions)) {
45
+ const fired = sessionFiredActions[sessionKey] || {};
46
+ for (const gateId of Object.keys(fired)) {
47
+ const fingerprints = fired[gateId] || {};
48
+ const count = Object.values(fingerprints).filter(Boolean).length;
49
+ if (count > 0) {
50
+ gatesWithActionStats.add(gateId);
51
+ firstBlocksByGate[gateId] = (firstBlocksByGate[gateId] || 0) + count;
52
+ }
53
+ }
54
+ }
55
+
56
+ // Legacy fallback: old stats only tracked gate fired per session bucket.
42
57
  for (const sessionKey of Object.keys(sessionFiredGates)) {
43
58
  const fired = sessionFiredGates[sessionKey] || {};
44
59
  for (const gateId of Object.keys(fired)) {
45
- if (fired[gateId]) {
60
+ if (fired[gateId] && !gatesWithActionStats.has(gateId)) {
46
61
  firstBlocksByGate[gateId] = (firstBlocksByGate[gateId] || 0) + 1;
47
62
  }
48
63
  }
@@ -52,6 +67,7 @@ function computeByGateSplit(stats) {
52
67
  const gateIds = new Set([
53
68
  ...Object.keys(rawByGate),
54
69
  ...Object.keys(firstBlocksByGate),
70
+ ...Object.keys(sessionFiredActions).flatMap((sessionKey) => Object.keys(sessionFiredActions[sessionKey] || {})),
55
71
  ]);
56
72
 
57
73
  for (const gateId of gateIds) {
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const FIXTURE_TOKENS = {
4
+ awsAccessKeyId: '__TG_FIXTURE_AWS_ACCESS_KEY_ID__',
5
+ githubPat: '__TG_FIXTURE_GITHUB_PAT__',
6
+ openAiLegacyKey: '__TG_FIXTURE_OPENAI_LEGACY_KEY__',
7
+ openAiProjectKey: '__TG_FIXTURE_OPENAI_PROJECT_KEY__',
8
+ rsaPrivateKeyHeader: '__TG_FIXTURE_RSA_PRIVATE_KEY_HEADER__',
9
+ ecPrivateKeyHeader: '__TG_FIXTURE_EC_PRIVATE_KEY_HEADER__',
10
+ privateKeyHeader: '__TG_FIXTURE_PRIVATE_KEY_HEADER__',
11
+ };
12
+
13
+ function buildAwsAccessKeyId() {
14
+ return ['AKIA', 'IOSFODNN7EXAMPLE'].join('');
15
+ }
16
+
17
+ function buildGitHubPat() {
18
+ return ['gh', 'p_', 'x'.repeat(36)].join('');
19
+ }
20
+
21
+ function buildOpenAiLegacyKey() {
22
+ return ['sk', '-', 'abcdefghijklmnopqrstuvwxyz01234567890'].join('');
23
+ }
24
+
25
+ function buildOpenAiProjectKey() {
26
+ return ['sk', '-proj-', 'abcdefghijklmnopqrstuvwxyz01234567890'].join('');
27
+ }
28
+
29
+ function buildPemHeader(prefix = '') {
30
+ return ['-----BEGIN ', prefix, 'PRIVATE KEY-----'].join('');
31
+ }
32
+
33
+ function fixtureReplacements() {
34
+ return [
35
+ [FIXTURE_TOKENS.awsAccessKeyId, buildAwsAccessKeyId()],
36
+ [FIXTURE_TOKENS.githubPat, buildGitHubPat()],
37
+ [FIXTURE_TOKENS.openAiLegacyKey, buildOpenAiLegacyKey()],
38
+ [FIXTURE_TOKENS.openAiProjectKey, buildOpenAiProjectKey()],
39
+ [FIXTURE_TOKENS.rsaPrivateKeyHeader, buildPemHeader('RSA ')],
40
+ [FIXTURE_TOKENS.ecPrivateKeyHeader, buildPemHeader('EC ')],
41
+ [FIXTURE_TOKENS.privateKeyHeader, buildPemHeader('')],
42
+ ];
43
+ }
44
+
45
+ function expandFixturePlaceholders(value) {
46
+ let expanded = String(value || '');
47
+ for (const [token, replacement] of fixtureReplacements()) {
48
+ expanded = expanded.split(token).join(replacement);
49
+ }
50
+ return expanded;
51
+ }
52
+
53
+ module.exports = {
54
+ FIXTURE_TOKENS,
55
+ buildAwsAccessKeyId,
56
+ buildGitHubPat,
57
+ buildOpenAiLegacyKey,
58
+ buildOpenAiProjectKey,
59
+ buildPemHeader,
60
+ expandFixturePlaceholders,
61
+ };
@@ -55,6 +55,11 @@ const BASH_SECRET_READ_PREFIXES = [
55
55
  ];
56
56
 
57
57
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
58
+ const SAFE_SECRET_STORAGE_DIRS = [
59
+ '.resume_secrets',
60
+ '.thumbgate/secrets',
61
+ '.config/thumbgate',
62
+ ];
58
63
 
59
64
  function redactText(text) {
60
65
  if (!text) return '';
@@ -172,6 +177,13 @@ function heuristicScanText(text, source = 'text') {
172
177
  pattern.regex.lastIndex = 0;
173
178
  let match = pattern.regex.exec(input);
174
179
  while (match) {
180
+ // Safe test key bypass
181
+ const matchedString = match[0].toLowerCase();
182
+ if (pattern.id === 'generic_assignment' && (matchedString.includes('sk_test_') || matchedString.includes('test_token'))) {
183
+ match = pattern.regex.exec(input);
184
+ continue;
185
+ }
186
+
175
187
  findings.push({
176
188
  id: pattern.id,
177
189
  label: pattern.label,
@@ -295,6 +307,26 @@ function resolvePathToken(token, cwd) {
295
307
  return path.join(cwd || process.cwd(), normalized);
296
308
  }
297
309
 
310
+ function normalizePathForPolicy(filePath) {
311
+ return path.resolve(String(filePath || '').replace(/^~(?=\/|$)/, os.homedir()));
312
+ }
313
+
314
+ function isSafeSecretStoragePath(filePath) {
315
+ if (!filePath) return false;
316
+ const normalized = normalizePathForPolicy(filePath);
317
+ const home = normalizePathForPolicy(os.homedir());
318
+ return SAFE_SECRET_STORAGE_DIRS.some((dir) => {
319
+ const allowedRoot = path.join(home, dir);
320
+ return normalized === allowedRoot || normalized.startsWith(`${allowedRoot}${path.sep}`);
321
+ });
322
+ }
323
+
324
+ function isSafeSecretStorageWrite(toolName, toolInput = {}, cwd = process.cwd()) {
325
+ if (!EDIT_LIKE_TOOLS.has(toolName)) return false;
326
+ const paths = getToolInputPaths(toolInput, cwd);
327
+ return paths.length > 0 && paths.every((filePath) => isSafeSecretStoragePath(filePath));
328
+ }
329
+
298
330
  function scanBashCommand(command, options = {}) {
299
331
  const cwd = options.cwd || process.cwd();
300
332
  const findings = [];
@@ -347,6 +379,7 @@ function scanHookInput(input = {}, options = {}) {
347
379
  let provider = resolveProvider(options.provider);
348
380
  let commandHash = null;
349
381
  let fileHashes = [];
382
+ const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, cwd);
350
383
 
351
384
  const contentFields = [
352
385
  toolInput.content,
@@ -376,11 +409,13 @@ function scanHookInput(input = {}, options = {}) {
376
409
  }
377
410
  }
378
411
 
379
- for (const content of contentFields) {
380
- const result = scanText(content, { provider, source: 'tool_input' });
381
- if (result.detected) {
382
- provider = result.provider;
383
- findings.push(...result.findings);
412
+ if (!safeSecretStorageWrite) {
413
+ for (const content of contentFields) {
414
+ const result = scanText(content, { provider, source: 'tool_input' });
415
+ if (result.detected) {
416
+ provider = result.provider;
417
+ findings.push(...result.findings);
418
+ }
384
419
  }
385
420
  }
386
421
 
@@ -402,6 +437,8 @@ function buildSafeSummary(findings, prefix) {
402
437
  module.exports = {
403
438
  SECRET_PATTERNS,
404
439
  SECRET_FILE_PATTERNS,
440
+ SAFE_SECRET_STORAGE_DIRS,
441
+ EDIT_LIKE_TOOLS,
405
442
  redactText,
406
443
  resolveProvider,
407
444
  scanText,
@@ -409,6 +446,8 @@ module.exports = {
409
446
  scanBashCommand,
410
447
  scanHookInput,
411
448
  classifySecretPath,
449
+ isSafeSecretStoragePath,
450
+ isSafeSecretStorageWrite,
412
451
  buildSafeSummary,
413
452
  tokenizeCommand,
414
453
  };
@@ -146,6 +146,14 @@ const VULN_PATTERNS = [
146
146
  regex: /(?:unserialize|yaml\.load\s*\((?!.*Loader\s*=\s*yaml\.SafeLoader)|pickle\.loads?|Marshal\.load)/g,
147
147
  fileTypes: ['.js', '.ts', '.py', '.rb'],
148
148
  },
149
+ {
150
+ id: 'badhost-url-confusion',
151
+ category: 'host-header',
152
+ severity: 'high',
153
+ label: 'Potential BadHost-style host or URL confusion in AI service',
154
+ regex: /\b(?:request\.url(?:\.path)?|url_for\s*\([^)]*_external\s*=\s*True|headers\s*\[\s*['"](?:host|x-forwarded-host)['"]\s*\])/gi,
155
+ fileTypes: ['.py'],
156
+ },
149
157
  ];
150
158
 
151
159
  // ---------------------------------------------------------------------------
@@ -231,6 +239,22 @@ function scanCode(content, filePath = '') {
231
239
  };
232
240
  }
233
241
 
242
+ /**
243
+ * Scan Python / AI-service code for BadHost-style URL and host-header confusion.
244
+ * This is deliberately narrow and evidence-oriented: it does not claim a CVE,
245
+ * it flags code that should prove canonical host handling before deployment.
246
+ * @param {string} content
247
+ * @param {string} filePath
248
+ * @returns {{ detected: boolean, findings: Array<Object> }}
249
+ */
250
+ function scanBadHostExposure(content, filePath = '') {
251
+ const result = scanCode(content, filePath);
252
+ return {
253
+ detected: result.findings.some((finding) => finding.id === 'badhost-url-confusion'),
254
+ findings: result.findings.filter((finding) => finding.id === 'badhost-url-confusion'),
255
+ };
256
+ }
257
+
234
258
  /**
235
259
  * Scan dependency changes in package.json mutations.
236
260
  * @param {string} oldContent - Previous package.json content (empty string if new file)
@@ -503,6 +527,60 @@ function scanGitDiff(diffContent) {
503
527
  };
504
528
  }
505
529
 
530
+ function buildThreatDefensePlaybook(scanResult = {}, options = {}) {
531
+ const findings = Array.isArray(scanResult.findings)
532
+ ? scanResult.findings
533
+ : (scanResult.securityScan && Array.isArray(scanResult.securityScan.findings) ? scanResult.securityScan.findings : []);
534
+ const critical = findings.filter((finding) => finding.severity === 'critical');
535
+ const high = findings.filter((finding) => finding.severity === 'high');
536
+ const categories = Array.from(new Set(findings.map((finding) => finding.category).filter(Boolean)));
537
+ const hasFindings = findings.length > 0;
538
+ const hasPatchEvidence = Boolean(options.patchEvidence || options.testEvidence || options.ciEvidence);
539
+
540
+ return {
541
+ name: 'thumbgate-ai-threat-defense-playbook',
542
+ status: critical.length > 0 ? 'block' : high.length > 0 ? 'remediate' : 'monitor',
543
+ phases: [
544
+ {
545
+ id: 'prepare',
546
+ action: 'harden-foundation',
547
+ evidence: ['gate templates enabled', 'protected files configured', 'rollback path documented'],
548
+ required: true,
549
+ },
550
+ {
551
+ id: 'scan-prioritize',
552
+ action: hasFindings ? 'prioritize detected security findings by severity and exploit surface' : 'keep posture scan active',
553
+ evidence: categories.length ? categories : ['clean scan'],
554
+ required: true,
555
+ },
556
+ {
557
+ id: 'remediate',
558
+ action: hasFindings ? 'patch, run focused tests, and re-scan before allowing risky agent actions' : 'no remediation required from current scan',
559
+ evidence: hasPatchEvidence ? ['patch evidence present'] : ['patch diff', 'focused test output', 'repeat scan'],
560
+ required: hasFindings,
561
+ },
562
+ {
563
+ id: 'monitor',
564
+ action: 'record audit event and keep continuous detection enabled for future tool calls',
565
+ evidence: ['audit trail event', 'gate stats', 'review checkpoint'],
566
+ required: true,
567
+ },
568
+ ],
569
+ priority: {
570
+ critical: critical.length,
571
+ high: high.length,
572
+ total: findings.length,
573
+ categories,
574
+ },
575
+ gateDecision: critical.length > 0 ? 'deny' : high.length > 0 ? 'warn' : 'allow',
576
+ nextActions: critical.length > 0
577
+ ? ['Block the action', 'Patch the critical finding', 'Run focused tests', 'Re-scan the diff before retry']
578
+ : high.length > 0
579
+ ? ['Warn the operator', 'Create a remediation task', 'Run focused tests', 'Monitor for repeat findings']
580
+ : ['Keep continuous scan enabled', 'Review checkpoint metrics after the next session'],
581
+ };
582
+ }
583
+
506
584
  // ---------------------------------------------------------------------------
507
585
  // Exports
508
586
  // ---------------------------------------------------------------------------
@@ -512,7 +590,9 @@ module.exports = {
512
590
  VULN_PATTERNS,
513
591
  SUPPLY_CHAIN_PATTERNS,
514
592
  scanCode,
593
+ scanBadHostExposure,
515
594
  scanDependencyChange,
516
595
  evaluateSecurityScan,
517
596
  scanGitDiff,
597
+ buildThreatDefensePlaybook,
518
598
  };