@link-assistant/hive-mind 1.64.2 → 1.64.4

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.
@@ -571,6 +571,7 @@ bot.command('help', async ctx => {
571
571
  message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
572
572
  message += '*/help* - Show this help message\n';
573
573
  message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
574
+ message += '*/stop* `<uuid>` - Send CTRL+C to an isolated solve/hive session (owner only). Also works as a reply to a message containing the UUID.\n';
574
575
  message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n';
575
576
  message += '*/terminal\\_watch* - Live-update an isolation session log (owner only). Usage: `/terminal_watch <uuid>` or reply with `/terminal_watch`\n\n';
576
577
  message += '🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
@@ -1109,6 +1110,46 @@ registerStartStopCommands(bot, sharedCommandOpts);
1109
1110
  await registerLogCommand(bot, sharedCommandOpts);
1110
1111
  await registerTerminalWatchCommand(bot, sharedCommandOpts);
1111
1112
 
1113
+ // Issue #1745: hidden /tokens command for chat owners (private DMs only,
1114
+ // undocumented, masked output). Lets operators audit which local tokens are
1115
+ // live in the bot's environment so they can search for accidental leaks.
1116
+ const { registerTokensCommand } = await import('./telegram-tokens-command.lib.mjs');
1117
+ registerTokensCommand(bot, { ...sharedCommandOpts, allowedChats });
1118
+
1119
+ // Issue #1745: register the leak-warning DM hook. The interactive bridge
1120
+ // fires reportInteractiveLeak() whenever it has to mask a known-local token
1121
+ // in an outbound PR comment. We DM every operator (chat creator) of every
1122
+ // allowlisted chat so at least one of them sees it quickly.
1123
+ const { registerLeakNotifier } = await import('./telegram-leak-notifier.lib.mjs');
1124
+ registerLeakNotifier(async ({ owner, repo, prNumber, tokenHits = [] }) => {
1125
+ if (!allowedChats || allowedChats.length === 0) return;
1126
+ const where = prNumber ? `${owner}/${repo}#${prNumber}` : `${owner}/${repo}`;
1127
+ const sources = tokenHits.length ? tokenHits.map(h => `${h.name} (${h.source})`).join(', ') : 'unknown';
1128
+ 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.`;
1129
+ for (const chatId of allowedChats) {
1130
+ try {
1131
+ const member = await bot.telegram.getChatMember(chatId, chatId).catch(() => null);
1132
+ // For groups, getChatMember(chatId, chatId) returns the chat itself; we
1133
+ // really want the creator. Fall back to getChatAdministrators.
1134
+ let ownerUserId = null;
1135
+ if (member && member.status === 'creator' && member.user?.id) {
1136
+ ownerUserId = member.user.id;
1137
+ } else {
1138
+ const admins = await bot.telegram.getChatAdministrators(chatId).catch(() => []);
1139
+ const creator = (admins || []).find(a => a.status === 'creator');
1140
+ if (creator && creator.user?.id) ownerUserId = creator.user.id;
1141
+ }
1142
+ if (ownerUserId) {
1143
+ await bot.telegram.sendMessage(ownerUserId, text, { parse_mode: 'Markdown' }).catch(err => {
1144
+ console.warn(`[telegram-leak-notifier] DM to user ${ownerUserId} (chat ${chatId}) failed: ${err.message}`);
1145
+ });
1146
+ }
1147
+ } catch (err) {
1148
+ console.warn(`[telegram-leak-notifier] could not notify owner of chat ${chatId}: ${err.message}`);
1149
+ }
1150
+ }
1151
+ });
1152
+
1112
1153
  // Add message listener for verbose debugging
1113
1154
  if (VERBOSE) {
1114
1155
  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
+ };
@@ -10,10 +10,17 @@
10
10
  * - Graceful stop: existing queue items continue to process
11
11
  * - Read-only commands (/help, /limits, /version) remain available when stopped
12
12
  * - Write commands (/solve, /hive) are rejected when stopped
13
+ * - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
14
+ * matching isolated solve/hive session via `$ --stop <UUID>` from
15
+ * link-foundation/start (issue #524).
13
16
  *
14
17
  * @see https://github.com/link-assistant/hive-mind/issues/1081
18
+ * @see https://github.com/link-assistant/hive-mind/issues/524
19
+ * @see https://github.com/link-foundation/start/issues/112
15
20
  */
16
21
 
22
+ import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
23
+
17
24
  // Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
18
25
  const stoppedChats = new Map();
19
26
 
@@ -86,6 +93,30 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
86
93
  return `❌ ${commandName} command rejected.\n\n🚫 Reason: ${reason}\n\nUse /start to resume (chat owner only).`;
87
94
  }
88
95
 
96
+ /**
97
+ * Extract a session UUID for `/stop`. Priority:
98
+ * 1. UUID literal anywhere in the `/stop` message text.
99
+ * 2. UUID in the text/caption of the message being replied to.
100
+ *
101
+ * The `text` argument is the raw `/stop ...` command text. `repliedTo`, when
102
+ * present, is the Telegram message object that the user replied to with `/stop`.
103
+ *
104
+ * @param {string} text
105
+ * @param {Object|null|undefined} repliedTo
106
+ * @returns {{ sessionId: string|null, source: 'argument'|'reply'|null }}
107
+ */
108
+ export function extractStopSessionId(text, repliedTo) {
109
+ // Strip the leading `/stop` (or `/stop@botname`) before looking for a UUID,
110
+ // so we don't accidentally match digits inside the command name itself.
111
+ const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
112
+ const direct = extractSessionIdFromText(argText);
113
+ if (direct) return { sessionId: direct, source: 'argument' };
114
+ const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
115
+ const fromReply = extractSessionIdFromText(replyText);
116
+ if (fromReply) return { sessionId: fromReply, source: 'reply' };
117
+ return { sessionId: null, source: null };
118
+ }
119
+
89
120
  /**
90
121
  * Registers the /start and /stop command handlers with the bot
91
122
  * @param {Object} bot - The Telegraf bot instance
@@ -95,9 +126,13 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
95
126
  * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
96
127
  * @param {Function} options.isGroupChat - Function to check if chat is a group
97
128
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
129
+ * @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
130
+ * @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
131
+ * @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
98
132
  */
99
133
  export function registerStartStopCommands(bot, options) {
100
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized } = options;
134
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
135
+ const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
101
136
 
102
137
  /**
103
138
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
@@ -145,9 +180,110 @@ export function registerStartStopCommands(bot, options) {
145
180
  return { valid: true, chatId };
146
181
  }
147
182
 
148
- // /stop command - stop accepting new tasks in this chat
149
- // Only accessible by chat owner (creator)
183
+ // /stop command. Two modes:
184
+ // 1. `/stop <UUID>` or reply-to-message-with-UUID forward CTRL+C to the
185
+ // matching isolated session via `$ --stop <UUID>` (issue #524).
186
+ // 2. bare `/stop` (optionally with a free-text reason) — pause new task
187
+ // acceptance for the chat (issue #1081).
188
+ // Only accessible by chat owner (creator) in both modes.
150
189
  bot.command('stop', async ctx => {
190
+ VERBOSE && console.log('[VERBOSE] /stop command received');
191
+ if (isOldMessage(ctx)) {
192
+ VERBOSE && console.log('[VERBOSE] /stop ignored: old message');
193
+ return;
194
+ }
195
+
196
+ // Detect UUID modes BEFORE the forwarded/reply rejection used by the
197
+ // chat-level stop, because the UUID-from-reply mode is intentionally a
198
+ // reply (issue #524).
199
+ const message = ctx.message;
200
+ const repliedTo = message?.reply_to_message || null;
201
+ const { sessionId, source } = extractStopSessionId(message?.text || '', repliedTo);
202
+
203
+ if (sessionId) {
204
+ VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${source})`);
205
+ // Reuse the same auth model as /log: must be chat owner in groups; in
206
+ // private DMs the user is implicitly the owner of their own chat.
207
+ const chatId = ctx.chat?.id;
208
+ const chatType = ctx.chat?.type;
209
+ if (chatType !== 'private') {
210
+ if (!isGroupChat(ctx)) {
211
+ await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
212
+ return;
213
+ }
214
+ if (!isChatAuthorized(chatId)) {
215
+ if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
216
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
217
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
218
+ return;
219
+ }
220
+ }
221
+ try {
222
+ const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
223
+ if (!member || member.status !== 'creator') {
224
+ VERBOSE && console.log('[VERBOSE] /stop <UUID> ignored: user is not chat owner');
225
+ await ctx.reply('❌ /stop <UUID> is only available to the chat owner.', { reply_to_message_id: message.message_id });
226
+ return;
227
+ }
228
+ } catch (error) {
229
+ console.error('[ERROR] /stop <UUID>: getChatMember failed:', error);
230
+ await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
231
+ return;
232
+ }
233
+ }
234
+
235
+ const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
236
+ parse_mode: 'Markdown',
237
+ reply_to_message_id: message.message_id,
238
+ });
239
+
240
+ let result;
241
+ try {
242
+ result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
243
+ } catch (error) {
244
+ console.error('[ERROR] /stop <UUID>: stopIsolatedSession threw:', error);
245
+ result = { success: false, output: '', error: error?.message || String(error) };
246
+ }
247
+
248
+ const trimmedOutput = (result.output || '').toString().trim();
249
+ const trimmedError = (result.error || '').toString().trim();
250
+ const lines = [];
251
+ if (result.success) {
252
+ lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
253
+ lines.push('');
254
+ lines.push('The session should terminate shortly.');
255
+ if (trimmedOutput) {
256
+ lines.push('');
257
+ lines.push('```');
258
+ lines.push(trimmedOutput.slice(0, 1000));
259
+ lines.push('```');
260
+ }
261
+ } else {
262
+ lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
263
+ if (trimmedError) {
264
+ lines.push('');
265
+ lines.push('```');
266
+ lines.push(trimmedError.slice(0, 1000));
267
+ lines.push('```');
268
+ }
269
+ }
270
+
271
+ try {
272
+ await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
273
+ } catch (error) {
274
+ console.error('[ERROR] /stop <UUID>: editMessageText failed, falling back to reply:', error);
275
+ await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
276
+ }
277
+ return;
278
+ }
279
+
280
+ // No UUID — fall through to the chat-level pause flow. That flow rejects
281
+ // forwards/replies on purpose (#1081) so a stray reply doesn't pause the chat.
282
+ if (isForwardedOrReply(ctx)) {
283
+ VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
284
+ return;
285
+ }
286
+
151
287
  const check = await validateOwnerCommand(ctx, '/stop');
152
288
  if (!check.valid) return;
153
289
  const chatId = check.chatId;
@@ -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
+ };