@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
@@ -7,8 +7,8 @@ import { log, maskToken, cleanErrorMessage, isENOSPC, ghCmdRetry } from './lib.m
7
7
  import { reportError } from './sentry.lib.mjs';
8
8
  import { describeRequestedThinking, githubLimits, timeouts } from './config.lib.mjs';
9
9
  import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
10
- import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent } from './token-sanitization.lib.mjs';
11
- export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent }; // Re-export for backward compatibility
10
+ import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent, sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
11
+ export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent, sanitizeForPublication, writeSanitizedPublicationFile }; // Re-export for backward compatibility
12
12
  import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
13
13
  import { formatResetTimeWithRelative } from './usage-limit.lib.mjs'; // See: https://github.com/link-assistant/hive-mind/issues/1236
14
14
  // Import model info helpers (Issue #1225)
@@ -336,7 +336,6 @@ export async function attachLogToGitHub(options) {
336
336
  repo,
337
337
  $,
338
338
  log,
339
- sanitizeLogContent,
340
339
  verbose = false,
341
340
  errorMessage,
342
341
  customTitle = `🤖 ${SOLUTION_DRAFT_LOG_MARKER}`,
@@ -452,7 +451,7 @@ export async function attachLogToGitHub(options) {
452
451
  if (verbose) {
453
452
  await log(' 🔍 Sanitizing log content to mask GitHub tokens...', { verbose: true });
454
453
  }
455
- let logContent = await sanitizeLogContent(rawLogContent);
454
+ let logContent = await sanitizeForPublication(rawLogContent);
456
455
 
457
456
  // Escape code blocks in the log content to prevent them from breaking markdown formatting
458
457
  if (verbose) {
@@ -637,17 +636,21 @@ ${logContent}
637
636
  // Create temp log file with sanitized content (no compression, just gh-upload-log)
638
637
  const tempLogFile = `/tmp/solution-draft-log-${targetType}-${Date.now()}.txt`;
639
638
  // Use the original sanitized content for upload since it's a plain text file
640
- await fs.writeFile(tempLogFile, await sanitizeLogContent(rawLogContent));
639
+ await writeSanitizedPublicationFile(tempLogFile, rawLogContent);
641
640
 
642
641
  // Use gh-upload-log default auto mode and shared repository fallback.
643
642
  const uploadDescription = `Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}`;
644
- const uploadResult = await uploadLogWithGhUploadLog({
645
- logFile: tempLogFile,
646
- isPublic: isPublicRepo,
647
- description: uploadDescription,
648
- verbose,
649
- });
650
- await fs.unlink(tempLogFile).catch(() => {});
643
+ let uploadResult;
644
+ try {
645
+ uploadResult = await uploadLogWithGhUploadLog({
646
+ logFile: tempLogFile,
647
+ isPublic: isPublicRepo,
648
+ description: uploadDescription,
649
+ verbose,
650
+ });
651
+ } finally {
652
+ await fs.unlink(tempLogFile).catch(() => {});
653
+ }
651
654
 
652
655
  if (uploadResult.success) {
653
656
  // Use rawUrl for direct file access (single chunk) or url for repository (multiple chunks)
@@ -782,12 +785,16 @@ ${sessionNote}
782
785
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
783
786
  }
784
787
  const tempCommentFile = `/tmp/log-upload-comment-${targetType}-${Date.now()}.md`;
785
- await fs.writeFile(tempCommentFile, logUploadComment);
788
+ await writeSanitizedPublicationFile(tempCommentFile, logUploadComment);
786
789
  // Issue #1625: post via postTrackedCommentFromFile so the returned
787
790
  // comment ID is registered in-memory and excluded from the
788
791
  // "did the AI post anything?" check.
789
- const posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber, bodyFile: tempCommentFile });
790
- await fs.unlink(tempCommentFile).catch(() => {});
792
+ let posted;
793
+ try {
794
+ posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber, bodyFile: tempCommentFile });
795
+ } finally {
796
+ await fs.unlink(tempCommentFile).catch(() => {});
797
+ }
791
798
  if (posted.ok) {
792
799
  const status = getLogUploadTerminalStatus({ errorMessage, errorDuringExecution, isUsageLimit });
793
800
  await log(` ${status.emoji} ${status.label} uploaded to ${targetName} as ${isPublicRepo ? 'public' : 'private'} ${uploadTypeLabel}${chunkInfo}${posted.commentId ? ` (comment id=${posted.commentId})` : ''}`);
@@ -846,12 +853,16 @@ async function attachRegularComment(options, logComment) {
846
853
  const logStats = await fs.stat(logFile);
847
854
 
848
855
  const tempFile = `/tmp/log-comment-${targetType}-${Date.now()}.md`;
849
- await fs.writeFile(tempFile, logComment);
856
+ await writeSanitizedPublicationFile(tempFile, logComment);
850
857
 
851
858
  // Issue #1625: track the posted comment ID so it's excluded from the
852
859
  // AI-authored-comment check in --auto-attach-solution-summary.
853
- const posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber, bodyFile: tempFile });
854
- await fs.unlink(tempFile).catch(() => {});
860
+ let posted;
861
+ try {
862
+ posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber, bodyFile: tempFile });
863
+ } finally {
864
+ await fs.unlink(tempFile).catch(() => {});
865
+ }
855
866
 
