@link-assistant/hive-mind 1.64.1 → 1.64.3

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.
@@ -28,6 +28,14 @@ import { safeExit } from './exit-handler.lib.mjs';
28
28
  const githubLib = await import('./github.lib.mjs');
29
29
  const { sanitizeLogContent, attachLogToGitHub } = githubLib;
30
30
 
31
+ // Issue #1745: process-wide sanitization counters used to print a one-line
32
+ // "we masked N secrets" summary at the end of each run.
33
+ const { formatSanitizationSummary } = await import('./token-sanitization.lib.mjs');
34
+ // Issue #1745: post-finish retroactive sanitization of bot-authored PR
35
+ // comments and the PR description. Runs by default; can be skipped via
36
+ // --dangerously-skip-output-sanitization.
37
+ const { runPostFinishSweep } = await import('./post-finish-sanitization-sweep.lib.mjs');
38
+
31
39
  // Import continuation functions (session resumption, PR detection)
32
40
  const autoContinue = await import('./solve.auto-continue.lib.mjs');
33
41
  const { autoContinueWhenLimitResets } = autoContinue;
@@ -556,6 +564,17 @@ export const cleanupClaudeFile = async (tempDir, branchName, claudeCommitHash =
556
564
  export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl, tempDir, shouldAttachLogs = false) => {
557
565
  await log('\n=== Session Summary ===');
558
566
 
567
+ // Issue #1745: report how many tokens were masked during this run, with the
568
+ // "use --dangerously-skip-output-sanitization to skip" hint when > 0.
569
+ try {
570
+ const sanitizationSummary = formatSanitizationSummary();
571
+ if (sanitizationSummary) {
572
+ await log(sanitizationSummary);
573
+ }
574
+ } catch {
575
+ /* never fail the summary because of this */
576
+ }
577
+
559
578
  if (sessionId) {
560
579
  await log(`✅ Session ID: ${sessionId}`);
561
580
  // Always use absolute path for log file display
@@ -622,6 +641,39 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
622
641
  const logFilePath = path.resolve(getLogFile());
623
642
  await log(`📁 Log file available: ${logFilePath}`);
624
643
  }
644
+
645
+ // Issue #1745: post-finish retroactive sanitization sweep. Re-reads
646
+ // bot-authored PR comments and the PR description, runs them through
647
+ // sanitizeOutput, and edits in place if a leak slipped past the live
648
+ // sanitizer. Honors --dangerously-skip-output-sanitization and the related
649
+ // active-tokens flag.
650
+ try {
651
+ const owner = argv.owner;
652
+ const repo = argv.repo;
653
+ const prNumber = argv.prNumber;
654
+ const skipOutputSanitization = argv['dangerously-skip-output-sanitization'] === true;
655
+ const skipActiveTokensOutputSanitization = argv['dangerously-skip-active-tokens-output-sanitization'] === true;
656
+ if (owner && repo && prNumber && !skipOutputSanitization) {
657
+ const sweepResult = await runPostFinishSweep({
658
+ $,
659
+ owner,
660
+ repo,
661
+ prNumber,
662
+ log,
663
+ sanitizationOptions: {
664
+ warnOnMismatch: false,
665
+ skipActiveTokensOutputSanitization,
666
+ },
667
+ });
668
+ if (sweepResult.totalEdited > 0) {
669
+ await log(`🔒 Post-finish sweep: edited ${sweepResult.totalEdited} bot-authored item(s) to mask leaked tokens.`);
670
+ const followup = formatSanitizationSummary(sweepResult.sanitizationStatsAfter);
671
+ if (followup) await log(followup);
672
+ }
673
+ }
674
+ } catch (sweepErr) {
675
+ await log(`⚠️ Post-finish sanitization sweep failed: ${sweepErr.message || sweepErr}`);
676
+ }
625
677
  };
626
678
 
627
679
  // Verify results by searching for new PRs and comments
@@ -1109,6 +1109,46 @@ registerStartStopCommands(bot, sharedCommandOpts);
1109
1109
  await registerLogCommand(bot, sharedCommandOpts);
1110
1110
  await registerTerminalWatchCommand(bot, sharedCommandOpts);
1111
1111
 
