@link-assistant/hive-mind 2.10.1 → 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 (39) hide show
  1. package/CHANGELOG.md +6 -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/solve.auto-pr.lib.mjs +11 -21
  27. package/src/solve.error-handlers.lib.mjs +2 -1
  28. package/src/solve.progress-monitoring.lib.mjs +20 -9
  29. package/src/solve.results.lib.mjs +9 -15
  30. package/src/start-screen.mjs +3 -0
  31. package/src/task.issue-creation.lib.mjs +5 -3
  32. package/src/task.mjs +21 -8
  33. package/src/telegram-bot.mjs +4 -1
  34. package/src/telegram-log-command.lib.mjs +38 -4
  35. package/src/telegram-safe-reply.lib.mjs +7 -4
  36. package/src/telegram-tokens-command.lib.mjs +1 -1
  37. package/src/token-sanitization.lib.mjs +177 -14
  38. package/src/tool-comments.lib.mjs +5 -5
  39. package/src/youtrack/youtrack-sync.mjs +4 -3
@@ -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, $);