thumbgate 1.27.19 → 1.27.20

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 (87) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +1 -1
  9. package/.well-known/mcp/server-card.json +1 -1
  10. package/README.md +0 -3
  11. package/adapters/claude/.mcp.json +2 -2
  12. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +1 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +27 -1
  21. package/bin/dashboard-cli.js +7 -0
  22. package/bin/postinstall.js +14 -23
  23. package/commands/dashboard.md +15 -0
  24. package/commands/thumbgate-dashboard.md +15 -0
  25. package/package.json +225 -100
  26. package/public/about.html +162 -0
  27. package/public/agent-manager.html +179 -0
  28. package/public/agents-cost-savings.html +153 -0
  29. package/public/ai-malpractice-prevention.html +818 -0
  30. package/public/assets/brand/github-social-preview.png +0 -0
  31. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  32. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  33. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  34. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  35. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  36. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  37. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  38. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  39. package/public/assets/brand/thumbgate-mark.svg +21 -0
  40. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  41. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  42. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  43. package/public/assets/legal-intake-control-flow.svg +66 -0
  44. package/public/brand/thumbgate-mark.svg +19 -0
  45. package/public/brand/thumbgate-og.svg +16 -0
  46. package/public/chatgpt-app.html +330 -0
  47. package/public/codex-enterprise.html +123 -0
  48. package/public/diagnostic.html +345 -0
  49. package/public/index.html +2 -2
  50. package/public/install.html +193 -0
  51. package/public/js/buyer-intent.js +672 -0
  52. package/public/numbers.html +2 -2
  53. package/public/pricing.html +399 -0
  54. package/scripts/action-receipts.js +324 -0
  55. package/scripts/activation-quickstart.js +187 -0
  56. package/scripts/agent-operations-planner.js +621 -0
  57. package/scripts/ai-component-inventory.js +367 -0
  58. package/scripts/async-eval-observability.js +236 -0
  59. package/scripts/audit.js +65 -0
  60. package/scripts/aws-blocks-guardrails.js +272 -0
  61. package/scripts/classifier-routing.js +130 -0
  62. package/scripts/dashboard-chat.js +332 -0
  63. package/scripts/feedback-aggregate.js +281 -0
  64. package/scripts/feedback-sanitizer.js +105 -0
  65. package/scripts/hook-stop-anti-claim.js +301 -0
  66. package/scripts/install-shim.js +87 -0
  67. package/scripts/mcp-oauth.js +293 -0
  68. package/scripts/noop-detect.js +285 -0
  69. package/scripts/parallel-workflow-orchestrator.js +293 -0
  70. package/scripts/plan-gate.js +243 -0
  71. package/scripts/plausible-domain-config.js +99 -0
  72. package/scripts/qa-scenario-planner.js +136 -0
  73. package/scripts/repeat-metric.js +137 -0
  74. package/scripts/secret-fixture-tokens.js +61 -0
  75. package/scripts/secret-redaction.js +166 -0
  76. package/scripts/self-harness-optimizer.js +141 -0
  77. package/scripts/self-healing-check.js +193 -0
  78. package/scripts/self-protection.js +90 -0
  79. package/scripts/silent-failure-cluster.js +531 -0
  80. package/scripts/statusline-cache-read.js +57 -0
  81. package/scripts/sync-telemetry-from-prod.js +374 -0
  82. package/scripts/tool-contract-validator.js +76 -0
  83. package/scripts/trajectory-scorer.js +63 -0
  84. package/scripts/verify-marketing-pages-deployed.js +212 -0
  85. package/scripts/visitor-journey.js +172 -0
  86. package/.claude-plugin/marketplace.json +0 -85
  87. package/adapters/chatgpt/openapi.yaml +0 -1707
