@link-assistant/hive-mind 2.10.0 → 2.10.2

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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +1 -1
  7. package/src/claude.lib.mjs +0 -4
  8. package/src/cleanup.mjs +18 -6
  9. package/src/codex.lib.mjs +0 -4
  10. package/src/configure-claude.mjs +3 -0
  11. package/src/credential-sanitization-core.lib.mjs +231 -0
  12. package/src/development-log.lib.mjs +39 -6
  13. package/src/fix.mjs +3 -0
  14. package/src/github-error-reporter.lib.mjs +13 -8
  15. package/src/github-issue-auto-close.lib.mjs +2 -1
  16. package/src/github-merge-issue-close.lib.mjs +2 -1
  17. package/src/github.lib.mjs +29 -18
  18. package/src/hive-screens.mjs +3 -0
  19. package/src/instrument.mjs +14 -0
  20. package/src/interactive-mode.lib.mjs +25 -40
  21. package/src/lib.mjs +89 -50
  22. package/src/log-upload.lib.mjs +22 -4
  23. package/src/post-finish-sanitization-sweep.lib.mjs +5 -5
  24. package/src/review.mjs +3 -1
  25. package/src/sentry.lib.mjs +27 -8
  26. package/src/session-monitor.lib.mjs +6 -10
  27. package/src/session-resume.lib.mjs +43 -23
  28. package/src/session-store.lib.mjs +3 -1
  29. package/src/solve.auto-pr.lib.mjs +11 -21
  30. package/src/solve.error-handlers.lib.mjs +2 -1
  31. package/src/solve.progress-monitoring.lib.mjs +20 -9
  32. package/src/solve.results.lib.mjs +9 -15
  33. package/src/start-screen.mjs +3 -0
  34. package/src/task.issue-creation.lib.mjs +5 -3
  35. package/src/task.mjs +21 -8
  36. package/src/telegram-bot.mjs +6 -3
  37. package/src/telegram-command-execution.lib.mjs +2 -2
  38. package/src/telegram-isolation.lib.mjs +2 -0
  39. package/src/telegram-log-command.lib.mjs +38 -4
  40. package/src/telegram-safe-reply.lib.mjs +7 -4
  41. package/src/telegram-solve-queue.lib.mjs +5 -6
  42. package/src/telegram-tokens-command.lib.mjs +1 -1
  43. package/src/token-sanitization.lib.mjs +177 -14
  44. package/src/tool-comments.lib.mjs +5 -5
  45. package/src/youtrack/youtrack-sync.mjs +4 -3
@@ -90,11 +90,13 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
90
90
  startTime: new Date(),
91
91
  url: item.url,
92
92
  command: item.command || 'solve',
93
+ commandAlias: item.commandAlias || null,
93
94
  isolationBackend: iso.backend,
94
95
  sessionId: sid,
95
96
  containerFilesystemStartBytes: Number.isFinite(r.containerFilesystemStartBytes) ? r.containerFilesystemStartBytes : null,
96
97
  tool,
97
98
  infoBlock: item.infoBlock,
99
+ args: Array.isArray(item.args) ? [...item.args] : undefined,
98
100
  // Issue #1688: propagate URL context + requester through the queue so the
99
101
  // completion notification can append a 'Pull request:' line and skip
100
102
  // notifying the requester twice via /subscribe.
@@ -21,8 +21,10 @@
21
21
  */
22
22
 
23
23
  import path from 'path';
24
+ import os from 'os';
24
25
  import fs from 'fs/promises';
25
26
  import { constants as fsConstants } from 'fs';
27
+ import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
26
28
 
27
29
  const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
28
30
  const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
@@ -30,6 +32,29 @@ const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
30
32
  // https://core.telegram.org/bots/api#senddocument
31
33
  const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
32
34
 
