thumbgate 1.28.0 → 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.
@@ -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[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}\.[A-Za-z0-9._-]{8,}\b/g },
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 regex = /"([^"]+)"|'([^']+)'|(\S+)/g;
285
- let match = regex.exec(String(command || ''));
286
- while (match) {
287
- tokens.push(match[1] || match[2] || match[3]);
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 scanBashCommand(command, options = {}) {
331
- const cwd = options.cwd || process.cwd();
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
- const inspectsFiles = BASH_SECRET_READ_PREFIXES.includes(verb);
342
-
343
- if (inspectsFiles) {
344
- for (const token of tokens.slice(1)) {
345
- if (!looksLikePath(token)) continue;
346
- const resolved = resolvePathToken(token, cwd);
347
- const fileScan = scanFile(resolved, { provider: options.provider });
348
- if (!fileScan.detected) continue;
349
- findings.push(...fileScan.findings.map((finding) => ({
350
- ...finding,
351
- source: 'command_file',
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 scanHookInput(input = {}, options = {}) {
375
- const toolName = String(input.tool_name || input.toolName || '').trim();
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
- if (!EDIT_LIKE_TOOLS.has(toolName)) {
392
- const paths = getToolInputPaths(toolInput, cwd);
393
- for (const filePath of paths) {
394
- const result = scanFile(filePath, { provider });
395
- if (result.detected) {
396
- provider = result.provider;
397
- fileHashes.push(result.fileHash);
398
- findings.push(...result.findings);
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
- if (typeof toolInput.command === 'string' && toolInput.command.trim()) {
404
- const result = scanBashCommand(toolInput.command, { provider, cwd });
405
- if (result.detected) {
406
- provider = result.provider;
407
- commandHash = result.commandHash;
408
- findings.push(...result.findings);
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
- 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
- }
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 is the single source of truth for "is this edit touching the
13
- * firewall's own kill-switches", used by BOTH the shipped `npx thumbgate
14
- * gate-check` entrypoint and this repo's dogfood PreToolUse hook.
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): never hard-block legitimate work.
19
- * * Self-lockout lesson (2026-07-07): a PreToolUse hook must NEVER deny the
20
- * tools needed to repair its own config, or the agent is bricked.
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
- * Resolution:
23
- * default -> WARN (surfaced + logged, never silent)
24
- * THUMBGATE_STRICT_ENFORCEMENT=1 -> BLOCK
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'|'warn', target:string, message:string}|null}
54
+ * @returns {{action:'block', target:string, message:string}|null}
59
55
  */
60
- function evaluateSelfProtection(toolName, toolInput, env = process.env) {
56
+ function evaluateSelfProtection(toolName, toolInput) {
61
57
  const target = selfProtectionTarget(toolName, toolInput);
62
58
  if (!target) return null;
63
- // Escape hatch: an operator repairing the gate opts out explicitly. Honors the
64
- // self-lockout lesson the repair path is never denied.
65
- if (isTrueEnv(env.THUMBGATE_ALLOW_SELF_EDIT)) return null;
66
- if (isTrueEnv(env.THUMBGATE_STRICT_ENFORCEMENT)) {
67
- return {
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: 'warn',
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