1112
+ // Issue #1745: hidden /tokens command for chat owners (private DMs only,
1113
+ // undocumented, masked output). Lets operators audit which local tokens are
1114
+ // live in the bot's environment so they can search for accidental leaks.
1115
+ const { registerTokensCommand } = await import('./telegram-tokens-command.lib.mjs');
1116
+ registerTokensCommand(bot, { ...sharedCommandOpts, allowedChats });
1117
+
1118
+ // Issue #1745: register the leak-warning DM hook. The interactive bridge
1119
+ // fires reportInteractiveLeak() whenever it has to mask a known-local token
1120
+ // in an outbound PR comment. We DM every operator (chat creator) of every
1121
+ // allowlisted chat so at least one of them sees it quickly.
1122
+ const { registerLeakNotifier } = await import('./telegram-leak-notifier.lib.mjs');
1123
+ registerLeakNotifier(async ({ owner, repo, prNumber, tokenHits = [] }) => {
1124
+ if (!allowedChats || allowedChats.length === 0) return;
1125
+ const where = prNumber ? `${owner}/${repo}#${prNumber}` : `${owner}/${repo}`;
1126
+ const sources = tokenHits.length ? tokenHits.map(h => `${h.name} (${h.source})`).join(', ') : 'unknown';
1127
+ const text = `🚨 *Token-leak event*\n\nA known local token was about to be published in *${where}* and was masked by the sanitizer just in time.\n\nTokens detected: ${sources}\n\nRotate the affected secret(s) now and check public surfaces (GitHub comments, gists, Slack) for any prior copies.`;
1128
+ for (const chatId of allowedChats) {
1129
+ try {
1130
+ const member = await bot.telegram.getChatMember(chatId, chatId).catch(() => null);
1131
+ // For groups, getChatMember(chatId, chatId) returns the chat itself; we
1132
+ // really want the creator. Fall back to getChatAdministrators.
1133
+ let ownerUserId = null;
1134
+ if (member && member.status === 'creator' && member.user?.id) {
1135
+ ownerUserId = member.user.id;
1136
+ } else {
1137
+ const admins = await bot.telegram.getChatAdministrators(chatId).catch(() => []);
1138
+ const creator = (admins || []).find(a => a.status === 'creator');
1139
+ if (creator && creator.user?.id) ownerUserId = creator.user.id;
1140
+ }
1141
+ if (ownerUserId) {
1142
+ await bot.telegram.sendMessage(ownerUserId, text, { parse_mode: 'Markdown' }).catch(err => {
1143
+ console.warn(`[telegram-leak-notifier] DM to user ${ownerUserId} (chat ${chatId}) failed: ${err.message}`);
1144
+ });
1145
+ }
1146
+ } catch (err) {
1147
+ console.warn(`[telegram-leak-notifier] could not notify owner of chat ${chatId}: ${err.message}`);
1148
+ }
1149
+ }
1150
+ });
1151
+
1112
1152
  // Add message listener for verbose debugging