35
+ /**
36
+ * Build a private, sanitized upload artifact without modifying the raw audit
37
+ * log. The returned cleanup function must be called after the network request.
38
+ */
39
+ async function prepareSanitizedLogUpload(logPath, caption) {
40
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hive-mind-telegram-log-'));
41
+ await fs.chmod(tempDir, 0o700);
42
+ const sanitizedPath = path.join(tempDir, path.basename(logPath));
43
+
44
+ try {
45
+ const [rawLog, safeCaption] = await Promise.all([fs.readFile(logPath, 'utf8'), sanitizeForPublication(caption)]);
46
+ await writeSanitizedPublicationFile(sanitizedPath, rawLog);
47
+ return {
48
+ path: sanitizedPath,
49
+ caption: safeCaption,
50
+ cleanup: () => fs.rm(tempDir, { recursive: true, force: true }),
51
+ };
52
+ } catch (error) {
53
+ await fs.rm(tempDir, { recursive: true, force: true });
54
+ throw error;
55
+ }
56
+ }
57
+
33
58
  /**
34
59
  * Extract the first RFC 4122 v4-shaped UUID found in `text`.
35
60
  *
@@ -305,11 +330,15 @@ export async function registerLogCommand(bot, options) {
305
330
 
306
331
  if (decision.destination === 'chat') {
307
332
  // Public repository → reply with the document directly in the chat.
333
+ let upload;
308
334
  try {
309
- await ctx.replyWithDocument({ source: logPath, filename }, { reply_to_message_id: message.message_id, caption, parse_mode: 'Markdown' });
335
+ upload = await prepareSanitizedLogUpload(logPath, caption);
336
+ await ctx.replyWithDocument({ source: upload.path, filename }, { reply_to_message_id: message.message_id, caption: upload.caption, parse_mode: 'Markdown' });
310
337
  } catch (error) {
311
338
  console.error('[ERROR] /log: replyWithDocument failed:', error);
312
- await ctx.reply(`❌ Failed to upload log: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
339
+ await ctx.reply('❌ Failed to sanitize or upload the log.', { reply_to_message_id: message.message_id });
340
+ } finally {
341
+ await upload?.cleanup();
313
342
  }
314
343
  return;
315
344
  }
@@ -348,16 +377,21 @@ export async function registerLogCommand(bot, options) {
348
377
  console.error('[ERROR] /log: DM forwarding step failed:', error);
349
378
  }
350
379
 
380
+ let upload;
351
381
  try {
382
+ upload = await prepareSanitizedLogUpload(logPath, caption);
352
383
  const replyOpts = forwardedMessageId ? { reply_to_message_id: forwardedMessageId, caption, parse_mode: 'Markdown' } : { caption, parse_mode: 'Markdown' };
353
- await ctx.telegram.sendDocument(userId, { source: logPath, filename }, replyOpts);
384
+ replyOpts.caption = upload.caption;
385
+ await ctx.telegram.sendDocument(userId, { source: upload.path, filename }, replyOpts);
354
386
  } catch (error) {
355
387
  console.error('[ERROR] /log: sendDocument to DM failed:', error);
356
388
  // Tell the user, in their original chat, that DM delivery failed
357
389
  // (commonly because they have not started a chat with the bot).
358
- const friendly = error?.code === 403 || /chat not found|bot can't initiate conversation/i.test(error?.message || '') ? 'I could not send you a DM. Please open a private chat with me and send /start, then try again.' : `Failed to send the log via DM: ${error.message || String(error)}`;
390
+ const friendly = error?.code === 403 || /chat not found|bot can't initiate conversation/i.test(error?.message || '') ? 'I could not send you a DM. Please open a private chat with me and send /start, then try again.' : 'Failed to sanitize or send the log via DM.';
359
391
  await ctx.reply(`❌ ${friendly}`, { reply_to_message_id: message.message_id });
360
392
  return;
393
+ } finally {
394
+ await upload?.cleanup();
361
395
  }
362
396
 
363
397
  // Acknowledge in the original chat (only if it wasn't already a DM).
@@ -1,4 +1,5 @@
1
1
  import { normalizeLocale, t } from './i18n.lib.mjs';
2
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
2
3
 
3
4
  const FORMATTING_FALLBACK_INSTALLED = Symbol.for('hiveMind.telegramFormattingFallbackInstalled');
4
5
  const DEFAULT_FORMATTING_FALLBACK_WARNING = '⚠️ Formatting error detected. Showing plain text fallback.';
@@ -385,7 +386,7 @@ export async function safeReply(ctx, text, options = {}) {
385
386
  const { telegramOptions, fallbackLocale, verbose } = splitOptions(options);
386
387
  const firstOptions = { parse_mode: 'Markdown', ...telegramOptions };
387
388
  return await sendTelegramTextChunks({
388
- text,
389
+ text: await sanitizeForPublication(text),
389
390
  telegramOptions: firstOptions,
390
391
  fallbackLocale,
391
392
  verbose,
@@ -398,7 +399,7 @@ export async function safeEditMessageText(telegram, chatId, messageId, inlineMes
398
399
  const { telegramOptions, fallbackLocale, verbose } = splitOptions(options);
399
400
  const firstOptions = { parse_mode: 'Markdown', ...telegramOptions };
400
401
  return await editTelegramTextChunks({
401
- text,
402
+ text: await sanitizeForPublication(text),
402
403
  telegramOptions: firstOptions,
403
404
  fallbackLocale,
404
405
  verbose,
@@ -419,9 +420,10 @@ function wrapTelegramSendMessage(telegram, defaults = {}) {
419
420
  args[2] = telegramOptions;
420
421
 
421
422
  if (typeof text !== 'string') return await original.apply(this, args);
423
+ const sanitizedText = await sanitizeForPublication(text);
422
424
 
423
425
  return await sendTelegramTextChunks({
424
- text,
426
+ text: sanitizedText,
425
427
  telegramOptions,
426
428
  fallbackLocale: fallbackLocale || defaults.fallbackLocale,
427
429
  verbose: verbose || defaults.verbose,
@@ -447,9 +449,10 @@ function wrapTelegramEditMessageText(telegram, defaults = {}) {
447
449
  args[4] = telegramOptions;
448
450
 
449
451
  if (typeof text !== 'string') return await original.apply(this, args);
452
+ const sanitizedText = await sanitizeForPublication(text);
450
453
 
451
454
  return await editTelegramTextChunks({
452
- text,
455
+ text: sanitizedText,
453
456
  telegramOptions,
454
457
  fallbackLocale: fallbackLocale || defaults.fallbackLocale,
455
458
  verbose: verbose || defaults.verbose,
@@ -53,6 +53,7 @@ class SolveQueueItem {
53
53
  this.ctx = options.ctx;
54
54
  this.requester = options.requester;
55
55
  this.infoBlock = options.infoBlock;
56
+ this.commandAlias = options.commandAlias || null; // #2109: retain Telegram spelling for resume guidance
56
57
  this.tool = options.tool || 'claude';
57
58
  // Issue #1983: preserve per-command isolation through queued execution.
58
59
  this.perCommandIsolation = options.perCommandIsolation || null;
@@ -1442,16 +1443,14 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1442
1443
  startTime: new Date(),
1443
1444
  url: item.url,
1444
1445
  command: 'solve',
1446
+ commandAlias: item.commandAlias || null,
1445
1447
  tool: item.tool || 'claude',
1446
1448
  infoBlock: item.infoBlock,
1447
- // Issue #1688: propagate URL context + requester so the completion
1448
- // notification can append a 'Pull request:' line and skip
1449
- // notifying the requester twice via /subscribe.
1449
+ args: Array.isArray(item.args) ? [...item.args] : undefined,
1450
+ // #1688: propagate URL context and requester to completion.
1450
1451
  urlContext: item.urlContext || null,
1451
1452
  requesterUserId: item.requesterUserId ?? null,
1452
- // Issue #594: --show-limits virtual option carries the start-of-task
1453
- // snapshot from the queueing point through to the completion
1454
- // handler so it can render an end-of-task delta.
1453
+ // #594: carry the start-of-task limits snapshot to completion.
1455
1454
  showLimits: item.showLimits === true,
1456
1455
  limitsAtStart: item.limitsAtStart || null,
1457
1456
  locale: item.locale || null,
@@ -66,7 +66,7 @@ const isOperatorOfAnyAllowedChat = async ({ telegram, userId, allowedChatIds })
66
66
 
67
67
  /**
68
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.
69
+ * The masked form is `first-3last-3` per maskToken's default.
70
70
  */
