thumbgate 1.26.7 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +2 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +20 -9
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/mcp/server-stdio.js +28 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +147 -10
- package/bin/dashboard-cli.js +7 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +60 -0
- package/config/mcp-allowlists.json +8 -7
- package/config/model-candidates.json +71 -6
- package/package.json +26 -10
- package/public/chatgpt-app.html +330 -0
- package/public/codex-plugin.html +66 -14
- package/public/dashboard.html +203 -17
- package/public/index.html +79 -4
- package/public/learn.html +70 -0
- package/public/lessons.html +129 -6
- package/public/numbers.html +2 -2
- package/public/pricing.html +20 -2
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-schema.js +26 -0
- package/scripts/dashboard-chat.js +64 -17
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/gates-engine.js +258 -61
- package/scripts/hybrid-feedback-context.js +141 -7
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plausible-domain-config.js +86 -0
- package/scripts/plausible-server-events.js +4 -2
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/repeat-metric.js +28 -12
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +80 -0
- package/scripts/seo-gsd.js +53 -0
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/tool-registry.js +37 -0
- package/scripts/workflow-sentinel.js +189 -4
- 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
|
|
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 --
|
|
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
|
+
};
|
package/scripts/repeat-metric.js
CHANGED
|
@@ -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
|
|
11
|
-
// bucket. That is
|
|
12
|
-
// agent had already been blocked on",
|
|
13
|
-
//
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
};
|