@@ -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
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // repeat-metric — first-class "repeat-attempts blocked before execution" metric
5
+ //
6
+ // This module exposes data ThumbGate already collects in gate-stats state. It
7
+ // does NOT write to disk; it is a pure function over gates-engine.loadStats().
8
+ //
9
+ // The headline number is stats.recurringBlocks — incremented by recordStat()
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
+ // ---------------------------------------------------------------------------
15
+
16
+ const gatesEngine = require('./gates-engine');
17
+
18
+ /**
19
+ * Derive a per-gate { firstBlocks, repeatBlocks } split from the raw stats.
20
+ *
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
+ *
28
+ * total block+warn events come from stats.byGate[id] (blocked + warned), which
29
+ * recordStat() also maintains. repeatBlocks is clamped to >= 0 to stay robust
30
+ * against partially-written / legacy state.
31
+ *
32
+ * @param {object} stats raw object returned by gates-engine.loadStats()
33
+ * @returns {Object<string,{firstBlocks:number, repeatBlocks:number}>}
34
+ */
35
+ function computeByGateSplit(stats) {
36
+ const byGate = {};
37
+ const sessionFiredActions = (stats && stats.sessionFiredActions) || {};
38
+ const sessionFiredGates = (stats && stats.sessionFiredGates) || {};
39
+ const rawByGate = (stats && stats.byGate) || {};
40
+
41
+ // Count distinct action fingerprints each gate fired on => firstBlocks.
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.
57
+ for (const sessionKey of Object.keys(sessionFiredGates)) {
58
+ const fired = sessionFiredGates[sessionKey] || {};
59
+ for (const gateId of Object.keys(fired)) {
60
+ if (fired[gateId] && !gatesWithActionStats.has(gateId)) {
61
+ firstBlocksByGate[gateId] = (firstBlocksByGate[gateId] || 0) + 1;
62
+ }
63
+ }
64
+ }
65
+
66
+ // Union of every gate id we know about from either source.
67
+ const gateIds = new Set([
68
+ ...Object.keys(rawByGate),
69
+ ...Object.keys(firstBlocksByGate),
70
+ ...Object.keys(sessionFiredActions).flatMap((sessionKey) => Object.keys(sessionFiredActions[sessionKey] || {})),
71
+ ]);
72
+
73
+ for (const gateId of gateIds) {
74
+ const gateStat = rawByGate[gateId] || {};
75
+ const totalFires = (gateStat.blocked || 0) + (gateStat.warned || 0);
76
+ const firstBlocks = firstBlocksByGate[gateId] || 0;
77
+ // Repeat fires are total fires beyond the first fire per session bucket.
78
+ const repeatBlocks = Math.max(0, totalFires - firstBlocks);
79
+ byGate[gateId] = { firstBlocks, repeatBlocks };
80
+ }
81
+
82
+ return byGate;
83
+ }
84
+
85
+ /**
86
+ * Compute the repeat-attempts-blocked-before-execution metric.
87
+ *
88
+ * Pure read of gates-engine.loadStats(); no disk writes.
89
+ *
90
+ * @returns {{
91
+ * repeatBlocksBeforeExecution: number,
92
+ * recurringBlocks: number,
93
+ * totalBlocked: number,
94
+ * byGate: Object<string,{firstBlocks:number, repeatBlocks:number}>
95
+ * }}
96
+ */
97
+ function computeRepeatMetric() {
98
+ let stats;
99
+ try {
100
+ stats = gatesEngine.loadStats() || {};
101
+ } catch (_) {
102
+ stats = {};
103
+ }
104
+
105
+ const recurringBlocks = Number(stats.recurringBlocks || 0);
106
+ const totalBlocked = Number(stats.blocked || 0);
107
+
108
+ return {
109
+ // Headline: a pre-action block that stopped a tool call the agent had
110
+ // already been blocked on this session.
111
+ repeatBlocksBeforeExecution: recurringBlocks,
112
+ recurringBlocks,
113
+ totalBlocked,
114
+ byGate: computeByGateSplit(stats),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Add a `repeat` sub-key to a gate-stats object WITHOUT mutating the original.
120
+ *
121
+ * Takes the object returned by gate-stats.calculateStats() or
122
+ * dashboard.computeGateStats() and returns a shallow copy with the repeat
123
+ * metric attached. The caller's file does not need to import any internals.
124
+ *
125
+ * @param {object} gateStatsObject
126
+ * @returns {object} copy of gateStatsObject with `.repeat`
127
+ */
128
+ function mergeRepeatMetricIntoGateStats(gateStatsObject) {
129
+ const base = gateStatsObject && typeof gateStatsObject === 'object' ? gateStatsObject : {};
130
+ return Object.assign({}, base, { repeat: computeRepeatMetric() });
131
+ }
132
+
133
+ module.exports = {
134
+ computeRepeatMetric,
135
+ mergeRepeatMetricIntoGateStats,
136
+ computeByGateSplit,
137
+ };
@@ -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
+ };
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * secret-redaction.js — the single, canonical secret-redaction helper.
5
+ *
6
+ * WHY THIS EXISTS
7
+ * On 2026-06-10 a live Stripe `sk_live_` key was found in plaintext inside a captured
8
+ * `.thumbgate/conversation-window.jsonl`. ThumbGate must never become a secret-leak vector:
9
+ * secrets must be redacted (a) before any conversation/feedback/memory record lands on disk,
10
+ * and (b) before any training/analytics dataset is exported or shared.
11
+ *
12
+ * This module is the ONE place that owns redaction-at-rest. Capture writers and dataset
13
+ * exporters all call into it instead of each rolling their own. It is intentionally separate
14
+ * from `secret-scanner.js`: that module is the PreToolUse *scan/block* layer and deliberately
15
+ * tolerates `sk_test_`/`test_token` inside commands. For data persisted to disk we redact
16
+ * test-mode secrets too — a `sk_test_` key is still a credential we should not store.
17
+ *
18
+ * NOT REDACTED ON PURPOSE
19
+ * - Stripe publishable keys (`pk_live_`, `pk_test_`) are public by design. No pattern matches
20
+ * them, and the generic `key=value` pattern keys on `api_key|secret|token|...`, never on a
21
+ * bare `publishable_key`, so they are preserved verbatim.
22
+ *
23
+ * Properties:
24
+ * - Idempotent: re-redacting already-redacted text is a no-op (the `[REDACTED:id]` marker
25
+ * contains characters outside every value charset).
26
+ * - Non-mutating: `redactSecretsDeep` returns a redacted copy; inputs are untouched.
27
+ */
28
+
29
+ const REDACTED = (id) => `[REDACTED:${id}]`;
30
+
31
+ // Identifier names that denote a credential, used by the generic `key = value` pattern. Kept as
32
+ // two small matchers (instead of one mega-alternation) so the assignment regex stays simple.
33
+ const SECRET_KEY_NAME = /(?:api[_-]?key|secret|token|password|passwd|credential|private[_-]?key|access[_-]?token|client[_-]?secret)/i;
34
+ // Public identifiers whose values are not secrets (e.g. Stripe publishable `pk_*` keys). Excluded
35
+ // so `publishable_key = pk_live_…` is preserved verbatim.
36
+ const PUBLIC_KEY_NAME = /publishable|public/i;
37
+
38
+ /**
39
+ * Ordered list of redaction patterns. Order matters: the most specific provider patterns run
40
+ * before the broad `key=value` fallback so a known secret keeps its precise label. Every regex
41
+ * is `g` (and case-insensitive where appropriate) so all occurrences are replaced.
42
+ *
43
+ * `replace` may be a string or a function `(match, ...groups) => string`.
44
+ */
45
+ const SECRET_REDACTION_PATTERNS = [
46
+ // PEM / OpenSSH private key blocks — redact the whole block.
47
+ {
48
+ id: 'private_key_block',
49
+ label: 'Private key block',
50
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
51
+ },
52
+
53
+ // Stripe secret-bearing keys. Publishable keys (pk_*) are intentionally absent.
54
+ { id: 'stripe_live_secret', label: 'Stripe live secret key', regex: /\bsk_live_[A-Za-z0-9]{8,}/g },
55
+ { id: 'stripe_test_secret', label: 'Stripe test secret key', regex: /\bsk_test_[A-Za-z0-9]{8,}/g },
56
+ { id: 'stripe_restricted_live', label: 'Stripe restricted live key', regex: /\brk_live_[A-Za-z0-9]{8,}/g },
57
+ { id: 'stripe_restricted_test', label: 'Stripe restricted test key', regex: /\brk_test_[A-Za-z0-9]{8,}/g },
58
+ { id: 'stripe_webhook_secret', label: 'Stripe webhook signing secret', regex: /\bwhsec_[A-Za-z0-9]{8,}/g },
59
+
60
+ // Anthropic / OpenAI. Run before the legacy `sk-` pattern so the precise label wins.
61
+ { id: 'anthropic_api_key', label: 'Anthropic API key', regex: /\bsk-ant-[A-Za-z0-9_-]{16,}/g },
62
+ { id: 'openai_project_key', label: 'OpenAI project key', regex: /\bsk-proj-[A-Za-z0-9_-]{16,}/g },
63
+ { id: 'openai_api_key', label: 'OpenAI API key', regex: /\bsk-[A-Za-z0-9]{20,}/g },
64
+
65
+ // GitHub tokens.
66
+ { id: 'github_pat', label: 'GitHub personal access token', regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/g },
67
+ { id: 'github_fine_grained_pat', label: 'GitHub fine-grained token', regex: /\bgithub_pat_\w{20,}/g },
68
+
69
+ // Slack tokens.
70
+ { id: 'slack_token', label: 'Slack token', regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
71
+
72
+ // Google API key.
73
+ { id: 'google_api_key', label: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}/g },
74
+
75
+ // AWS access key id (long-lived AKIA + temporary ASIA).
76
+ { id: 'aws_access_key', label: 'AWS access key id', regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
77
+
78
+ // JSON Web Tokens.
79
+ {
80
+ id: 'jwt',
81
+ label: 'JSON Web Token',
82
+ regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}\.[A-Za-z0-9._-]{8,}/g,
83
+ },
84
+
85
+ // `Authorization: Bearer <token>` style. Keep the scheme word, redact the credential.
86
+ {
87
+ id: 'bearer_token',
88
+ label: 'Bearer token',
89
+ regex: /\b(Bearer|Token)\s+[a-z0-9._~+/=-]{16,}/gi,
90
+ replace: (_m, scheme) => `${scheme} ${REDACTED('bearer_token')}`,
91
+ },
92
+
93
+ // Generic `<name> = <value>` / `<name>: "<value>"` assignment with a 16+ char whitespace-free
94
+ // value. A simple matcher finds every assignment; the replace fn redacts only when the name is a
95
+ // credential (SECRET_KEY_NAME) and not a public identifier (PUBLIC_KEY_NAME), so `publishable_key`
96
+ // is preserved. Runs last so provider-specific labels are preferred.
97
+ {
98
+ id: 'secret_assignment',
99
+ label: 'Secret assignment',
100
+ regex: /\b([\w.-]{2,40})(\s*[:=]\s*["']?)([A-Za-z0-9._+=~/-]{16,})(["']?)/g,
101
+ replace: (match, key, pre, _value, post) =>
102
+ (SECRET_KEY_NAME.test(key) && !PUBLIC_KEY_NAME.test(key))
103
+ ? `${key}${pre}${REDACTED('secret_assignment')}${post}`
104
+ : match,
105
+ },
106
+ ];
107
+
108
+ /**
109
+ * Redact secrets from a single string. Returns the input unchanged when it is not a non-empty
110
+ * string. Each pattern's lastIndex is reset before use so the module-level (stateful, `g`-flag)
111
+ * regexes are safe to reuse across calls.
112
+ *
113
+ * @param {string} text
114
+ * @returns {string}
115
+ */
116
+ function redactSecrets(text) {
117
+ if (typeof text !== 'string' || text.length === 0) return text;
118
+ let out = text;
119
+ for (const pattern of SECRET_REDACTION_PATTERNS) {
120
+ pattern.regex.lastIndex = 0;
121
+ const replacement = pattern.replace || REDACTED(pattern.id);
122
+ out = out.replace(pattern.regex, replacement);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Returns true if `text` contains at least one redactable secret.
129
+ * @param {string} text
130
+ * @returns {boolean}
131
+ */
132
+ function containsSecret(text) {
133
+ if (typeof text !== 'string' || text.length === 0) return false;
134
+ return SECRET_REDACTION_PATTERNS.some((pattern) => {
135
+ pattern.regex.lastIndex = 0;
136
+ return pattern.regex.test(text);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Recursively redact every string contained in `value`. Objects and arrays are deep-copied so
142
+ * the input is never mutated; non-string primitives pass through untouched. Object keys are left
143
+ * as-is (only values are redacted).
144
+ *
145
+ * @param {*} value
146
+ * @returns {*} redacted copy
147
+ */
148
+ function redactSecretsDeep(value) {
149
+ if (typeof value === 'string') return redactSecrets(value);
150
+ if (Array.isArray(value)) return value.map((item) => redactSecretsDeep(item));
151
+ if (value && typeof value === 'object') {
152
+ const out = {};
153
+ for (const [key, val] of Object.entries(value)) {
154
+ out[key] = redactSecretsDeep(val);
155
+ }
156
+ return out;
157
+ }
158
+ return value;
159
+ }
160
+
161
+ module.exports = {
162
+ SECRET_REDACTION_PATTERNS,
163
+ redactSecrets,
164
+ redactSecretsDeep,
165
+ containsSecret,
166
+ };
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Self-Harness Optimizer
4
+ *
5
+ * Implements the continuous self-improving loop from the "Self-Harness" paradigm (arXiv:2606.09498):
6
+ * 1. Weakness Mining: Reads auto-promoted gates from repeated failures (.thumbgate/auto-promoted-gates.json)
7
+ * 2. Harness Proposal: Generates technical rules and injects them directly into system prompts (AGENTS.md & GEMINI.md)
8
+ * 3. Validation: Runs quick test suites to ensure prompt updates do not introduce regressions.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+
15
+ const ROOT = path.join(__dirname, '..');
16
+ const GATES_PATH = path.join(ROOT, '.thumbgate', 'auto-promoted-gates.json');
17
+ const AGENTS_MD_PATH = path.join(ROOT, 'AGENTS.md');
18
+ const GEMINI_MD_PATH = path.join(ROOT, 'GEMINI.md');
19
+
20
+ const RULE_SECTION_HEADER = '## 🛡️ Self-Harness Prevention Rules (Auto-Generated)';
21
+
22
+ function readGates() {
23
+ if (!fs.existsSync(GATES_PATH)) return [];
24
+ try {
25
+ const data = JSON.parse(fs.readFileSync(GATES_PATH, 'utf-8'));
26
+ return Array.isArray(data.gates) ? data.gates : [];
27
+ } catch (err) {
28
+ console.error('Failed to parse auto-promoted-gates.json:', err);
29
+ return [];
30
+ }
31
+ }
32
+
33
+ function generateRuleSection(gates) {
34
+ if (gates.length === 0) {
35
+ return `${RULE_SECTION_HEADER}\n\n- No active auto-generated prevention rules at this time.\n`;
36
+ }
37
+
38
+ const lines = [
39
+ RULE_SECTION_HEADER,
40
+ '',
41
+ '> [!IMPORTANT]',
42
+ '> The following rules were automatically derived from execution failures and thumbs-down feedback.',
43
+ '> You MUST follow these constraints strictly to prevent repeated errors.',
44
+ ''
45
+ ];
46
+
47
+ gates.forEach(gate => {
48
+ // Extract rule message or pattern
49
+ const ruleText = gate.message ? gate.message.replace('Automatically blocked due to repeated failures: ', '') : `NEVER match pattern: ${gate.pattern}`;
50
+ lines.push(`- **Rule [${gate.id}]**: ${ruleText}`);
51
+ });
52
+
53
+ lines.push('');
54
+ return lines.join('\n');
55
+ }
56
+
57
+ function updatePromptFile(filePath, newSection) {
58
+ if (!fs.existsSync(filePath)) {
59
+ console.warn(`Prompt file not found: ${filePath}`);
60
+ return null;
61
+ }
62
+
63
+ const content = fs.readFileSync(filePath, 'utf-8');
64
+ const backup = content;
65
+
66
+ let newContent;
67
+ const index = content.indexOf(RULE_SECTION_HEADER);
68
+ if (index !== -1) {
69
+ // Replace existing section till the end or next major section
70
+ const before = content.substring(0, index);
71
+ const remainder = content.substring(index);
72
+
73
+ // Find next header (e.g. '## ')
74
+ const nextHeaderIndex = remainder.slice(RULE_SECTION_HEADER.length).search(/\n## /);
75
+ if (nextHeaderIndex !== -1) {
76
+ const after = remainder.substring(RULE_SECTION_HEADER.length + nextHeaderIndex + 1);
77
+ newContent = before + newSection + after;
78
+ } else {
79
+ newContent = before + newSection;
80
+ }
81
+ } else {
82
+ // Append to the end of the file
83
+ newContent = content.trim() + '\n\n' + newSection;
84
+ }
85
+
86
+ fs.writeFileSync(filePath, newContent, 'utf-8');
87
+ return backup;
88
+ }
89
+
90
+ function validateAndRollback(backups) {
91
+ console.log('Running quick validation suite...');
92
+ try {
93
+ execSync('npm run verify:quick', { cwd: ROOT, stdio: 'inherit' });
94
+ console.log('✅ Validation succeeded! No regressions detected.');
95
+
96
+ // Auto-commit prompt updates to git if validation succeeds
97
+ try {
98
+ execSync('git add AGENTS.md GEMINI.md', { cwd: ROOT, stdio: 'ignore' });
99
+ execSync('git commit -m "chore(prompt): auto-update self-harness prevention rules [auto-commit]"', { cwd: ROOT, stdio: 'ignore' });
100
+ console.log('✅ Prompt updates auto-committed to git.');
101
+ } catch (gitErr) {
102
+ console.warn('⚠️ Git commit failed or no changes to commit:', gitErr.message);
103
+ }
104
+
105
+ return true;
106
+ } catch (err) {
107
+ console.error('❌ Validation failed! Reverting prompt changes.');
108
+ for (const [filePath, backupContent] of Object.entries(backups)) {
109
+ if (backupContent) {
110
+ fs.writeFileSync(filePath, backupContent, 'utf-8');
111
+ console.log(`Reverted: ${path.basename(filePath)}`);
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function main() {
119
+ console.log('Mining weaknesses from auto-promoted gates...');
120
+ const gates = readGates();
121
+ console.log(`Found ${gates.length} active gates.`);
122
+
123
+ const newSection = generateRuleSection(gates);
124
+ const backups = {};
125
+
126
+ console.log('Updating AGENTS.md...');
127
+ backups[AGENTS_MD_PATH] = updatePromptFile(AGENTS_MD_PATH, newSection);
128
+
129
+ console.log('Updating GEMINI.md...');
130
+ backups[GEMINI_MD_PATH] = updatePromptFile(GEMINI_MD_PATH, newSection);
131
+
132
+ const success = validateAndRollback(backups);
133
+ if (!success) {
134
+ process.exit(1);
135
+ }
136
+ console.log('Self-Harness optimization complete.');
137
+ }
138
+
139
+ if (require.main === module) {
140
+ main();
141
+ }