71
71
  export const formatTokenList = tokens => {
72
72
  if (!tokens || tokens.length === 0) {
@@ -15,10 +15,14 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
15
15
  * @module token-sanitization
16
16
  */
17
17
 
18
- // Import shared utility from lib.mjs
19
- import { maskToken, log, isENOSPC } from './lib.mjs';
18
+ // Import shared utilities. The dependency-free core is also used directly by
19
+ // lib.mjs, so it must not depend on this asynchronous Secretlint layer.
20
+ import { log, isENOSPC } from './lib.mjs';
21
+ import { CREDENTIAL_SANITIZATION_ERROR_CODE, CREDENTIAL_SANITIZATION_FAILURE_MESSAGE, createCredentialStreamSanitizer, findCredentialResiduals, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
20
22
  import { reportError } from './sentry.lib.mjs';
21
23
 
24
+ export { createCredentialStreamSanitizer };
25
+
22
26
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
23
27
  // Dynamic imports for runtime dependencies
24
28
  const getOsModule = async () => (await import('os')).default;
@@ -28,6 +32,7 @@ const getFsModule = async () => (await import('fs')).promises;
28
32
  // Lazy-loaded secretlint modules (initialized on first use)
29
33
  let secretlintCore = null;
30
34
  let secretlintConfig = null;
35
+ let githubCommandTokensCache = null;
31
36
 
32
37
  // Issue #1745: process-wide counters for how many tokens were masked. The
33
38
  // final-summary path (solve.mjs / hive.mjs) reads these to print a one-line
@@ -107,10 +112,10 @@ const initSecretlint = async () => {
107
112
  };
108
113
 
109
114
  return true;
110
- } catch (error) {
115
+ } catch (_error) {
111
116
  // secretlint not available - fall back to custom patterns only
112
117
  if (global.verboseMode) {
113
- await log(` ⚠️ Secretlint not available, using fallback patterns: ${error.message}`, { verbose: true });
118
+ await log(' ⚠️ Secretlint is not available; publication boundaries will remain blocked.', { verbose: true });
114
119
  }
115
120
  secretlintConfig = false;
116
121
  return false;
@@ -240,6 +245,9 @@ export const getGitHubTokensFromFiles = async () => {
240
245
  * @returns {Promise<string[]>} Array of tokens found
241
246
  */
242
247
  export const getGitHubTokensFromCommand = async () => {
248
+ if (githubCommandTokensCache) {
249
+ return [...githubCommandTokensCache];
250
+ }
243
251
  if (typeof globalThis.use === 'undefined') {
244
252
  await ensureUseM();
245
253
  }
@@ -276,6 +284,7 @@ export const getGitHubTokensFromCommand = async () => {
276
284
  }
277
285
  }
278
286
 
287
+ githubCommandTokensCache = [...tokens];
279
288
  return tokens;
280
289
  };
281
290
 
@@ -284,11 +293,14 @@ export const getGitHubTokensFromCommand = async () => {
284
293
  * @param {string} content - Content to scan
285
294
  * @returns {Promise<Array<{start: number, end: number, token: string, ruleId: string}>>} Array of detected secrets with rule info
286
295
  */
287
- const detectSecretsWithSecretlint = async content => {
296
+ const detectSecretsWithSecretlint = async (content, options = {}) => {
288
297
  const secrets = [];
289
298
 
290
299
  const available = await initSecretlint();
291
300
  if (!available || !secretlintCore || !secretlintConfig) {
301
+ if (options.required) {
302
+ throw new Error('Secretlint scanner is unavailable.');
303
+ }
292
304
  return secrets;
293
305
  }
294
306
 
@@ -309,6 +321,12 @@ const detectSecretsWithSecretlint = async content => {
309
321
  if (message.range && message.range.length === 2) {
310
322
  const [start, end] = message.range;
311
323
  const token = content.substring(start, end);
324
+ // The synchronous core may already have sanitized the credential
325
+ // portion of a larger structured value (for example a database DSN).
326
+ // Do not let a broad Secretlint range erase the remaining safe context.
327
+ if (token.includes('[REDACTED]') || /…/.test(token)) {
328
+ continue;
329
+ }
312
330
  secrets.push({
313
331
  start,
314
332
  end,
@@ -319,8 +337,11 @@ const detectSecretsWithSecretlint = async content => {
319
337
  }
320
338
  }
321
339
  } catch (error) {
340
+ if (options.required) {
341
+ throw new Error('Secretlint scanner failed.', { cause: error });
342
+ }
322
343
  if (global.verboseMode) {
323
- await log(` ⚠️ Secretlint detection error: ${error.message}`, { verbose: true });
344
+ await log(' ⚠️ Secretlint detection failed.', { verbose: true });
324
345
  }
325
346
  }
326
347
 
@@ -404,7 +425,7 @@ const detectSecretsWithCustomPatterns = content => {
404
425
  while ((match = pattern.exec(content)) !== null) {
405
426
  const token = match[0];
406
427
  // Skip if already masked (contains consecutive asterisks)
407
- if (/\*{3,}/.test(token)) {
428
+ if (/\*{3,}/.test(token) || token.includes('[REDACTED]') || /…/.test(token)) {
408
429
  continue;
409
430
  }
410
431
  secrets.push({
@@ -454,6 +475,44 @@ const compareDetectionResults = async (secretlintSecrets, customSecrets) => {
454
475
  return { secretlintOnly, customOnly, both };
455
476
  };
456
477
 
478
+ /**
479
+ * Run the dependency-free sanitizer without changing exact strings covered by
480
+ * the legacy local-output exclusion carve-out. Publication callers never pass
481
+ * exclusions and therefore cannot reach this compatibility behavior.
482
+ */
483
+ const sanitizeCredentialTextPreservingExclusions = (input, excludedSet) => {
484
+ const text = String(input ?? '');
485
+ if (excludedSet.size === 0) return sanitizeCredentialText(text);
486
+
487
+ const excludedTokens = [...excludedSet].sort((a, b) => b.length - a.length);
488
+ let output = '';
489
+ let cursor = 0;
490
+
491
+ while (cursor < text.length) {
492
+ let nextIndex = -1;
493
+ let nextToken = '';
494
+ for (const token of excludedTokens) {
495
+ const index = text.indexOf(token, cursor);
496
+ if (index === -1) continue;
497
+ if (nextIndex === -1 || index < nextIndex || (index === nextIndex && token.length > nextToken.length)) {
498
+ nextIndex = index;
499
+ nextToken = token;
500
+ }
501
+ }
502
+
503
+ if (nextIndex === -1) {
504
+ output += sanitizeCredentialText(text.slice(cursor));
505
+ break;
506
+ }
507
+
508
+ output += sanitizeCredentialText(text.slice(cursor, nextIndex));
509
+ output += nextToken;
510
+ cursor = nextIndex + nextToken.length;
511
+ }
512
+
513
+ return output;
514
+ };
515
+
457
516
  /**
458
517
  * Sanitize arbitrary outbound output by masking sensitive tokens while avoiding false positives
459
518
  * Uses DUAL APPROACH: Both secretlint AND custom patterns run independently
@@ -470,7 +529,7 @@ const compareDetectionResults = async (secretlintSecrets, customSecrets) => {
470
529
  * @returns {Promise<string>} Sanitized output with tokens masked
471
530
  */
472
531
  export const sanitizeOutput = async (output, options = {}) => {
473
- let sanitized = output;
532
+ let sanitized = String(output ?? '');
474
533
  const { warnOnMismatch = global.verboseMode, skipOutputSanitization = false, skipActiveTokensOutputSanitization = false, excludeTokens = [] } = options;
475
534
  const excludedSet = new Set((excludeTokens || []).filter(t => typeof t === 'string' && t.length > 0));
476
535
  const isExcluded = token => excludedSet.has(token);
@@ -513,6 +572,29 @@ export const sanitizeOutput = async (output, options = {}) => {
513
572
  return sanitized;
514
573
  }
515
574
 
575
+ // Always apply the dependency-free structured/vendor pass before optional
576
+ // scanners. Record custom-pattern matches before that pass because the
577
+ // core deliberately masks them first; otherwise the legacy local-output
578
+ // summary counters would no longer observe those replacements.
579
+ const preCoreCustomSecrets = detectSecretsWithCustomPatterns(sanitized);
580
+ let corePatternMasks = 0;
581
+ for (const secret of preCoreCustomSecrets) {
582
+ if (isExcluded(secret.token)) continue;
583
+ if (sanitizeCredentialText(secret.token, { includeEnvironmentCredentials: false }) !== secret.token) {
584
+ corePatternMasks++;
585
+ }
586
+ }
587
+
588
+ const beforeCore = sanitized;
589
+ sanitized = sanitizeCredentialTextPreservingExclusions(sanitized, excludedSet);
590
+ if (sanitized !== beforeCore) {
591
+ // Structured credentials that do not have a standalone vendor pattern
592
+ // still count as one sanitization event for the operator-facing summary.
593
+ const coreMaskCount = Math.max(corePatternMasks, 1);
594
+ sanitizationStats.patternMasks += coreMaskCount;
595
+ sanitizationStats.totalMasked += coreMaskCount;
596
+ }
597
+
516
598
  // Step 2: DUAL APPROACH - Run both detection methods independently
517
599
  const [secretlintSecrets, customSecrets] = await Promise.all([detectSecretsWithSecretlint(sanitized), Promise.resolve(detectSecretsWithCustomPatterns(sanitized))]);
518
600
 
@@ -524,9 +606,8 @@ export const sanitizeOutput = async (output, options = {}) => {
524
606
  stats.secretlintOnlyWarnings = secretlintOnly;
525
607
  await log(` ⚠️ PATTERN GAP: Secretlint found ${secretlintOnly.length} secret(s) that our custom patterns missed:`, { verbose: true });
526
608
  for (const secret of secretlintOnly) {
527
- // Show truncated token and rule that detected it
528
- const truncated = secret.token.length > 20 ? `${secret.token.substring(0, 10)}...${secret.token.substring(secret.token.length - 5)}` : secret.token;
529
- await log(` • Rule: ${secret.ruleId}, Token preview: ${truncated}`, { verbose: true });
609
+ // Rule identifiers are useful diagnostics; token previews are not.
610
+ await log(` • Rule: ${secret.ruleId}`, { verbose: true });
530
611
  }
531
612
  await log(` Consider adding custom patterns for these secret types to improve our detection.`, { verbose: true });
532
613
  }
@@ -634,16 +715,94 @@ export const sanitizeOutput = async (output, options = {}) => {
634
715
  level: isNoSpace ? 'error' : 'warning',
635
716
  });
636
717
  if (isNoSpace) {
637
- await log(` ❌ ENOSPC: No space left on device during log sanitization. Skipping sanitization.`);
718
+ await log(` ❌ ENOSPC: No space left on device during output sanitization. Output was blocked.`);
638
719
  await log(` Consider freeing disk space (e.g., rm -rf ~/.claude/debug/*.txt) and retrying.`);
639
720
  } else {
640
- await log(` ⚠️ Warning: Could not fully sanitize log content: ${error.message}`, { verbose: true });
721
+ await log(` ⚠️ Warning: Output sanitization failed; unsafe output was blocked.`, { verbose: true });
641
722
  }
723
+ return CREDENTIAL_SANITIZATION_FAILURE_MESSAGE;
642
724
  }
643
725
 
644
726
  return sanitized;
645
727
  };
646
728
 
729
+ export class CredentialSanitizationError extends Error {
730
+ constructor(options = {}) {
731
+ super(CREDENTIAL_SANITIZATION_FAILURE_MESSAGE, options);
732
+ this.name = 'CredentialSanitizationError';
733
+ this.code = CREDENTIAL_SANITIZATION_ERROR_CODE;
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Exact publication-boundary sanitizer.
739
+ *
740
+ * Unlike best-effort local diagnostics, outbound mutations require both the
741
+ * synchronous maintained patterns and Secretlint to complete successfully.
742
+ * The final bytes are scanned again immediately before a caller publishes
743
+ * them. Any scanner failure or residual finding blocks publication.
744
+ */
745
+ export const sanitizeForPublication = async (input, options = {}) => {
746
+ try {
747
+ const scanner =
748
+ options.scanner ||
749
+ (async value => {
750
+ const sanitized = await sanitizeOutput(value, {
751
+ warnOnMismatch: false,
752
+ // Publication boundaries intentionally ignore all dangerous bypass
753
+ // flags and user-content exclusions.
754
+ skipOutputSanitization: false,
755
+ skipActiveTokensOutputSanitization: false,
756
+ excludeTokens: [],
757
+ });
758
+ if (sanitized === CREDENTIAL_SANITIZATION_FAILURE_MESSAGE) {
759
+ throw new Error('Primary sanitizer failed.');
760
+ }
761
+ return sanitized;
762
+ });
763
+ const sanitized = String(await scanner(String(input ?? '')));
764
+ const residualScanner =
765
+ options.residualScanner ||
766
+ (async value => {
767
+ const residuals = findCredentialResiduals(value);
768
+ const secretlintResiduals = await detectSecretsWithSecretlint(value, { required: true });
769
+ const knownTokenResiduals = await containsKnownToken(value);
770
+ return [...residuals, ...secretlintResiduals, ...knownTokenResiduals];
771
+ });
772
+ const residuals = await residualScanner(sanitized);
773
+ if (!Array.isArray(residuals) || residuals.length > 0) {
774
+ throw new Error('Residual credential material detected.');
775
+ }
776
+ return sanitized;
777
+ } catch (cause) {
778
+ reportError(new Error('Credential publication boundary blocked unsafe output.'), {
779
+ context: 'credential_publication_boundary',
780
+ level: 'warning',
781
+ });
782
+ throw new CredentialSanitizationError({ cause });
783
+ }
784
+ };
785
+
786
+ /**
787
+ * Write an exact outbound payload to an owner-readable file after the
788
+ * fail-closed publication scan. Returns the bytes written for callers that
789
+ * also need to compare or reuse them.
790
+ */
791
+ export const writeSanitizedPublicationFile = async (filePath, input) => {
792
+ const sanitized = await sanitizeForPublication(input);
793
+ const fs = await getFsModule();
794
+ // Publication intermediates are always new files. Exclusive creation avoids
795
+ // following a pre-planted symlink in a shared temporary directory.
796
+ const handle = await fs.open(filePath, 'wx', 0o600);
797
+ try {
798
+ await handle.writeFile(sanitized, { encoding: 'utf8' });
799
+ await handle.chmod(0o600);
800
+ } finally {
801
+ await handle.close();
802
+ }
803
+ return sanitized;
804
+ };
805
+
647
806
  // Export detection functions for testing and visibility
648
807
  export { detectSecretsWithSecretlint, detectSecretsWithCustomPatterns, compareDetectionResults };
649
808
 
@@ -706,7 +865,7 @@ export const getEnvironmentTokens = () => {
706
865
  const out = [];
707
866
  for (const name of KNOWN_LOCAL_TOKEN_ENV_VARS) {
708
867
  const value = process.env[name];
709
- if (typeof value === 'string' && value.length >= 12) {
868
+ if (typeof value === 'string' && value.length > 0) {
710
869
  out.push({ name, value });
711
870
  }
712
871
  }
@@ -888,6 +1047,8 @@ export const extractTokensFromUserContent = async (text, options = {}) => {
888
1047
 
889
1048
  // Default export for convenience
890
1049
  export default {
1050
+ CredentialSanitizationError,
1051
+ createCredentialStreamSanitizer,
891
1052
  isSafeToken,
892
1053
  isHexInSafeContext,
893
1054
  getGitHubTokensFromFiles,
@@ -900,6 +1061,8 @@ export default {
900
1061
  getEnvironmentTokens,
901
1062
  getAllKnownLocalTokens,
902
1063
  containsKnownToken,
1064
+ sanitizeForPublication,
1065
+ writeSanitizedPublicationFile,
903
1066
  sanitizeCommentBody,
904
1067
  getSanitizationStats,
905
1068
  resetSanitizationStats,
@@ -215,7 +215,7 @@ export const resetTrackedToolCommentIds = () => {
215
215
  * @param {string} options.body
216
216
  * @returns {Promise<{ok: boolean, commentId: string|null, stderr?: string}>}
217
217
  */
218
- export const postTrackedComment = async ({ $, owner, repo, targetNumber, body, sanitizationOptions }) => {
218
+ export const postTrackedComment = async ({ $, owner, repo, targetNumber, body, sanitizationOptions: _sanitizationOptions }) => {
219
219
  if (!$) {
220
220
  throw new Error('postTrackedComment requires a command-stream $ helper');
221
221
  }
@@ -225,10 +225,10 @@ export const postTrackedComment = async ({ $, owner, repo, targetNumber, body, s
225
225
  // We use the /issues/<n>/comments endpoint because it works identically
226
226
  // for both PRs and issues (a PR is an issue at this endpoint).
227
227
  const apiPath = `repos/${owner}/${repo}/issues/${targetNumber}/comments`;
228
- const { sanitizeOutput } = await import('./token-sanitization.lib.mjs');
229
- // Issue #1745: caller may pass dangerous-skip flags + carve-out tokens.
230
- // Defaults preserve fail-closed behavior: full sanitization.
231
- const sanitizedBody = await sanitizeOutput(body, sanitizationOptions || {});
228
+ const { sanitizeForPublication } = await import('./token-sanitization.lib.mjs');
229
+ // This is the exact outbound mutation boundary. Dangerous local-output
230
+ // bypasses and user-content carve-outs must not weaken GitHub publication.
231
+ const sanitizedBody = await sanitizeForPublication(body);
232
232
  const payload = JSON.stringify({ body: sanitizedBody });
233
233
 
234
234
  // command-stream's options key is `stdin`, not `input` — unknown keys are
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from '../github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
3
+ import { sanitizeForPublication } from '../token-sanitization.lib.mjs';
3
4
 
4
5
  /**
5
6
  * YouTrack to GitHub Issue Synchronization Module
@@ -66,10 +67,10 @@ export async function syncYouTrackIssueToGitHub(youTrackIssue, owner, repo, youT
66
67
 
67
68
  // Format title with YouTrack ID for automatic linking
68
69
  // Format: "[PROJECT-123] Original Title" or "PROJECT-123: Original Title"
69
- const ghTitle = `[${youTrackId}] ${youTrackIssue.summary}`;
70
+ const ghTitle = await sanitizeForPublication(`[${youTrackId}] ${youTrackIssue.summary}`);
70
71
 
71
72
  // Build issue body with YouTrack details
72
- const ghBody = `## YouTrack Issue
73
+ const ghBody = await sanitizeForPublication(`## YouTrack Issue
73
74
 
74
75
  **ID:** ${youTrackId}
75
76
  **Link:** ${youTrackUrl}
@@ -82,7 +83,7 @@ ${youTrackIssue.description || 'No description provided.'}
82
83
  ---
83
84
  *This issue is automatically synchronized from YouTrack. Any commits or PRs that reference \`${youTrackId}\` will be automatically linked in YouTrack.*
84
85
 
85
- **Note:** To process this issue, ensure the 'help wanted' label exists in your repository.`;
86
+ **Note:** To process this issue, ensure the 'help wanted' label exists in your repository.`);
86
87
 
87
88
  // Check if issue already exists
88
89
  const existingIssue = await findGitHubIssueForYouTrack(youTrackId, owner, repo, $);