thumbgate 1.27.6 → 1.27.7

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 (96) hide show
  1. package/.claude/commands/thumbgate-blocked.md +27 -0
  2. package/.claude/commands/thumbgate-doctor.md +30 -0
  3. package/.claude/commands/thumbgate-guard.md +36 -0
  4. package/.claude/commands/thumbgate-protect.md +30 -0
  5. package/.claude/commands/thumbgate-rules.md +30 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +6 -2
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/README.md +49 -5
  10. package/adapters/claude/.mcp.json +2 -2
  11. package/adapters/letta/README.md +41 -0
  12. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  13. package/adapters/mcp/server-stdio.js +16 -1
  14. package/adapters/opencode/opencode.json +1 -1
  15. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  16. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  17. package/bench/observability-eval-suite.json +26 -0
  18. package/bin/cli.js +180 -2
  19. package/bin/postinstall.js +1 -1
  20. package/config/gate-templates.json +84 -0
  21. package/config/gates/claim-verification.json +6 -0
  22. package/config/gates/default.json +20 -0
  23. package/config/github-about.json +1 -1
  24. package/config/model-candidates.json +50 -0
  25. package/package.json +65 -25
  26. package/public/agent-manager.html +41 -1
  27. package/public/agents-cost-savings.html +1 -1
  28. package/public/ai-malpractice-prevention.html +2 -1
  29. package/public/assets/brand/github-social-preview.png +0 -0
  30. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  31. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  32. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  33. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  34. package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
  35. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  36. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  37. package/public/assets/brand/thumbgate-mark.svg +15 -0
  38. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  39. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  40. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  41. package/public/assets/legal-intake-control-flow.svg +66 -0
  42. package/public/blog.html +1 -1
  43. package/public/brand/thumbgate-mark.svg +15 -0
  44. package/public/brand/thumbgate-og.svg +16 -0
  45. package/public/codex-enterprise.html +1 -1
  46. package/public/codex-plugin.html +1 -1
  47. package/public/compare.html +23 -3
  48. package/public/dashboard.html +312 -30
  49. package/public/federal.html +1 -1
  50. package/public/guide.html +5 -4
  51. package/public/index.html +167 -49
  52. package/public/js/buyer-intent.js +672 -0
  53. package/public/learn.html +74 -7
  54. package/public/lessons.html +2 -1
  55. package/public/numbers.html +3 -3
  56. package/public/pricing.html +63 -15
  57. package/public/pro.html +7 -7
  58. package/scripts/activation-quickstart.js +187 -0
  59. package/scripts/agent-memory-lifecycle.js +211 -0
  60. package/scripts/async-eval-observability.js +236 -0
  61. package/scripts/auto-promote-gates.js +75 -4
  62. package/scripts/build-metadata.js +24 -3
  63. package/scripts/cli-schema.js +22 -0
  64. package/scripts/dashboard-chat.js +2 -1
  65. package/scripts/dashboard.js +8 -0
  66. package/scripts/export-databricks-bundle.js +5 -1
  67. package/scripts/export-dpo-pairs.js +7 -2
  68. package/scripts/feedback-aggregate.js +281 -0
  69. package/scripts/feedback-loop.js +34 -0
  70. package/scripts/filesystem-search.js +35 -10
  71. package/scripts/gates-engine.js +198 -6
  72. package/scripts/gemini-embedding-policy.js +2 -1
  73. package/scripts/hook-stop-anti-claim.js +227 -0
  74. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  75. package/scripts/lesson-inference.js +8 -3
  76. package/scripts/lesson-search.js +17 -1
  77. package/scripts/operational-integrity.js +39 -5
  78. package/scripts/plausible-domain-config.js +4 -2
  79. package/scripts/rate-limiter.js +12 -6
  80. package/scripts/secret-redaction.js +166 -0
  81. package/scripts/security-scanner.js +100 -0
  82. package/scripts/self-distill-agent.js +3 -1
  83. package/scripts/self-harness-optimizer.js +141 -0
  84. package/scripts/seo-gsd.js +635 -0
  85. package/scripts/statusline-cache-path.js +17 -2
  86. package/scripts/statusline-cache-read.js +57 -0
  87. package/scripts/statusline-local-stats.js +9 -1
  88. package/scripts/statusline-meta.js +5 -2
  89. package/scripts/statusline.sh +13 -1
  90. package/scripts/sync-telemetry-from-prod.js +374 -0
  91. package/scripts/telemetry-analytics.js +9 -0
  92. package/scripts/thumbgate-search.js +85 -19
  93. package/scripts/tool-contract-validator.js +76 -0
  94. package/scripts/vector-store.js +44 -0
  95. package/scripts/workspace-evolver.js +62 -2
  96. package/src/api/server.js +715 -86