856
867
  if (posted.ok) {
857
868
  const status = getLogUploadTerminalStatus({ errorMessage, errorDuringExecution, isUsageLimit });
@@ -13,6 +13,9 @@
13
13
  */
14
14
 
15
15
  import { runHiveScreens } from './hive-screens.lib.mjs';
16
+ import { setupStdioLogInterceptor } from './lib.mjs';
17
+
18
+ setupStdioLogInterceptor();
16
19
 
17
20
  const exitCode = await runHiveScreens(process.argv.slice(2));
18
21
  process.exit(exitCode);
@@ -1,5 +1,17 @@
1
1
  // Lazy-load config only when needed to avoid loading use-m at module initialization
2
2
  // This prevents network fetches that can hang during --help or --version
3
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
+
5
+ const sanitizeEventValue = (value, seen = new WeakSet()) => {
6
+ if (typeof value === 'string') return sanitizeCredentialText(value);
7
+ if (!value || typeof value !== 'object') return value;
8
+ if (seen.has(value)) return value;
9
+ seen.add(value);
10
+ for (const [key, item] of Object.entries(value)) {
11
+ value[key] = sanitizeEventValue(item, seen);
12
+ }
13
+ return value;
14
+ };
3
15
 
4
16
  // Check if Sentry should be disabled
5
17
  const shouldDisableSentry = () => {
@@ -86,6 +98,8 @@ if (!shouldDisableSentry()) {
86
98
 
87
99
  // Before send hook to filter out sensitive data
88
100
  beforeSend(event) {
101
+ sanitizeEventValue(event);
102
+
89
103
  // Filter out sensitive environment variables
90
104
  if (event.contexts && event.contexts.runtime && event.contexts.runtime.env) {
91
105
  const sensitiveKeys = ['API_KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'ANTHROPIC'];
@@ -45,12 +45,12 @@ import { formatInteractiveMcpServersList, getInteractiveMcpDiagnostics } from '.
45
45
  // Use the session-started marker as the single source of truth for the
46
46
  // header string, keeping posting and filtering in lock-step.
47
47
  import { INTERACTIVE_SESSION_STARTED_MARKER, trackToolCommentId } from './tool-comments.lib.mjs';
48
- // Issue #1745: every comment body posted by the AI bridge MUST flow through
49
- // sanitizeCommentBody() before leaving the process. The leak in
48
+ // Issue #1745/#2111: every comment body posted by the AI bridge MUST flow
49
+ // through the strict publication sanitizer before leaving the process. The leak in
50
50
  // xlab2016/space_db_private#20 happened because raw bash-tool stdout
51
51
  // (including TELEGRAM_BOT_TOKEN=...) was published verbatim. See
52
52
  // docs/case-studies/issue-1745/analysis.md for the full timeline.
53
- import { containsKnownToken, getAllKnownLocalTokens, sanitizeCommentBody } from './token-sanitization.lib.mjs';
53
+ import { containsKnownToken, getAllKnownLocalTokens, sanitizeForPublication } from './token-sanitization.lib.mjs';
54
54
  import { reportInteractiveLeak } from './telegram-leak-notifier.lib.mjs';
55
55
 
56
56
  /**
@@ -73,15 +73,6 @@ export const createInteractiveHandler = options => {
73
73
  log,
74
74
  verbose = false,
75
75
  execFile: execFileFn,
76
- // Issue #1745: dangerous-skip flags. All default to false; passing them
77
- // through lets the operator opt out of pattern-based sanitization (for
78
- // controlled debugging in private repos) while keeping active-token
79
- // masking on by default.
80
- skipOutputSanitization = false,
81
- skipActiveTokensOutputSanitization = false,
82
- // Pre-existing user content carve-out (issue body / non-bot comments /
83
- // pre-existing code). When provided, sanitizer leaves these tokens untouched.
84
- excludeTokens = [],
85
76
  // Issue #1843: when true (default), base64 tool-result images are embedded
86
77
  // inline; when false they degrade to a metadata note. See createImageRenderer.
87
78
  imageUploadEnabled = true,
@@ -158,39 +149,33 @@ export const createInteractiveHandler = options => {
158
149
  hits = [];
159
150
  }
160
151
 
161
- let sanitized;
162
152
  try {
163
- sanitized = await sanitizeCommentBody(body, {
164
- knownTokens,
165
- skipOutputSanitization,
166
- skipActiveTokensOutputSanitization,
167
- excludeTokens,
168
- });
169
- } catch (err) {
170
- await log(`⚠️ Interactive mode: sanitizeCommentBody failed: ${err.message} — falling back to raw body MASKED`);
171
- // Fail closed: if sanitization fails entirely, drop the body to a safe
172
- // placeholder rather than leaking. Better to lose detail than secrets.
173
- sanitized = '[redacted: sanitization failed]';
174
- }
153
+ const sanitized = await sanitizeForPublication(body);
175
154
 
176
- if (hits.length > 0) {
177
- await log(`🚨 Interactive mode: known-local token(s) detected in outbound comment — sanitizer masked them. Sources: ${hits.map(h => h.source).join(', ')}`);
178
- try {
179
- await reportInteractiveLeak({
180
- owner,
181
- repo,
182
- prNumber,
183
- tokenHits: hits,
184
- log,
185
- });
186
- } catch (err) {
187
- if (verbose) {
188
- await log(`⚠️ Interactive mode: leak notifier failed: ${err.message}`, { verbose: true });
155
+ if (hits.length > 0) {
156
+ await log(`🚨 Interactive mode: known-local token(s) detected in outbound comment — sanitizer masked them. Sources: ${hits.map(h => h.source).join(', ')}`);
157
+ try {
158
+ await reportInteractiveLeak({
159
+ owner,
160
+ repo,
161
+ prNumber,
162
+ tokenHits: hits,
163
+ log,
164
+ });
165
+ } catch (err) {
166
+ if (verbose) {
167
+ await log(`⚠️ Interactive mode: leak notifier failed: ${err.message}`, { verbose: true });
168
+ }
189
169
  }
190
170
  }
191
- }
192
171
 
193
- return sanitized;
172
+ return sanitized;
173
+ } catch (error) {
174
+ // Publication is an external trust boundary. Legacy skip/exclusion
175
+ // options accepted by createInteractiveHandler must never weaken it.
176
+ await log('⚠️ Interactive mode: credential sanitization failed; GitHub mutation blocked.');
177
+ throw error;
178
+ }
194
179
  };
195
180
 
196
181
  /**
package/src/lib.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+ import { createCredentialStreamSanitizer, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
+
5
+ export { maskToken };
3
6
 
4
7
  // Shared library functions for hive-mind project
5
8
 
@@ -36,7 +39,8 @@ if (typeof globalThis.use === 'undefined') {
36
39
  await ensureUseM();
37
40
  }
38
41
 
39
- const fs = (await use('fs')).promises;
42
+ const fsModule = await use('fs');
43
+ const fs = fsModule.promises;
40
44
 
41
45
  // Global reference for log file (can be set by importing module)
42
46
  export let logFile = null;
@@ -83,15 +87,20 @@ export const log = async (message, options = {}) => {
83
87
  return;
84
88
  }
85
89
 
90
+ const sanitizedMessage = sanitizeCredentialText(message);
91
+
86
92
  // Write to file if log file is set
87
93
  // Issue #1572: Handle multi-line messages by timestamping each line,
88
94
  // so continuation lines don't appear without timestamps in the log file
89
95
  if (logFile) {
90
96
  const timestamp = new Date().toISOString();
91
97
  const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
92
- const lines = String(message).split('\n');
98
+ const lines = sanitizedMessage.split('\n');
93
99
  const logMessage = lines.map(line => `${prefix} ${line}`).join('\n');
94
- await fs.appendFile(logFile, logMessage + '\n').catch(error => {
100
+ try {
101
+ await fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 });
102
+ await fs.chmod(logFile, 0o600);
103
+ } catch (error) {
95
104
  // Silent fail for file append errors to avoid infinite loop
96
105
  // but report to Sentry in verbose mode
97
106
  if (global.verboseMode) {
@@ -101,7 +110,7 @@ export const log = async (message, options = {}) => {
101
110
  logFile,
102
111
  });
103
112
  }
104
- });
113
+ }
105
114
  }
106
115
 
107
116
  // Write to console based on level
@@ -110,15 +119,15 @@ export const log = async (message, options = {}) => {
110
119
  try {
111
120
  switch (level) {
112
121
  case 'error':
113
- console.error(message);
122
+ console.error(sanitizedMessage);
114
123
  break;
115
124
  case 'warning':
116
125
  case 'warn':
117
- console.warn(message);
126
+ console.warn(sanitizedMessage);
118
127
  break;
119
128
  case 'info':
120
129
  default:
121
- console.log(message);
130
+ console.log(sanitizedMessage);
122
131
  break;
123
132
  }
124
133
  } finally {
@@ -151,18 +160,20 @@ export const setupVerboseLogInterceptor = () => {
151
160
  if (logFile && args.length > 0) {
152
161
  const firstArg = String(args[0]);
153
162
  if (firstArg.includes('[VERBOSE]')) {
154
- const message = args.map(a => String(a)).join(' ');
163
+ const message = sanitizeCredentialText(args.map(a => String(a)).join(' '));
155
164
  const logMessage = `[${new Date().toISOString()}] [VERBOSE] ${message}`;
156
165
  _writingFromLog = true;
157
- fs.appendFile(logFile, logMessage + '\n').catch(() => {
158
- // Silent fail to avoid infinite loops
159
- });
166
+ fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 })
167
+ .then(() => fs.chmod(logFile, 0o600))
168
+ .catch(() => {
169
+ // Silent fail to avoid infinite loops
170
+ });
160
171
  }
161
172
  }
162
173
 
163
174
  // Always call original console.log (with guard flag set if [VERBOSE])
164
175
  try {
165
- originalConsoleLog(...args);
176
+ originalConsoleLog(...args.map(value => (typeof value === 'string' || Buffer.isBuffer(value) ? sanitizeCredentialText(value) : value)));
166
177
  } finally {
167
178
  _writingFromLog = false;
168
179
  }
@@ -206,9 +217,12 @@ const invokeWriteCallback = (callback, error = null) => {
206
217
  const appendInternalDiagnostic = async message => {
207
218
  if (!logFile) return;
208
219
  const prefix = `[${new Date().toISOString()}] [INTERNAL]`;
209
- await fs.appendFile(logFile, `${prefix} ${message}\n`).catch(() => {
210
- // Silent fail to avoid recursive logging errors
211
- });
220
+ await fs
221
+ .appendFile(logFile, `${prefix} ${sanitizeCredentialText(message)}\n`, { mode: 0o600 })
222
+ .then(() => fs.chmod(logFile, 0o600))
223
+ .catch(() => {
224
+ // Silent fail to avoid recursive logging errors
225
+ });
212
226
  };
213
227
 
214
228
  const formatStreamDiagnostic = stream => {
@@ -278,14 +292,51 @@ export const setupStdioLogInterceptor = () => {
278
292
 
279
293
  const originalStdoutWrite = process.stdout.write.bind(process.stdout);
280
294
  const originalStderrWrite = process.stderr.write.bind(process.stderr);
295
+ const stdoutSanitizer = createCredentialStreamSanitizer();
296
+ const stderrSanitizer = createCredentialStreamSanitizer();
281
297
  installBrokenPipeGuard(process.stdout, 'stdout');
282
298
  installBrokenPipeGuard(process.stderr, 'stderr');
283
299
 
300
+ // Node does not guarantee that the final write contains a newline. Flush
301
+ // retained records on both graceful and explicit exits so buffering never
302
+ // drops the sanitized tail of terminal output or its persistent log.
303
+ const flushPendingRecord = (sanitizer, originalWrite, streamName) => {
304
+ const sanitizedChunk = sanitizer.flush();
305
+ if (!sanitizedChunk) return;
306
+ try {
307
+ originalWrite(sanitizedChunk);
308
+ } catch (error) {
309
+ if (!isBrokenPipeError(error)) throw error;
310
+ }
311
+ if (logFile && sanitizedChunk.trim()) {
312
+ try {
313
+ const logMessage = `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${sanitizedChunk}`;
314
+ fsModule.appendFileSync(logFile, logMessage + (sanitizedChunk.endsWith('\n') ? '' : '\n'), { mode: 0o600 });
315
+ fsModule.chmodSync(logFile, 0o600);
316
+ } catch {
317
+ // Exit-time logging cannot safely report another error.
318
+ }
319
+ }
320
+ };
321
+ const flushPendingOutput = () => {
322
+ flushPendingRecord(stdoutSanitizer, originalStdoutWrite, 'stdout');
323
+ flushPendingRecord(stderrSanitizer, originalStderrWrite, 'stderr');
324
+ };
325
+ process.once('beforeExit', flushPendingOutput);
326
+ process.once('exit', flushPendingOutput);
327
+
284
328
  process.stdout.write = (chunk, encoding, callback) => {
285
- // Always write to terminal first, unless the output pipe is already broken.
329
+ const normalizedCallback = normalizeWriteCallback(encoding, callback);
330
+ const sanitizedChunk = stdoutSanitizer.write(chunk);
331
+ if (!sanitizedChunk) {
332
+ invokeWriteCallback(normalizedCallback);
333
+ return true;
334
+ }
335
+
336
+ // Write only the completed, sanitized record to the terminal.
286
337
  const result = safeTerminalWrite({
287
338
  originalWrite: originalStdoutWrite,
288
- chunk,
339
+ chunk: sanitizedChunk,
289
340
  encoding,
290
341
  callback,
291
342
  streamName: 'stdout',
@@ -293,12 +344,14 @@ export const setupStdioLogInterceptor = () => {
293
344
 
294
345
  // Also append to log file if set, but skip if this write originated from log()
295
346
  if (logFile && !_writingFromLog) {
296
- const text = typeof chunk === 'string' ? chunk : chunk.toString(encoding || 'utf8');
347
+ const text = sanitizedChunk;
297
348
  if (text.trim()) {
298
349
  const logMessage = `[${new Date().toISOString()}] [STDOUT] ${text.replace(/\n$/, '')}`;
299
- fs.appendFile(logFile, logMessage + '\n').catch(() => {
300
- // Silent fail to avoid infinite loops
301
- });
350
+ fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 })
351
+ .then(() => fs.chmod(logFile, 0o600))
352
+ .catch(() => {
353
+ // Silent fail to avoid infinite loops
354
+ });
302
355
  }
303
356
  }
304
357
 
@@ -306,10 +359,17 @@ export const setupStdioLogInterceptor = () => {
306
359
  };
307
360
 
308
361
  process.stderr.write = (chunk, encoding, callback) => {
309
- // Always write to terminal first, unless the output pipe is already broken.
362
+ const normalizedCallback = normalizeWriteCallback(encoding, callback);
363
+ const sanitizedChunk = stderrSanitizer.write(chunk);
364
+ if (!sanitizedChunk) {
365
+ invokeWriteCallback(normalizedCallback);
366
+ return true;
367
+ }
368
+
369
+ // Write only the completed, sanitized record to the terminal.
310
370
  const result = safeTerminalWrite({
311
371
  originalWrite: originalStderrWrite,
312
- chunk,
372
+ chunk: sanitizedChunk,
313
373
  encoding,
314
374
  callback,
315
375
  streamName: 'stderr',
@@ -317,12 +377,14 @@ export const setupStdioLogInterceptor = () => {
317
377
 
318
378
  // Also append to log file if set, but skip if this write originated from log()
319
379
  if (logFile && !_writingFromLog) {
320
- const text = typeof chunk === 'string' ? chunk : chunk.toString(encoding || 'utf8');
380
+ const text = sanitizedChunk;
321
381
  if (text.trim()) {
322
382
  const logMessage = `[${new Date().toISOString()}] [STDERR] ${text.replace(/\n$/, '')}`;
323
- fs.appendFile(logFile, logMessage + '\n').catch(() => {
324
- // Silent fail to avoid infinite loops
325
- });
383
+ fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 })
384
+ .then(() => fs.chmod(logFile, 0o600))
385
+ .catch(() => {
386
+ // Silent fail to avoid infinite loops
387
+ });
326
388
  }
327
389
  }
328
390
 
@@ -330,29 +392,6 @@ export const setupStdioLogInterceptor = () => {
330
392
  };
331
393
  };
332
394
 
333
- /**
334
- * Mask sensitive tokens in text
335
- * @param {string} token - Token to mask
336
- * @param {Object} options - Masking options
337
- * @param {number} [options.minLength=12] - Minimum length to mask
338
- * @param {number} [options.startChars=3] - Number of characters to show at start
339
- * @param {number} [options.endChars=3] - Number of characters to show at end
340
- * @returns {string} Masked token
341
- */
342
- export const maskToken = (token, options = {}) => {
343
- const { minLength = 12, startChars = 3, endChars = 3 } = options;
344
-
345
- if (!token || token.length < minLength) {
346
- return token; // Don't mask very short strings
347
- }
348
-
349
- const start = token.substring(0, startChars);
350
- const end = token.substring(token.length - endChars);
351
- const middle = '*'.repeat(Math.max(token.length - (startChars + endChars), 3));
352
-
353
- return start + middle + end;
354
- };
355
-
356
395
  /**
357
396
  * Format timestamps for use in filenames
358
397
  * @param {Date} [date=new Date()] - Date to format
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
3
7
 
4
8
  // Log upload module for hive-mind
5
9
  // Uses gh-upload-log for uploading log files to GitHub
@@ -142,14 +146,24 @@ export const parseGhUploadLogOutput = outputValue => {
142
146
  * @param {boolean} [options.verbose=false] - Enable verbose logging
143
147
  * @returns {Promise<{success: boolean, url: string|null, rawUrl: string|null, type: 'gist'|'repository'|null, chunks: number, repositoryName?: string|null, repositoryPath?: string|null}>}
144
148
  */
145
- export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description, verbose = false }) => {
149
+ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description, verbose = false, runUpload = runGhUploadLogCommand }) => {
146
150
  const result = { success: false, url: null, rawUrl: null, type: null, chunks: 1 };
151
+ let privateTempDirectory = null;
147
152
 
148
153
  try {
154
+ const exactSourceBytes = await fs.readFile(logFile, 'utf8');
155
+ const sanitizedLog = await sanitizeForPublication(exactSourceBytes);
156
+ const sanitizedDescription = description ? await sanitizeForPublication(description) : description;
157
+ privateTempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'hive-mind-log-upload-'));
158
+ await fs.chmod(privateTempDirectory, 0o700);
159
+ const privateLogFile = path.join(privateTempDirectory, 'sanitized.log');
160
+ await fs.writeFile(privateLogFile, sanitizedLog, { encoding: 'utf8', mode: 0o600 });
161
+ await fs.chmod(privateLogFile, 0o600);
162
+
149
163
  const commandArgs = buildGhUploadLogArgs({
150
- logFile,
164
+ logFile: privateLogFile,
151
165
  isPublic,
152
- description,
166
+ description: sanitizedDescription,
153
167
  verbose,
154
168
  });
155
169
 
@@ -157,7 +171,7 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
157
171
  await log(` 📤 Running: ${formatGhUploadLogCommand(commandArgs)}`, { verbose: true });
158
172
  }
159
173
 
160
- const uploadResult = await runGhUploadLogCommand(commandArgs);
174
+ const uploadResult = await runUpload(commandArgs);
161
175
  const output = (uploadResult.stdout?.toString() || '') + (uploadResult.stderr?.toString() || '');
162
176
 
163
177
  if (uploadResult.code !== 0) {
@@ -291,6 +305,10 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
291
305
  });
292
306
  await log(` ❌ Error running gh-upload-log: ${error.message}`);
293
307
  return result;
308
+ } finally {
309
+ if (privateTempDirectory) {
310
+ await fs.rm(privateTempDirectory, { recursive: true, force: true }).catch(() => {});
311
+ }
294
312
  }