1113
1153
  if (VERBOSE) {
1114
1154
  bot.on('message', (ctx, next) => {
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Telegram leak-notifier (Issue #1745)
4
+ *
5
+ * The interactive AI bridge calls `reportInteractiveLeak()` whenever it
6
+ * detects that a comment body it was about to publish contained a
7
+ * known-local token. The sanitizer masks the token before it goes out, but
8
+ * we still want the chat owner who started the session to know — quickly,
9
+ * out-of-band — so they can rotate the token immediately.
10
+ *
11
+ * The Telegram bot calls `registerLeakNotifier()` on startup with a callback
12
+ * that knows how to DM the chat owner. We keep this contract intentionally
13
+ * small (callback-based, no direct telegraf import) so:
14
+ *
15
+ * 1. interactive-mode.lib.mjs doesn't have to depend on telegraf at all
16
+ * (avoids a heavy import in the AI subprocess).
17
+ * 2. Tests can register a no-op (or assertion-collecting) notifier.
18
+ * 3. solve.mjs running outside the Telegram bot process degrades gracefully
19
+ * to a console warning.
20
+ *
21
+ * @see docs/case-studies/issue-1745/analysis.md
22
+ * @module telegram-leak-notifier
23
+ */
24
+
25
+ let registeredNotifier = null;
26
+
27
+ /**
28
+ * Telegram bot calls this once during startup so the AI bridge has a way
29
+ * to send out-of-band leak warnings.
30
+ *
31
+ * @param {Function} notifier async ({ owner, repo, prNumber, tokenHits }) => void
32
+ */
33
+ export const registerLeakNotifier = notifier => {
34
+ registeredNotifier = typeof notifier === 'function' ? notifier : null;
35
+ };
36
+
37
+ /** Test hook — clear the registered notifier between tests. */
38
+ export const clearLeakNotifierForTests = () => {
39
+ registeredNotifier = null;
40
+ };
41
+
42
+ /**
43
+ * Issue #1745 — fired by interactive-mode.lib.mjs when it had to mask a
44
+ * known-local token in an outbound comment.
45
+ *
46
+ * Always succeeds. If no notifier is registered (we're running outside the
47
+ * Telegram bot process) it falls back to a structured console warning.
48
+ *
49
+ * @param {Object} params
50
+ * @param {string} params.owner repo owner
51
+ * @param {string} params.repo repo name
52
+ * @param {number} [params.prNumber] pull-request number, when applicable
53
+ * @param {Array<{name: string, source: string}>} [params.tokenHits]
54
+ * list of token identifiers (NEVER the values) that were detected.
55
+ * @param {Function} [params.log] async logger from interactive-mode
56
+ */
57
+ export const reportInteractiveLeak = async ({ owner, repo, prNumber, tokenHits = [], log } = {}) => {
58
+ const fallbackLog = log || (async msg => console.warn(msg));
59
+
60
+ const summary = tokenHits.length ? tokenHits.map(h => `${h.name} (${h.source})`).join(', ') : 'unknown';
61
+
62
+ const where = prNumber ? `${owner}/${repo}#${prNumber}` : `${owner}/${repo}`;
63
+
64
+ await fallbackLog(`🚨 Token-leak event: ${summary} found in outbound comment for ${where} (sanitizer masked it).`);
65
+
66
+ if (registeredNotifier) {
67
+ try {
68
+ await registeredNotifier({ owner, repo, prNumber, tokenHits });
69
+ } catch (err) {
70
+ await fallbackLog(`⚠️ Telegram leak notifier threw: ${err.message}`);
71
+ }
72
+ }
73
+ };
74
+
75
+ export default {
76
+ registerLeakNotifier,
77
+ reportInteractiveLeak,
78
+ clearLeakNotifierForTests,
79
+ };
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Telegram /tokens command — hidden, owner-only, private-chat only.
4
+ *
5
+ * Lists every known LOCAL token the bot can see (env vars + GitHub CLI
6
+ * tokens), already masked via `maskToken` (3-char prefix/suffix per
7
+ * issue #1745). Useful for spot-checking which secrets are live in the
8
+ * bot's environment so the operator can search for them in public places
9
+ * before they become a leak.
10
+ *
11
+ * Privacy / safety guarantees:
12
+ *
13
+ * - Hidden command. Not advertised in /help. Not part of the BotFather
14
+ * command list.
15
+ * - Private-chat only. Never echoes tokens (even masked) into a group chat.
16
+ * - Authenticated. The user must own (`status === 'creator'`) at least one
17
+ * chat that is on the allowlist — i.e. they're an actual operator of
18
+ * this bot, not a random DMer.
19
+ * - Output is always masked. We never print raw values.
20
+ *
21
+ * @see https://github.com/link-assistant/hive-mind/issues/1745
22
+ * @module telegram-tokens-command
23
+ */
24
+
25
+ import { getAllKnownLocalTokens } from './token-sanitization.lib.mjs';
26
+ import { maskToken } from './lib.mjs';
27
+
28
+ /**
29
+ * Resolve allowed chat IDs into an array of numeric IDs the user could own.
30
+ * Accepts:
31
+ * - Array<number|string>
32
+ * - Function returning Array<number|string>
33
+ * - undefined / null (treated as "any" — useful in private bot deployments)
34
+ *
35
+ * @param {Array|Function|null|undefined} allowedChats
36
+ * @returns {Array<string>} numeric chat IDs as strings
37
+ */
38
+ const resolveAllowedChatIds = allowedChats => {
39
+ if (!allowedChats) return [];
40
+ const raw = typeof allowedChats === 'function' ? allowedChats() : allowedChats;
41
+ if (!Array.isArray(raw)) return [];
42
+ return raw.map(v => String(v)).filter(Boolean);
43
+ };
44
+
45
+ /**
46
+ * Returns true if `userId` is the creator of any chat in `allowedChatIds`.
47
+ * Returns true unconditionally when `allowedChatIds` is empty (private
48
+ * deployment — no allowlist means any DM is fine).
49
+ */
50
+ const isOperatorOfAnyAllowedChat = async ({ telegram, userId, allowedChatIds }) => {
51
+ if (!allowedChatIds || allowedChatIds.length === 0) {
52
+ return true;
53
+ }
54
+ for (const chatId of allowedChatIds) {
55
+ try {
56
+ const member = await telegram.getChatMember(chatId, userId);
57
+ if (member && member.status === 'creator') {
58
+ return true;
59
+ }
60
+ } catch {
61
+ // Bot may have been removed from the chat; skip and try the next one.
62
+ }
63
+ }
64
+ return false;
65
+ };
66
+
67
+ /**
68
+ * Format the token list for display. Each line: `name (source): masked`.
69
+ * The masked form is `first-3 *** last-3` per maskToken's new default.
70
+ */
71
+ export const formatTokenList = tokens => {
72
+ if (!tokens || tokens.length === 0) {
73
+ return 'No known local tokens found in this bot process.';
74
+ }
75
+ const lines = tokens.map(t => {
76
+ const masked = maskToken(t.value);
77
+ return `• ${t.name} (${t.source}): \`${masked}\``;
78
+ });
79
+ return ['🔐 *Active local tokens (masked):*', '', ...lines, '', '_Use this list to search public places (GitHub, Slack, etc.) for accidentally leaked tokens before they become a problem. Tokens are masked with first 3 + last 3 characters per issue #1745._'].join('\n');
80
+ };
81
+
82
+ /**
83
+ * Registers the hidden /tokens command on the bot.
84
+ *
85
+ * @param {Object} bot - Telegraf bot
86
+ * @param {Object} options
87
+ * @param {boolean} [options.VERBOSE]
88
+ * @param {Function} [options.isOldMessage]
89
+ * @param {Array|Function} [options.allowedChats] — used for owner-of-allowed-chat check
90
+ * @param {Function} [options.fetchTokens] — test override for getAllKnownLocalTokens
91
+ */
92
+ export const registerTokensCommand = (bot, options = {}) => {
93
+ const { VERBOSE = false, isOldMessage, allowedChats } = options;
94
+ const fetchTokens = options.fetchTokens || getAllKnownLocalTokens;
95
+
96
+ bot.command('tokens', async ctx => {
97
+ if (isOldMessage && isOldMessage(ctx)) {
98
+ VERBOSE && console.log('[VERBOSE] /tokens ignored: old message');
99
+ return;
100
+ }
101
+
102
+ const chat = ctx.chat;
103
+ if (!chat || !ctx.from) return;
104
+
105
+ // Step 1: private-chat only. Silently no-op in groups so the command stays
106
+ // truly hidden — a curious group member never gets a hint that it exists.
107
+ if (chat.type !== 'private') {
108
+ VERBOSE && console.log(`[VERBOSE] /tokens ignored: chat type ${chat.type} (private only)`);
109
+ return;
110
+ }
111
+
112
+ // Step 2: authenticate by ownership of an allowlisted chat.
113
+ const allowedChatIds = resolveAllowedChatIds(allowedChats);
114
+ let isOperator = false;
115
+ try {
116
+ isOperator = await isOperatorOfAnyAllowedChat({
117
+ telegram: ctx.telegram,
118
+ userId: ctx.from.id,
119
+ allowedChatIds,
120
+ });
121
+ } catch (err) {
122
+ VERBOSE && console.error('[VERBOSE] /tokens auth check failed:', err);
123
+ isOperator = false;
124
+ }
125
+
126
+ if (!isOperator) {
127
+ VERBOSE && console.log(`[VERBOSE] /tokens denied: user ${ctx.from.id} is not creator of any allowed chat`);
128
+ // Reply with a generic "unknown command"-shaped message so the command
129
+ // stays undiscoverable to non-operators.
130
+ return;
131
+ }
132
+
133
+ // Step 3: gather and emit.
134
+ let tokens;
135
+ try {
136
+ tokens = await fetchTokens();
137
+ } catch (err) {
138
+ VERBOSE && console.error('[VERBOSE] /tokens: fetchTokens failed:', err);
139
+ await ctx.reply('❌ Failed to gather local tokens.');
140
+ return;
141
+ }
142
+
143
+ const message = formatTokenList(tokens);
144
+ await ctx.reply(message, { parse_mode: 'Markdown' });
145
+ });
146
+ };
147
+
148
+ export default {
149
+ registerTokensCommand,
150
+ formatTokenList,
151
+ };