@@ -580,17 +580,51 @@ function resolveGitHubRepository(env = process.env) {
580
580
  function findOpenPrForBranch({ branchName, runner = runGh, env = process.env } = {}) {
581
581
  const normalizedBranch = String(branchName || '').trim();
582
582
  if (!normalizedBranch) return null;
583
- if (!env.GH_TOKEN && !env.GITHUB_TOKEN) {
583
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN;
584
+ if (!token) {
584
585
  return null;
585
586
  }
586
- const args = ['pr', 'list'];
587
587
  const repository = resolveGitHubRepository(env);
588
- if (repository) {
589
- args.push('--repo', repository);
588
+ if (!repository) return null;
589
+
590
+ // Try REST API first as it is much more robust and doesn't rely on gh CLI's GraphQL auth
591
+ try {
592
+ const owner = repository.split('/')[0];
593
+ const url = `https://api.github.com/repos/${repository}/pulls?head=${encodeURIComponent(owner + ':' + normalizedBranch)}&state=open`;
594
+ const curlConfig = `header = "Authorization: token ${token}"\nheader = "User-Agent: ThumbGate-CI"`;
595
+ const curlResult = spawnSync('curl', [
596
+ '-s',
597
+ '-K', '-',
598
+ url
599
+ ], {
600
+ encoding: 'utf8',
601
+ input: curlConfig
602
+ });
603
+
604
+ if (curlResult && curlResult.status === 0) {
605
+ const prs = JSON.parse(curlResult.stdout || '[]');
606
+ if (Array.isArray(prs) && prs.length > 0) {
607
+ return {
608
+ number: prs[0].number,
609
+ state: prs[0].state,
610
+ isDraft: prs[0].draft || false,
611
+ url: prs[0].html_url
612
+ };
613
+ }
614
+ }
615
+ } catch (err) {
616
+ console.warn(`[findOpenPrForBranch] REST fallback failed: ${err.message}`);
590
617
  }
591
- args.push('--head', normalizedBranch, '--state', 'open', '--json', 'number,state,isDraft,url');
618
+
619
+ // Fall back to gh CLI
620
+ const args = ['pr', 'list', '--repo', repository, '--head', normalizedBranch, '--state', 'open', '--json', 'number,state,isDraft,url'];
592
621
  const result = runner(args);
593
622
  if (!result || result.status !== 0) {
623
+ if (result) {
624
+ console.warn(`[findOpenPrForBranch] gh command failed with status ${result.status}. Stderr: ${result.stderr || ''}`);
625
+ } else {
626
+ console.warn(`[findOpenPrForBranch] gh command failed to spawn.`);
627
+ }
594
628
  return null;
595
629
  }
596
630
  try {
@@ -14,9 +14,11 @@ function normalizeDomain(value) {
14
14
  const input = String(value || '').trim();
15
15
  if (!input) return '';
16
16
  try {
17
- return new URL(input.includes('://') ? input : `https://${input}`).host.toLowerCase();
17
+ return new URL(input.includes('://') ? input : `https://${input}`).hostname.toLowerCase();
18
18
  } catch {
19
- return input.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
19
+ const withoutProtocol = input.replace(/^https?:\/\//i, '');
20
+ const hostnameAndPort = withoutProtocol.split('/')[0];
21
+ return hostnameAndPort.toLowerCase().split(':')[0];
20
22
  }
21
23
  }
22
24
 
@@ -17,8 +17,8 @@ const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-li
17
17
  // hit the wall within the first week, not the first quarter.
18
18
  // ──────────────────────────────────────────────────────────
19
19
  const FREE_TIER_LIMITS = {
20
- capture_feedback: { daily: 5, lifetime: 25, label: 'feedback captures (5/day, 25 total on free)' },
21
- prevention_rules: { daily: 2, lifetime: 6, label: 'prevention rules generated (2/day on free)' },
20
+ capture_feedback: { daily: 2, lifetime: 10, label: 'feedback captures (2/day, 10 total on free)' },
21
+ prevention_rules: { daily: 2, lifetime: 3, label: 'prevention rules generated (2/day on free)' },
22
22
  recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
23
23
  search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
24
24
  search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
@@ -29,12 +29,12 @@ const FREE_TIER_LIMITS = {
29
29
  };
30
30
 
31
31
  const FREE_TIER_MAX_GATES = 3; // 3 active prevention rules on free; Pro is unlimited
32
- const FREE_TIER_DAILY_BLOCKS = 3; // 3 gate blocks/day on free; after limit, deny → warn + upgrade CTA
32
+ const FREE_TIER_DAILY_BLOCKS = 2; // 3 gate blocks/day on free; after limit, deny → warn + upgrade CTA
33
33
 
34
34
  const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Enterprise: ${ENTERPRISE_PRICE_LABEL} after workflow qualification.`;
35
35
 
36
36
  const PAYWALL_MESSAGES = {
37
- capture_feedback: 'Free tier: 5 captures/day (25 total). Your feedback is stored locally — upgrade to capture unlimited.',
37
+ capture_feedback: 'Free tier: 2 captures/day (10 total). Your feedback is stored locally — upgrade to capture unlimited.',
38
38
  prevention_rules: 'Free tier includes 3 active prevention rules and 2 rule generations/day. Upgrade to Pro for unlimited rules.',
39
39
  recall: 'Recall is a Pro feature. Your past feedback is stored locally — upgrade to search and reuse it.',
40
40
  search_lessons: 'Lesson search is a Pro feature. Upgrade to find patterns in your agent\'s mistakes.',
@@ -153,12 +153,18 @@ function checkLimit(action, authContext) {
153
153
  const dailyCurrent = usage.counts[action] || 0;
154
154
  const lifetimeCurrent = usage.lifetime[action] || 0;
155
155
 
156
+ const isCapture = action === 'capture_feedback';
157
+ const monthlyLink = isCapture
158
+ ? 'https://buy.stripe.com/7sYfZhaiE1eSbO99uj3sI0d'
159
+ : PRO_MONTHLY_PAYMENT_LINK;
160
+ const upgradeMessage = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${monthlyLink}\n Enterprise: ${ENTERPRISE_PRICE_LABEL} after workflow qualification.`;
161
+
156
162
  // Check lifetime limit first (the hard wall)
157
163
  if (lifetimeLimit !== Infinity && lifetimeCurrent >= lifetimeLimit) {
158
164
  const paywallMsg = PAYWALL_MESSAGES[action] || PAYWALL_MESSAGES.default;
159
165
  return {
160
166
  allowed: false,
161
- message: `${paywallMsg}\n\n${UPGRADE_MESSAGE}`,
167
+ message: `${paywallMsg}\n\n${upgradeMessage}`,
162
168
  used: lifetimeCurrent,
163
169
  limit: lifetimeLimit,
164
170
  limitType: 'lifetime',
@@ -170,7 +176,7 @@ function checkLimit(action, authContext) {
170
176
  const paywallMsg = PAYWALL_MESSAGES[action] || PAYWALL_MESSAGES.default;
171
177
  return {
172
178
  allowed: false,
173
- message: `Daily limit reached. ${paywallMsg}\n\n${UPGRADE_MESSAGE}`,
179
+ message: `Daily limit reached. ${paywallMsg}\n\n${upgradeMessage}`,
174
180
  used: dailyCurrent,
175
181
  limit: dailyLimit,
176
182
  limitType: 'daily',
@@ -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
+ };
@@ -37,6 +37,7 @@ const VULN_PATTERNS = [
37
37
  label: 'Command injection via unsanitized input',
38
38
  regex: /\bexec(?:Sync)?\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+\s*(?:req\.|input|args|params|query|body|user))/g,
39
39
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
40
+ remediation: 'Avoid passing unsanitized input to child_process.exec/execSync. Use execFile or spawn with args passed as an array.',
40
41
  },
41
42
  {
42
43
  id: 'shell-interpolation',
@@ -45,6 +46,7 @@ const VULN_PATTERNS = [
45
46
  label: 'Shell command with string interpolation',
46
47
  regex: /\bexec(?:Sync)?\s*\(\s*`[^`]*\$\{[^}]*(?:req\.|input|args|params|query|body|user|process\.env)/g,
47
48
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
49
+ remediation: 'Use child_process.execFile or child_process.spawn with an array of arguments to avoid shell command interpolation.',
48
50
  },
49
51
  {
50
52
  id: 'sql-injection',
@@ -53,6 +55,7 @@ const VULN_PATTERNS = [
53
55
  label: 'Potential SQL injection via string concatenation',
54
56
  regex: /(?:query|execute|run|all|get)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+\s*(?:req\.|input|args|params|query|body|user))/g,
55
57
  fileTypes: ['.js', '.ts', '.mjs', '.cjs', '.py'],
58
+ remediation: 'Use parameterized queries or prepared statements instead of dynamic SQL string concatenation.',
56
59
  },
57
60
  {
58
61
  id: 'eval-usage',
@@ -61,6 +64,7 @@ const VULN_PATTERNS = [
61
64
  label: 'Dynamic code execution (eval/Function constructor)',
62
65
  regex: /\b(?:eval|new\s+Function)\s*\([^)]*(?:req\.|input|args|params|query|body|user)/g,
63
66
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
67
+ remediation: 'Use JSON.parse() or a safe parser library instead of eval() or dynamic Function constructors.',
64
68
  },
65
69
 
66
70
  // XSS
@@ -71,6 +75,7 @@ const VULN_PATTERNS = [
71
75
  label: 'Potential XSS via innerHTML assignment',
72
76
  regex: /\.innerHTML\s*=\s*(?!['"]<(?:div|span|p|br|hr)\s*\/?>['"])/g,
73
77
  fileTypes: ['.js', '.ts', '.jsx', '.tsx', '.mjs'],
78
+ remediation: 'Use element.textContent or element.innerText instead of innerHTML to prevent cross-site scripting (XSS).',
74
79
  },
75
80
  {
76
81
  id: 'xss-dangerously-set',
@@ -79,6 +84,7 @@ const VULN_PATTERNS = [
79
84
  label: 'React dangerouslySetInnerHTML with dynamic content',
80
85
  regex: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*(?!['"])/g,
81
86
  fileTypes: ['.jsx', '.tsx', '.js', '.ts'],
87
+ remediation: 'Ensure dynamic content passed to dangerouslySetInnerHTML is sanitized using DOMPurify or equivalent.',
82
88
  },
83
89
 
84
90
  // Path traversal
@@ -89,6 +95,7 @@ const VULN_PATTERNS = [
89
95
  label: 'Path traversal via unsanitized user input',
90
96
  regex: /path\.(?:join|resolve)\s*\([^)]*(?:req\.|input|args|params|query|body|user)/g,
91
97
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
98
+ remediation: 'Sanitize path input using path.basename() or validate the path against an explicit list of allowed directories.',
92
99
  },
93
100
  {
94
101
  id: 'path-traversal-direct',
@@ -97,6 +104,7 @@ const VULN_PATTERNS = [
97
104
  label: 'Direct file read with user-controlled path',
98
105
  regex: /fs\.(?:readFile(?:Sync)?|createReadStream)\s*\(\s*(?:req\.|input|args|params|query|body|user)/g,
99
106
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
107
+ remediation: 'Validate input paths and restrict file system access to a sandboxed directory.',
100
108
  },
101
109
 
102
110
  // Prototype pollution
@@ -107,6 +115,7 @@ const VULN_PATTERNS = [
107
115
  label: 'Potential prototype pollution via recursive merge',
108
116
  regex: /(?:__proto__|constructor\s*\[\s*['"]prototype['"]\s*\]|Object\.assign\s*\(\s*\{\s*\})/g,
109
117
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
118
+ remediation: 'Avoid recursive object mergers without checking for __proto__ or constructor keys, or use Object.create(null).',
110
119
  },
111
120
 
112
121
  // Insecure crypto
@@ -117,6 +126,7 @@ const VULN_PATTERNS = [
117
126
  label: 'Weak hash algorithm (MD5/SHA1) for security use',
118
127
  regex: /createHash\s*\(\s*['"](?:md5|sha1)['"]\s*\)/gi,
119
128
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
129
+ remediation: 'Use SHA-256 or SHA-512 (e.g. crypto.createHash("sha256")) instead of MD5 or SHA-1 for security use cases.',
120
130
  },
121
131
  {
122
132
  id: 'hardcoded-secret',
@@ -125,6 +135,7 @@ const VULN_PATTERNS = [
125
135
  label: 'Hardcoded secret/password in source code',
126
136
  regex: /(?:password|secret|apiKey|api_key|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{12,}['"]/g,
127
137
  fileTypes: ['.js', '.ts', '.mjs', '.cjs', '.py', '.go', '.java'],
138
+ remediation: 'Move hardcoded secrets to environment variables or use a secure secrets manager.',
128
139
  },
129
140
 
130
141
  // SSRF
@@ -135,6 +146,7 @@ const VULN_PATTERNS = [
135
146
  label: 'Potential SSRF via user-controlled URL',
136
147
  regex: /(?:fetch|axios|got|request|https?\.(?:get|request))\s*\(\s*(?:`[^`]*\$\{|(?:req\.|input|args|params|query|body|user))/g,
137
148
  fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
149
+ remediation: 'Validate the destination host against a strict whitelist of allowed domains and block requests to internal IPs.',
138
150
  },
139
151
 
140
152
  // Insecure deserialization
@@ -145,6 +157,7 @@ const VULN_PATTERNS = [
145
157
  label: 'Unsafe deserialization of untrusted data',
146
158
  regex: /(?:unserialize|yaml\.load\s*\((?!.*Loader\s*=\s*yaml\.SafeLoader)|pickle\.loads?|Marshal\.load)/g,
147
159
  fileTypes: ['.js', '.ts', '.py', '.rb'],
160
+ remediation: 'Use safe parsing libraries or options (such as yaml.safeLoad) instead of unsafe deserialization/eval-like loaders.',
148
161
  },
149
162
  {
150
163
  id: 'badhost-url-confusion',
@@ -153,6 +166,7 @@ const VULN_PATTERNS = [
153
166
  label: 'Potential BadHost-style host or URL confusion in AI service',
154
167
  regex: /\b(?:request\.url(?:\.path)?|url_for\s*\([^)]*_external\s*=\s*True|headers\s*\[\s*['"](?:host|x-forwarded-host)['"]\s*\])/gi,
155
168
  fileTypes: ['.py'],
169
+ remediation: 'Verify Host and X-Forwarded-Host headers against an approved whitelist before using them for routing or URL generation.',
156
170
  },
157
171
  ];
158
172
 
@@ -166,6 +180,7 @@ const SUPPLY_CHAIN_PATTERNS = [
166
180
  category: 'supply-chain',
167
181
  severity: 'high',
168
182
  label: 'Potentially typosquatted package name',
183
+ remediation: 'Verify spelling and authenticity of package name. If typosquatted, run: npm uninstall <package>.',
169
184
  // Common typosquat indicators: single-char substitutions of popular packages
170
185
  knownSafe: new Set([
171
186
  'express', 'lodash', 'axios', 'react', 'vue', 'angular', 'moment',
@@ -179,6 +194,7 @@ const SUPPLY_CHAIN_PATTERNS = [
179
194
  category: 'supply-chain',
180
195
  severity: 'critical',
181
196
  label: 'Suspicious install script in package.json',
197
+ remediation: 'Remove the pre/postinstall script, or run package installation with --ignore-scripts.',
182
198
  regex: /["'](?:pre|post)?install["']\s*:\s*["'](?:.*(?:curl|wget|nc\s|bash\s|sh\s|eval|exec|child_process))/g,
183
199
  },
184
200
  {
@@ -186,10 +202,79 @@ const SUPPLY_CHAIN_PATTERNS = [
186
202
  category: 'supply-chain',
187
203
  severity: 'medium',
188
204
  label: 'Wildcard or latest version in dependency',
205
+ remediation: 'Specify a concrete version constraint (e.g., "^1.0.0") instead of a wildcard or latest.',
189
206
  regex: /["'](?:dependencies|devDependencies|peerDependencies)["'][\s\S]{0,500}?["'][^"']+["']\s*:\s*["'](?:\*|latest|>=)/g,
190
207
  },
191
208
  ];
192
209
 
210
+ /**
211
+ * Simple static analysis check (reachability/usage check) to see if a package is imported.
212
+ * @param {string} pkg - The package name to search for
213
+ * @param {string} rootDir - Root directory to walk
214
+ * @returns {boolean}
215
+ */
216
+ function isPackageImported(pkg, rootDir = process.cwd()) {
217
+ const IGNORED_DIRS = new Set([
218
+ 'node_modules', '.git', 'dist', 'coverage', '.planning', '.artifacts', '.gemini', '.antigravitycli'
219
+ ]);
220
+ const FILE_EXTS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py']);
221
+
222
+ // Escape package name for regex
223
+ const escapedPkg = pkg.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
224
+ const importRegex = new RegExp(
225
+ `(?:require\\s*\\(\\s*['"]${escapedPkg}['"]\\s*\\)|from\\s*['"]${escapedPkg}['"]|import\\s*\\(\\s*['"]${escapedPkg}['"]\\s*\\)|import\\s+['"]${escapedPkg}['"])`,
226
+ 'i'
227
+ );
228
+
229
+ let found = false;
230
+ let fileCount = 0;
231
+ const maxFiles = 200; // safety limit to keep it fast (<50ms)
232
+
233
+ function walk(dir) {
234
+ if (found || fileCount >= maxFiles) return;
235
+ let files;
236
+ try {
237
+ files = fs.readdirSync(dir);
238
+ } catch {
239
+ return;
240
+ }
241
+
242
+ for (const file of files) {
243
+ if (found || fileCount >= maxFiles) return;
244
+ const fullPath = path.join(dir, file);
245
+ let stat;
246
+ try {
247
+ stat = fs.statSync(fullPath);
248
+ } catch {
249
+ continue;
250
+ }
251
+
252
+ if (stat.isDirectory()) {
253
+ if (!IGNORED_DIRS.has(file)) {
254
+ walk(fullPath);
255
+ }
256
+ } else if (stat.isFile()) {
257
+ const ext = path.extname(file).toLowerCase();
258
+ if (FILE_EXTS.has(ext)) {
259
+ fileCount++;
260
+ try {
261
+ const content = fs.readFileSync(fullPath, 'utf8');
262
+ if (importRegex.test(content)) {
263
+ found = true;
264
+ return;
265
+ }
266
+ } catch {
267
+ // ignore read errors
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ walk(rootDir);
275
+ return found;
276
+ }
277
+
193
278
  // ---------------------------------------------------------------------------
194
279
  // Core scanning functions
195
280
  // ---------------------------------------------------------------------------
@@ -227,6 +312,7 @@ function scanCode(content, filePath = '') {
227
312
  line: lineNumber,
228
313
  match: match[0].slice(0, 120),
229
314
  path: filePath,
315
+ remediation: pattern.remediation,
230
316
  });
231
317
  // Only report first match per pattern per file to avoid noise
232
318
  break;
@@ -287,6 +373,10 @@ function scanDependencyChange(oldContent, newContent) {
287
373
 
288
374
  for (const [pkg, version] of Object.entries(newDeps)) {
289
375
  if (!oldDeps[pkg]) {
376
+ // Run usage check (reachability)
377
+ const reachable = isPackageImported(pkg);
378
+ const reachability = reachable ? 'imported' : 'unimported';
379
+
290
380
  // New dependency added — check for red flags
291
381
  if (version === '*' || version === 'latest' || version.startsWith('>=')) {
292
382
  findings.push({
@@ -295,6 +385,9 @@ function scanDependencyChange(oldContent, newContent) {
295
385
  severity: 'medium',
296
386
  label: `Wildcard version for new dependency: ${pkg}@${version}`,
297
387
  path: 'package.json',
388
+ remediation: `Specify a concrete version constraint (e.g., "^${version === '*' || version === 'latest' ? '1.0.0' : version}") instead of a wildcard.`,
389
+ reachable,
390
+ reachability,
298
391
  });
299
392
  }
300
393
 
@@ -306,6 +399,9 @@ function scanDependencyChange(oldContent, newContent) {
306
399
  severity: 'high',
307
400
  label: `Suspiciously short package name: "${pkg}"`,
308
401
  path: 'package.json',
402
+ remediation: 'Double-check spelling and verify this package is not a typosquatting attempt.',
403
+ reachable,
404
+ reachability,
309
405
  });
310
406
  }
311
407
 
@@ -318,6 +414,9 @@ function scanDependencyChange(oldContent, newContent) {
318
414
  severity: slopsquatFinding.severity,
319
415
  label: slopsquatFinding.label,
320
416
  path: 'package.json',
417
+ remediation: `Verify spelling and authenticity of package "${pkg}". If typosquatted, run: npm uninstall ${pkg}.`,
418
+ reachable,
419
+ reachability,
321
420
  });
322
421
  }
323
422
  }
@@ -335,6 +434,7 @@ function scanDependencyChange(oldContent, newContent) {
335
434
  severity: 'critical',
336
435
  label: `Suspicious install script: ${name} → ${cmd.slice(0, 80)}`,
337
436
  path: 'package.json',
437
+ remediation: 'Remove the suspicious pre/postinstall script, or run package installation with --ignore-scripts.',
338
438
  });
339
439
  }
340
440
  }
@@ -26,6 +26,7 @@ const { resolveFeedbackDir } = require('./feedback-paths');
26
26
  const { createLesson, inferStructuredLesson } = require('./lesson-inference');
27
27
  const { buildStableId } = require('./conversation-context');
28
28
  const { ensureParentDir, readJsonl } = require('./fs-utils');
29
+ const { redactSecretsDeep } = require('./secret-redaction');
29
30
 
30
31
  const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
31
32
  const SELF_DISTILL_RUNS_PATH = path.join(HOME, '.thumbgate', 'self-distill-runs.jsonl');
@@ -383,7 +384,8 @@ async function generateLlmLessons(conversationWindow, model) {
383
384
 
384
385
  function writeRunManifest(manifest) {
385
386
  ensureParentDir(SELF_DISTILL_RUNS_PATH);
386
- fs.appendFileSync(SELF_DISTILL_RUNS_PATH, JSON.stringify(manifest) + '\n');
387
+ // Redact secrets — the manifest embeds lesson triggers/actions distilled from conversation text.
388
+ fs.appendFileSync(SELF_DISTILL_RUNS_PATH, JSON.stringify(redactSecretsDeep(manifest)) + '\n');
387
389
  }
388
390
 
389
391
  function readRunManifests() {