thumbgate 1.27.20 → 1.28.1
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 +2 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +124 -129
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +18 -33
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +95 -78
- package/bin/postinstall.js +1 -1
- package/config/entitlement-public-keys.json +6 -0
- package/config/gates/default.json +3 -3
- package/config/github-about.json +2 -5
- package/config/merge-quality-checks.json +3 -0
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +28 -10
- package/public/about.html +1 -4
- package/public/agent-manager.html +6 -6
- package/public/agents-cost-savings.html +3 -3
- package/public/ai-malpractice-prevention.html +1 -1
- package/public/blog.html +12 -9
- package/public/chatgpt-app.html +3 -3
- package/public/codex-enterprise.html +3 -3
- package/public/codex-plugin.html +5 -5
- package/public/compare.html +10 -10
- package/public/dashboard.html +1 -1
- package/public/diagnostic.html +23 -1
- package/public/federal.html +2 -2
- package/public/guide.html +14 -14
- package/public/index.html +202 -220
- package/public/install.html +14 -14
- package/public/learn.html +4 -17
- package/public/numbers.html +2 -2
- package/public/partner-intake.html +198 -0
- package/public/pricing.html +61 -31
- package/public/pro.html +2 -2
- package/scripts/agent-reward-model.js +13 -0
- package/scripts/bayes-optimal-gate.js +6 -1
- package/scripts/billing.js +815 -137
- package/scripts/checkout-attribution-reference.js +85 -0
- package/scripts/claude-feedback-sync.js +23 -6
- package/scripts/cli-feedback.js +33 -8
- package/scripts/commercial-offer.js +3 -3
- package/scripts/entitlement.js +250 -0
- package/scripts/export-databricks-bundle.js +5 -0
- package/scripts/export-dpo-pairs.js +6 -0
- package/scripts/export-hf-dataset.js +5 -0
- package/scripts/feedback-loop.js +290 -1
- package/scripts/feedback-quality.js +25 -26
- package/scripts/feedback-sanitizer.js +151 -3
- package/scripts/gates-engine.js +134 -43
- package/scripts/hosted-config.js +9 -2
- package/scripts/imperative-detector.js +85 -0
- package/scripts/intervention-policy.js +13 -0
- package/scripts/mailer/resend-mailer.js +11 -5
- package/scripts/pr-manager.js +9 -22
- package/scripts/pro-local-dashboard.js +198 -0
- package/scripts/risk-scorer.js +6 -0
- package/scripts/secret-scanner.js +363 -68
- package/scripts/self-protection.js +21 -36
- package/scripts/seo-gsd.js +2 -2
- package/scripts/thompson-sampling.js +11 -2
- package/src/api/server.js +202 -22
- package/public/assets/brand/github-social-preview.png +0 -0
|
@@ -18,7 +18,7 @@ const SECRET_PATTERNS = [
|
|
|
18
18
|
{ id: 'stripe_live_secret', label: 'Stripe live secret key', regex: /\bsk_live_[A-Za-z0-9]{16,}\b/g },
|
|
19
19
|
{ id: 'slack_token', label: 'Slack token', regex: /\bxox(?:a|b|p|r|s)-[A-Za-z0-9-]{10,}\b/g },
|
|
20
20
|
{ id: 'aws_access_key', label: 'AWS access key', regex: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
21
|
-
{ id: 'jwt_token', label: 'JWT token', regex: /\beyJ[
|
|
21
|
+
{ id: 'jwt_token', label: 'JWT token', regex: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g },
|
|
22
22
|
{ id: 'pem_private_key', label: 'Private key block', regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g },
|
|
23
23
|
{
|
|
24
24
|
id: 'generic_assignment',
|
|
@@ -54,6 +54,30 @@ const BASH_SECRET_READ_PREFIXES = [
|
|
|
54
54
|
'printenv',
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
const OUTBOUND_FILE_COMMANDS = new Set(['curl', 'wget']);
|
|
58
|
+
const OUTBOUND_COMMAND_WRAPPERS = new Set(['command', 'env', 'nohup', 'sudo']);
|
|
59
|
+
const SHELL_QUOTES = new Set(['"', "'"]);
|
|
60
|
+
const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n']);
|
|
61
|
+
const WRAPPER_OPTIONS_WITH_VALUE = {
|
|
62
|
+
env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
|
|
63
|
+
sudo: new Set([
|
|
64
|
+
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt',
|
|
65
|
+
'-C', '--close-from', '-T', '--command-timeout', '-R', '--chroot',
|
|
66
|
+
'-D', '--chdir',
|
|
67
|
+
]),
|
|
68
|
+
};
|
|
69
|
+
const CURL_DATA_FILE_OPTIONS = new Set([
|
|
70
|
+
'-d',
|
|
71
|
+
'--data',
|
|
72
|
+
'--data-ascii',
|
|
73
|
+
'--data-binary',
|
|
74
|
+
'--data-urlencode',
|
|
75
|
+
'--json',
|
|
76
|
+
]);
|
|
77
|
+
const CURL_FORM_FILE_OPTIONS = new Set(['-F', '--form']);
|
|
78
|
+
const CURL_UPLOAD_FILE_OPTIONS = new Set(['-T', '--upload-file']);
|
|
79
|
+
const WGET_POST_FILE_OPTIONS = new Set(['--post-file', '--body-file']);
|
|
80
|
+
|
|
57
81
|
const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
58
82
|
const SAFE_SECRET_STORAGE_DIRS = [
|
|
59
83
|
'.resume_secrets',
|
|
@@ -248,7 +272,7 @@ function scanText(text, options = {}) {
|
|
|
248
272
|
}
|
|
249
273
|
|
|
250
274
|
function scanFile(filePath, options = {}) {
|
|
251
|
-
const pathFinding = classifySecretPath(filePath);
|
|
275
|
+
const pathFinding = options.includePathFinding === false ? null : classifySecretPath(filePath);
|
|
252
276
|
const provider = resolveProvider(options.provider);
|
|
253
277
|
const findings = [];
|
|
254
278
|
if (pathFinding) findings.push(pathFinding);
|
|
@@ -279,14 +303,47 @@ function scanFile(filePath, options = {}) {
|
|
|
279
303
|
};
|
|
280
304
|
}
|
|
281
305
|
|
|
306
|
+
function flushToken(tokens, state) {
|
|
307
|
+
if (!state.current) return;
|
|
308
|
+
tokens.push(state.current);
|
|
309
|
+
state.current = '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function consumeTokenCharacter(state, tokens, char) {
|
|
313
|
+
if (state.escaped) {
|
|
314
|
+
state.current += char;
|
|
315
|
+
state.escaped = false;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (char === '\\' && state.quote !== "'") {
|
|
319
|
+
state.escaped = true;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (state.quote) {
|
|
323
|
+
if (char === state.quote) state.quote = null;
|
|
324
|
+
else state.current += char;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (SHELL_QUOTES.has(char)) {
|
|
328
|
+
state.quote = char;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (/\s/.test(char)) {
|
|
332
|
+
flushToken(tokens, state);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
state.current += char;
|
|
336
|
+
}
|
|
337
|
+
|
|
282
338
|
function tokenizeCommand(command) {
|
|
283
339
|
const tokens = [];
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
match = regex.exec(String(command || ''));
|
|
340
|
+
const state = { current: '', quote: null, escaped: false };
|
|
341
|
+
|
|
342
|
+
for (const char of String(command || '')) {
|
|
343
|
+
consumeTokenCharacter(state, tokens, char);
|
|
289
344
|
}
|
|
345
|
+
if (state.escaped) state.current += '\\';
|
|
346
|
+
flushToken(tokens, state);
|
|
290
347
|
return tokens;
|
|
291
348
|
}
|
|
292
349
|
|
|
@@ -307,6 +364,202 @@ function resolvePathToken(token, cwd) {
|
|
|
307
364
|
return path.join(cwd || process.cwd(), normalized);
|
|
308
365
|
}
|
|
309
366
|
|
|
367
|
+
function flushCommandSegment(segments, state) {
|
|
368
|
+
const segment = state.current.trim();
|
|
369
|
+
if (segment) segments.push(segment);
|
|
370
|
+
state.current = '';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function consumeSegmentCharacter(state, segments, char) {
|
|
374
|
+
if (state.escaped) {
|
|
375
|
+
state.current += char;
|
|
376
|
+
state.escaped = false;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (char === '\\' && state.quote !== "'") {
|
|
380
|
+
state.current += char;
|
|
381
|
+
state.escaped = true;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (state.quote) {
|
|
385
|
+
state.current += char;
|
|
386
|
+
if (char === state.quote) state.quote = null;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (SHELL_QUOTES.has(char)) {
|
|
390
|
+
state.quote = char;
|
|
391
|
+
state.current += char;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (SHELL_SEGMENT_SEPARATORS.has(char)) {
|
|
395
|
+
flushCommandSegment(segments, state);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
state.current += char;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function splitCommandSegments(command) {
|
|
402
|
+
const segments = [];
|
|
403
|
+
const state = { current: '', quote: null, escaped: false };
|
|
404
|
+
|
|
405
|
+
for (const char of String(command || '')) {
|
|
406
|
+
consumeSegmentCharacter(state, segments, char);
|
|
407
|
+
}
|
|
408
|
+
flushCommandSegment(segments, state);
|
|
409
|
+
return segments;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isShellAssignment(token) {
|
|
413
|
+
return /^[A-Za-z_]\w*=/.test(String(token || ''));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function skipWrapperOptions(tokens, startIndex, wrapper) {
|
|
417
|
+
let index = startIndex;
|
|
418
|
+
const valueOptions = WRAPPER_OPTIONS_WITH_VALUE[wrapper] || new Set();
|
|
419
|
+
|
|
420
|
+
while (index < tokens.length) {
|
|
421
|
+
const token = String(tokens[index] || '');
|
|
422
|
+
if (wrapper === 'env' && isShellAssignment(token)) {
|
|
423
|
+
index += 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (token === '--') return index + 1;
|
|
427
|
+
if (!token.startsWith('-')) return index;
|
|
428
|
+
if (wrapper === 'command' && (token === '-v' || token === '-V')) return -1;
|
|
429
|
+
|
|
430
|
+
const optionName = token.split('=', 1)[0];
|
|
431
|
+
if (valueOptions.has(optionName) && !token.includes('=')) index += 2;
|
|
432
|
+
else index += 1;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return index;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function findOutboundCommand(tokens) {
|
|
439
|
+
let index = 0;
|
|
440
|
+
while (isShellAssignment(tokens[index])) index += 1;
|
|
441
|
+
|
|
442
|
+
while (index < tokens.length) {
|
|
443
|
+
const wrapper = path.basename(String(tokens[index] || '')).toLowerCase();
|
|
444
|
+
if (!OUTBOUND_COMMAND_WRAPPERS.has(wrapper)) break;
|
|
445
|
+
index += 1;
|
|
446
|
+
index = skipWrapperOptions(tokens, index, wrapper);
|
|
447
|
+
if (index < 0) return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const command = path.basename(String(tokens[index] || '')).toLowerCase();
|
|
451
|
+
return OUTBOUND_FILE_COMMANDS.has(command) ? { command, index } : null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function stripCurlFormMetadata(fileReference) {
|
|
455
|
+
return String(fileReference || '').split(';', 1)[0];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function curlDataFileReference(value, allowNamedReference = false) {
|
|
459
|
+
const normalized = String(value || '');
|
|
460
|
+
if (normalized.startsWith('@')) return normalized.slice(1);
|
|
461
|
+
if (!allowNamedReference) return null;
|
|
462
|
+
const markerIndex = normalized.indexOf('@');
|
|
463
|
+
return markerIndex > 0 ? normalized.slice(markerIndex + 1) : null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function curlFormFileReference(value) {
|
|
467
|
+
const normalized = String(value || '');
|
|
468
|
+
const marker = /(?:^|=)[@<](.+)$/.exec(normalized);
|
|
469
|
+
return marker ? stripCurlFormMetadata(marker[1]) : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function addOutboundReference(references, fileReference, cwd, metadata) {
|
|
473
|
+
const normalized = String(fileReference || '').trim();
|
|
474
|
+
if (!normalized || normalized === '-') return;
|
|
475
|
+
const resolvedPath = resolvePathToken(normalized, cwd);
|
|
476
|
+
if (!resolvedPath) return;
|
|
477
|
+
references.push({
|
|
478
|
+
path: resolvedPath,
|
|
479
|
+
...metadata,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function readOptionArgument(args, index, option) {
|
|
484
|
+
const token = String(args[index] || '');
|
|
485
|
+
if (token === option) {
|
|
486
|
+
return { value: args[index + 1], consumesNext: true };
|
|
487
|
+
}
|
|
488
|
+
if (option.startsWith('--') && token.startsWith(`${option}=`)) {
|
|
489
|
+
return { value: token.slice(option.length + 1), consumesNext: false };
|
|
490
|
+
}
|
|
491
|
+
if (option.length === 2 && token.startsWith(option) && token.length > 2) {
|
|
492
|
+
return { value: token.slice(2), consumesNext: false };
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function outboundOptionSpecs(command) {
|
|
498
|
+
if (command === 'wget') {
|
|
499
|
+
return [{
|
|
500
|
+
options: WGET_POST_FILE_OPTIONS,
|
|
501
|
+
fileReference: (value) => value,
|
|
502
|
+
}];
|
|
503
|
+
}
|
|
504
|
+
return [
|
|
505
|
+
{
|
|
506
|
+
options: CURL_DATA_FILE_OPTIONS,
|
|
507
|
+
fileReference: (value, option) => curlDataFileReference(value, option === '--data-urlencode'),
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
options: CURL_FORM_FILE_OPTIONS,
|
|
511
|
+
fileReference: curlFormFileReference,
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
options: CURL_UPLOAD_FILE_OPTIONS,
|
|
515
|
+
fileReference: (value) => value,
|
|
516
|
+
},
|
|
517
|
+
];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function findOutboundOptionArgument(args, index, specs) {
|
|
521
|
+
for (const spec of specs) {
|
|
522
|
+
for (const option of spec.options) {
|
|
523
|
+
const argument = readOptionArgument(args, index, option);
|
|
524
|
+
if (argument) return { argument, fileReference: spec.fileReference, option };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function extractCommandFileReferences(command, args, cwd, references) {
|
|
531
|
+
const specs = outboundOptionSpecs(command);
|
|
532
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
533
|
+
const match = findOutboundOptionArgument(args, index, specs);
|
|
534
|
+
if (!match) continue;
|
|
535
|
+
addOutboundReference(references, match.fileReference(match.argument.value, match.option), cwd, {
|
|
536
|
+
command,
|
|
537
|
+
option: match.option,
|
|
538
|
+
});
|
|
539
|
+
if (match.argument.consumesNext) index += 1;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function extractOutboundFileReferences(command, cwd = process.cwd()) {
|
|
544
|
+
// Only file-bearing client options enter this path. Endpoint-only egress
|
|
545
|
+
// remains governed by the existing warn-level network gate.
|
|
546
|
+
const references = [];
|
|
547
|
+
for (const segment of splitCommandSegments(command)) {
|
|
548
|
+
const tokens = tokenizeCommand(segment);
|
|
549
|
+
const outboundCommand = findOutboundCommand(tokens);
|
|
550
|
+
if (!outboundCommand) continue;
|
|
551
|
+
const args = tokens.slice(outboundCommand.index + 1);
|
|
552
|
+
extractCommandFileReferences(outboundCommand.command, args, cwd, references);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const seen = new Set();
|
|
556
|
+
return references.filter((reference) => {
|
|
557
|
+
if (seen.has(reference.path)) return false;
|
|
558
|
+
seen.add(reference.path);
|
|
559
|
+
return true;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
310
563
|
function normalizePathForPolicy(filePath) {
|
|
311
564
|
return path.resolve(String(filePath || '').replace(/^~(?=\/|$)/, os.homedir()));
|
|
312
565
|
}
|
|
@@ -327,37 +580,70 @@ function isSafeSecretStorageWrite(toolName, toolInput = {}, cwd = process.cwd())
|
|
|
327
580
|
return paths.length > 0 && paths.every((filePath) => isSafeSecretStoragePath(filePath));
|
|
328
581
|
}
|
|
329
582
|
|
|
330
|
-
function
|
|
331
|
-
|
|
332
|
-
const findings = [];
|
|
333
|
-
const inlineScan = scanText(command, { provider: options.provider, source: 'command' });
|
|
334
|
-
findings.push(...inlineScan.findings.map((finding) => ({
|
|
583
|
+
function commandTextFindings(inlineScan) {
|
|
584
|
+
return inlineScan.findings.map((finding) => ({
|
|
335
585
|
...finding,
|
|
336
586
|
reason: `${finding.label} found in command text`,
|
|
337
|
-
}))
|
|
587
|
+
}));
|
|
588
|
+
}
|
|
338
589
|
|
|
590
|
+
function scanCommandReadFiles(command, cwd, provider) {
|
|
339
591
|
const tokens = tokenizeCommand(command);
|
|
340
592
|
const verb = String(tokens[0] || '').toLowerCase();
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
593
|
+
if (!BASH_SECRET_READ_PREFIXES.includes(verb)) return [];
|
|
594
|
+
|
|
595
|
+
const findings = [];
|
|
596
|
+
for (const token of tokens.slice(1)) {
|
|
597
|
+
if (!looksLikePath(token)) continue;
|
|
598
|
+
const resolved = resolvePathToken(token, cwd);
|
|
599
|
+
const fileScan = scanFile(resolved, { provider });
|
|
600
|
+
if (!fileScan.detected) continue;
|
|
601
|
+
findings.push(...fileScan.findings.map((finding) => ({
|
|
602
|
+
...finding,
|
|
603
|
+
source: 'command_file',
|
|
604
|
+
})));
|
|
354
605
|
}
|
|
606
|
+
return findings;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function scanOutboundCommandFiles(command, cwd, provider) {
|
|
610
|
+
const findings = [];
|
|
611
|
+
const fileHashes = [];
|
|
612
|
+
for (const reference of extractOutboundFileReferences(command, cwd)) {
|
|
613
|
+
const fileScan = scanFile(reference.path, {
|
|
614
|
+
provider,
|
|
615
|
+
includePathFinding: false,
|
|
616
|
+
});
|
|
617
|
+
if (!fileScan.detected) continue;
|
|
618
|
+
findings.push(...fileScan.findings.map((finding) => ({
|
|
619
|
+
...finding,
|
|
620
|
+
source: 'outbound_file',
|
|
621
|
+
reason: `${finding.label} found in file referenced by ${reference.command} ${reference.option}`,
|
|
622
|
+
})));
|
|
623
|
+
if (fileScan.fileHash) fileHashes.push(fileScan.fileHash);
|
|
624
|
+
}
|
|
625
|
+
return { findings, fileHashes };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function scanBashCommand(command, options = {}) {
|
|
629
|
+
const cwd = options.cwd || process.cwd();
|
|
630
|
+
const inlineScan = scanText(command, { provider: options.provider, source: 'command' });
|
|
631
|
+
const findings = commandTextFindings(inlineScan);
|
|
632
|
+
findings.push(...scanCommandReadFiles(command, cwd, options.provider));
|
|
633
|
+
|
|
634
|
+
const outboundScan = inlineScan.provider === 'off'
|
|
635
|
+
? { findings: [], fileHashes: [] }
|
|
636
|
+
: scanOutboundCommandFiles(command, cwd, options.provider);
|
|
637
|
+
findings.push(...outboundScan.findings);
|
|
638
|
+
|
|
639
|
+
const uniqueFileHashes = [...new Set(outboundScan.fileHashes)];
|
|
355
640
|
|
|
356
641
|
return {
|
|
357
642
|
detected: findings.length > 0,
|
|
358
643
|
provider: inlineScan.provider,
|
|
359
644
|
findings: uniqueFindings(findings),
|
|
360
645
|
commandHash: hashText(command),
|
|
646
|
+
fileHashes: uniqueFileHashes,
|
|
361
647
|
};
|
|
362
648
|
}
|
|
363
649
|
|
|
@@ -371,61 +657,70 @@ function getToolInputPaths(toolInput = {}, cwd = process.cwd()) {
|
|
|
371
657
|
return candidates.map((candidate) => resolvePathToken(candidate, cwd));
|
|
372
658
|
}
|
|
373
659
|
|
|
374
|
-
function
|
|
375
|
-
|
|
376
|
-
const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
|
|
377
|
-
const cwd = input.cwd || options.cwd || process.cwd();
|
|
378
|
-
const findings = [];
|
|
379
|
-
let provider = resolveProvider(options.provider);
|
|
380
|
-
let commandHash = null;
|
|
381
|
-
let fileHashes = [];
|
|
382
|
-
const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, cwd);
|
|
383
|
-
|
|
384
|
-
const contentFields = [
|
|
660
|
+
function getToolInputContent(toolInput) {
|
|
661
|
+
return [
|
|
385
662
|
toolInput.content,
|
|
386
663
|
toolInput.new_string,
|
|
387
664
|
toolInput.value,
|
|
388
665
|
toolInput.text,
|
|
389
666
|
].filter((value) => typeof value === 'string' && value.trim());
|
|
667
|
+
}
|
|
390
668
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
}
|
|
669
|
+
function scanHookPaths(state, toolName, toolInput, cwd) {
|
|
670
|
+
if (EDIT_LIKE_TOOLS.has(toolName)) return;
|
|
671
|
+
for (const filePath of getToolInputPaths(toolInput, cwd)) {
|
|
672
|
+
const result = scanFile(filePath, { provider: state.provider });
|
|
673
|
+
if (!result.detected) continue;
|
|
674
|
+
state.provider = result.provider;
|
|
675
|
+
state.fileHashes.push(result.fileHash);
|
|
676
|
+
state.findings.push(...result.findings);
|
|
401
677
|
}
|
|
678
|
+
}
|
|
402
679
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
680
|
+
function scanHookCommand(state, toolInput, cwd) {
|
|
681
|
+
const command = toolInput.command;
|
|
682
|
+
if (typeof command !== 'string' || !command.trim()) return;
|
|
683
|
+
const result = scanBashCommand(command, { provider: state.provider, cwd });
|
|
684
|
+
if (!result.detected) return;
|
|
685
|
+
state.provider = result.provider;
|
|
686
|
+
state.commandHash = result.commandHash;
|
|
687
|
+
state.fileHashes.push(...(result.fileHashes || []));
|
|
688
|
+
state.findings.push(...result.findings);
|
|
689
|
+
}
|
|
411
690
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
691
|
+
function scanHookContent(state, toolInput, safeSecretStorageWrite) {
|
|
692
|
+
if (safeSecretStorageWrite) return;
|
|
693
|
+
for (const content of getToolInputContent(toolInput)) {
|
|
694
|
+
const result = scanText(content, { provider: state.provider, source: 'tool_input' });
|
|
695
|
+
if (!result.detected) continue;
|
|
696
|
+
state.provider = result.provider;
|
|
697
|
+
state.findings.push(...result.findings);
|
|
420
698
|
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function scanHookInput(input = {}, options = {}) {
|
|
702
|
+
const toolName = String(input.tool_name || input.toolName || '').trim();
|
|
703
|
+
const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
|
|
704
|
+
const cwd = input.cwd || options.cwd || process.cwd();
|
|
705
|
+
const state = {
|
|
706
|
+
provider: resolveProvider(options.provider),
|
|
707
|
+
findings: [],
|
|
708
|
+
commandHash: null,
|
|
709
|
+
fileHashes: [],
|
|
710
|
+
};
|
|
711
|
+
const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, cwd);
|
|
712
|
+
|
|
713
|
+
scanHookPaths(state, toolName, toolInput, cwd);
|
|
714
|
+
scanHookCommand(state, toolInput, cwd);
|
|
715
|
+
scanHookContent(state, toolInput, safeSecretStorageWrite);
|
|
421
716
|
|
|
422
717
|
return {
|
|
423
|
-
detected: findings.length > 0,
|
|
424
|
-
provider,
|
|
718
|
+
detected: state.findings.length > 0,
|
|
719
|
+
provider: state.provider,
|
|
425
720
|
toolName,
|
|
426
|
-
findings: uniqueFindings(findings),
|
|
427
|
-
commandHash,
|
|
428
|
-
fileHashes: fileHashes.filter(Boolean),
|
|
721
|
+
findings: uniqueFindings(state.findings),
|
|
722
|
+
commandHash: state.commandHash,
|
|
723
|
+
fileHashes: state.fileHashes.filter(Boolean),
|
|
429
724
|
};
|
|
430
725
|
}
|
|
431
726
|
|
|
@@ -9,20 +9,19 @@
|
|
|
9
9
|
* an agent could disable the firewall before continuing and the surfaced
|
|
10
10
|
* verdict was a clean ALLOW.
|
|
11
11
|
*
|
|
12
|
-
* This module
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* This module retains the path-classification API used by the dogfood hook and
|
|
13
|
+
* third-party integrations. Enforcement itself lives in gates-engine.js so the
|
|
14
|
+
* plugin hook and `thumbgate gate-check` cannot drift apart.
|
|
15
15
|
*
|
|
16
16
|
* Posture threads three constraints:
|
|
17
17
|
* * Andy's ask: don't let the agent quietly rewrite/disable the gate.
|
|
18
|
-
* * CEO warn-by-default (2026-06-04):
|
|
19
|
-
* * Self-lockout lesson (2026-07-07):
|
|
20
|
-
*
|
|
18
|
+
* * CEO warn-by-default (2026-06-04): ordinary operational gates stay advisory.
|
|
19
|
+
* * Self-lockout lesson (2026-07-07): every protected-file floor needs an
|
|
20
|
+
* audited, short-lived repair path.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* THUMBGATE_ALLOW_SELF_EDIT=1 -> full opt-out (preserves the repair path)
|
|
22
|
+
* Self-protection is now an unconditional floor. Intentional repairs use the
|
|
23
|
+
* audited, time-limited protected approval / break-glass path; environment
|
|
24
|
+
* flags cannot disable it.
|
|
26
25
|
*/
|
|
27
26
|
|
|
28
27
|
const SELF_GOVERNANCE_PATH_PATTERNS = [
|
|
@@ -30,18 +29,15 @@ const SELF_GOVERNANCE_PATH_PATTERNS = [
|
|
|
30
29
|
/(?:^|\/)\.codex\/config\.toml$/i,
|
|
31
30
|
/(?:^|\/)scripts\/hook-[^/]+\.(?:js|sh)$/i, // the hook scripts themselves
|
|
32
31
|
/(?:^|\/)config\/gates\//i, // gate definitions
|
|
32
|
+
/(?:^|\/)config\/budget\.json$/i,
|
|
33
33
|
/(?:^|\/)config\/enforcement\.json$/i, // enforcement policy
|
|
34
34
|
/(?:^|\/)config\/mcp-allowlists\.json$/i, // MCP policy surface
|
|
35
|
+
/(?:^|\/)\.thumbgate\/config\.json$/i,
|
|
36
|
+
/(?:^|\/)thumbgate\.json$/i,
|
|
35
37
|
];
|
|
36
38
|
|
|
37
39
|
const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
38
40
|
|
|
39
|
-
function isTrueEnv(value) {
|
|
40
|
-
if (value === undefined || value === null) return false;
|
|
41
|
-
const v = String(value).trim().toLowerCase();
|
|
42
|
-
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
41
|
/**
|
|
46
42
|
* Returns the matched governance file path, or null. Accepts both camelCase
|
|
47
43
|
* (`tool_name`/`tool_input`) and the already-normalized shapes callers use.
|
|
@@ -55,31 +51,20 @@ function selfProtectionTarget(toolName, toolInput) {
|
|
|
55
51
|
|
|
56
52
|
/**
|
|
57
53
|
* Evaluate the self-protection posture for a tool call.
|
|
58
|
-
* @returns {{action:'block'
|
|
54
|
+
* @returns {{action:'block', target:string, message:string}|null}
|
|
59
55
|
*/
|
|
60
|
-
function evaluateSelfProtection(toolName, toolInput
|
|
56
|
+
function evaluateSelfProtection(toolName, toolInput) {
|
|
61
57
|
const target = selfProtectionTarget(toolName, toolInput);
|
|
62
58
|
if (!target) return null;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
action: 'block',
|
|
69
|
-
target,
|
|
70
|
-
message:
|
|
71
|
-
`ThumbGate self-protection: blocked ${toolName} to its own governance file "${target}". `
|
|
72
|
-
+ `Editing the firewall's own hook wiring / gate config while strict enforcement is on is denied. `
|
|
73
|
-
+ `Set THUMBGATE_ALLOW_SELF_EDIT=1 to make an intentional repair.`,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
59
|
+
const { runHardFloor } = require('./gates-engine');
|
|
60
|
+
const output = runHardFloor({ tool_name: toolName, tool_input: toolInput });
|
|
61
|
+
if (!output) return null;
|
|
62
|
+
const parsed = JSON.parse(output);
|
|
63
|
+
const message = parsed.hookSpecificOutput && parsed.hookSpecificOutput.permissionDecisionReason;
|
|
76
64
|
return {
|
|
77
|
-
action: '
|
|
65
|
+
action: 'block',
|
|
78
66
|
target,
|
|
79
|
-
message:
|
|
80
|
-
`⚠️ ThumbGate self-protection: this ${toolName} targets a governance file that configures the firewall itself `
|
|
81
|
-
+ `("${target}"). It is being ALLOWED and LOGGED (warn-by-default). An agent editing this could weaken or disable `
|
|
82
|
-
+ `its own guardrails — confirm this is intentional. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block such edits.`,
|
|
67
|
+
message: message || `ThumbGate self-protection blocked ${toolName} to "${target}".`,
|
|
83
68
|
};
|
|
84
69
|
}
|
|
85
70
|
|
package/scripts/seo-gsd.js
CHANGED
|
@@ -562,7 +562,7 @@ const PRETOOLUSE_HOOK_GUIDE_SPEC = Object.freeze({
|
|
|
562
562
|
],
|
|
563
563
|
[
|
|
564
564
|
'Does this run locally or call a cloud service?',
|
|
565
|
-
'Local-first. The PreToolUse decision happens in the hook process on your machine in milliseconds — no network round-trip, no cloud dependency, no data leaving the laptop.
|
|
565
|
+
'Local-first. The PreToolUse decision happens in the hook process on your machine in milliseconds — no network round-trip, no cloud dependency, no data leaving the laptop. Pro adds personal recall, exports, dashboard proof, and managed adapter coverage. Enterprise adds hosted sharing for teams that want to share rules across seats.',
|
|
566
566
|
],
|
|
567
567
|
],
|
|
568
568
|
relatedPaths: ['/guides/mcp-tool-governance', '/guides/ai-agent-pre-action-approval-gates', '/guides/ai-coding-agent-zero-trust'],
|
|
@@ -1972,7 +1972,7 @@ const PAGE_BLUEPRINTS = [
|
|
|
1972
1972
|
faq: [
|
|
1973
1973
|
{
|
|
1974
1974
|
question: 'Why pay $19/mo for ThumbGate Pro when disler hooks are free?',
|
|
1975
|
-
answer: 'Free disler hooks are static patterns you maintain per machine — re-copying them when they change, debugging false positives alone, and re-applying them in every new project. ThumbGate Pro adds the learning loop (thumbs-
|
|
1975
|
+
answer: 'Free disler hooks are static patterns you maintain per machine — re-copying them when they change, debugging false positives alone, and re-applying them in every new project. ThumbGate Pro adds the learning loop (repeated thumbs-downs → cross-session prevention rule), the personal dashboard, lesson recall/search, DPO export, and adapter maintenance across the weekly breaking-change cycle of Claude Code, Cursor, and Cline. Free disler is the right answer if you only ever work in one project on one machine and never want to learn from past mistakes. Pro is the right answer when those assumptions stop holding; Enterprise adds shared hosted lessons across seats.',
|
|
1976
1976
|
},
|
|
1977
1977
|
{
|
|
1978
1978
|
question: 'Is ThumbGate just a packaged version of disler/claude-code-hooks-mastery?',
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
const fs = require('fs');
|
|
23
23
|
const { parseTimestamp } = require('./feedback-schema');
|
|
24
24
|
const { getEffectiveSetting } = require('./evolution-state');
|
|
25
|
+
const { requireLearnedModelsEntitlement } = require('./entitlement');
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Constants
|
|
@@ -179,7 +180,11 @@ function saveModel(model, modelPath) {
|
|
|
179
180
|
* @param {number} [params.weightMultiplier] - Optional multiplier for stronger/slower updates
|
|
180
181
|
* @returns {Object} The mutated model
|
|
181
182
|
*/
|
|
182
|
-
function updateModel(model, { signal, timestamp, categories, weightMultiplier, failureType }) {
|
|
183
|
+
function updateModel(model, { signal, timestamp, categories, weightMultiplier, failureType, entitlement }) {
|
|
184
|
+
requireLearnedModelsEntitlement({
|
|
185
|
+
...(entitlement || {}),
|
|
186
|
+
label: 'Thompson Sampling model update',
|
|
187
|
+
});
|
|
183
188
|
const multiplier = Number.isFinite(weightMultiplier) && weightMultiplier > 0 ? weightMultiplier : 1;
|
|
184
189
|
const weight = timeDecayWeight(timestamp) * multiplier;
|
|
185
190
|
const isPositive = signal === 'positive';
|
|
@@ -334,7 +339,11 @@ function getTemperatureScaledPosteriorParams(params, temperature = 1.0) {
|
|
|
334
339
|
* @param {number} temperature - Scaling factor (default 1.0)
|
|
335
340
|
* @returns {Object} Map of category → float sample in [0, 1]
|
|
336
341
|
*/
|
|
337
|
-
function samplePosteriors(model, temperature = 1.0) {
|
|
342
|
+
function samplePosteriors(model, temperature = 1.0, options = {}) {
|
|
343
|
+
requireLearnedModelsEntitlement({
|
|
344
|
+
...(options.entitlement || {}),
|
|
345
|
+
label: 'Thompson Sampling posterior sampling',
|
|
346
|
+
});
|
|
338
347
|
const samples = {};
|
|
339
348
|
|
|
340
349
|
for (const [cat, params] of Object.entries(model.categories || {})) {
|