295
313
  };
296
314
 
@@ -21,7 +21,7 @@
21
21
  * @module post-finish-sanitization-sweep
22
22
  */
23
23
 
24
- import { sanitizeOutput, getSanitizationStats } from './token-sanitization.lib.mjs';
24
+ import { sanitizeForPublication, getSanitizationStats } from './token-sanitization.lib.mjs';
25
25
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): caller passes $ already wrapped through wrapDollarWithGhRetry
26
26
 
27
27
  /**
@@ -57,7 +57,7 @@ const detectBotLogin = async $ => {
57
57
  * @param {Object} [args.sanitizationOptions] forwarded to sanitizeOutput
58
58
  * @returns {Promise<{scanned:number, edited:number, errors:number}>}
59
59
  */
60
- export const sweepPrConversationComments = async ({ $, owner, repo, prNumber, botLogin, log = async () => {}, sanitizationOptions = {} }) => {
60
+ export const sweepPrConversationComments = async ({ $, owner, repo, prNumber, botLogin, log = async () => {}, sanitizationOptions: _sanitizationOptions = {} }) => {
61
61
  const stats = { scanned: 0, edited: 0, errors: 0 };
62
62
  let response;
63
63
  try {
@@ -86,7 +86,7 @@ export const sweepPrConversationComments = async ({ $, owner, repo, prNumber, bo
86
86
  stats.scanned++;
87
87
  let sanitized;
88
88
  try {
89
- sanitized = await sanitizeOutput(c.body, sanitizationOptions);
89
+ sanitized = await sanitizeForPublication(c.body);
90
90
  } catch (err) {
91
91
  await log(`⚠️ post-finish sweep: sanitize comment ${c.id} failed: ${err.message || err}`);
92
92
  stats.errors++;
@@ -116,7 +116,7 @@ export const sweepPrConversationComments = async ({ $, owner, repo, prNumber, bo
116
116
  * @param {Object} args
117
117
  * @returns {Promise<{scanned:number, edited:number, errors:number}>}
118
118
  */
119
- export const sweepPrDescription = async ({ $, owner, repo, prNumber, log = async () => {}, sanitizationOptions = {} }) => {
119
+ export const sweepPrDescription = async ({ $, owner, repo, prNumber, log = async () => {}, sanitizationOptions: _sanitizationOptions = {} }) => {
120
120
  const stats = { scanned: 0, edited: 0, errors: 0 };
121
121
  let response;
122
122
  try {
@@ -142,7 +142,7 @@ export const sweepPrDescription = async ({ $, owner, repo, prNumber, log = async
142
142
  stats.scanned++;
143
143
  let sanitized;
144
144
  try {
145
- sanitized = await sanitizeOutput(body, sanitizationOptions);
145
+ sanitized = await sanitizeForPublication(body);
146
146
  } catch (err) {
147
147
  await log(`⚠️ post-finish sweep: sanitize PR body failed: ${err.message || err}`);
148
148
  stats.errors++;
package/src/review.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+ import { log, setLogFile, getLogFile, formatAligned, extractToolErrorCore, setupStdioLogInterceptor } from './lib.mjs';
4
+
5
+ setupStdioLogInterceptor();
3
6
 
4
7
  // Early exit paths - handle these before loading all modules to speed up testing
5
8
  const earlyArgs = process.argv.slice(2);
@@ -44,7 +47,6 @@ const path = (await use('path')).default;
44
47
  const fs = (await use('fs')).promises;
45
48
 
46
49
  // Import shared functions from lib.mjs to follow DRY principle
47
- import { log, setLogFile, getLogFile, formatAligned, extractToolErrorCore } from './lib.mjs';
48
50
  import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
49
51
  import { reportError } from './sentry.lib.mjs';
50
52
  import * as memoryCheck from './memory-check.mjs';