@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.
- package/CHANGELOG.md +12 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +0 -4
- package/src/cleanup.mjs +18 -6
- package/src/codex.lib.mjs +0 -4
- package/src/configure-claude.mjs +3 -0
- package/src/credential-sanitization-core.lib.mjs +231 -0
- package/src/development-log.lib.mjs +39 -6
- package/src/fix.mjs +3 -0
- package/src/github-error-reporter.lib.mjs +13 -8
- package/src/github-issue-auto-close.lib.mjs +2 -1
- package/src/github-merge-issue-close.lib.mjs +2 -1
- package/src/github.lib.mjs +29 -18
- package/src/hive-screens.mjs +3 -0
- package/src/instrument.mjs +14 -0
- package/src/interactive-mode.lib.mjs +25 -40
- package/src/lib.mjs +89 -50
- package/src/log-upload.lib.mjs +22 -4
- package/src/post-finish-sanitization-sweep.lib.mjs +5 -5
- package/src/review.mjs +3 -1
- package/src/sentry.lib.mjs +27 -8
- package/src/session-monitor.lib.mjs +6 -10
- package/src/session-resume.lib.mjs +43 -23
- package/src/session-store.lib.mjs +3 -1
- package/src/solve.auto-pr.lib.mjs +11 -21
- package/src/solve.error-handlers.lib.mjs +2 -1
- package/src/solve.progress-monitoring.lib.mjs +20 -9
- package/src/solve.results.lib.mjs +9 -15
- package/src/start-screen.mjs +3 -0
- package/src/task.issue-creation.lib.mjs +5 -3
- package/src/task.mjs +21 -8
- package/src/telegram-bot.mjs +6 -3
- package/src/telegram-command-execution.lib.mjs +2 -2
- package/src/telegram-isolation.lib.mjs +2 -0
- package/src/telegram-log-command.lib.mjs +38 -4
- package/src/telegram-safe-reply.lib.mjs +7 -4
- package/src/telegram-solve-queue.lib.mjs +5 -6
- package/src/telegram-tokens-command.lib.mjs +1 -1
- package/src/token-sanitization.lib.mjs +177 -14
- package/src/tool-comments.lib.mjs +5 -5
- 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
|
|
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(
|
|
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
|
-
|
|
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.' :
|
|
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
|
-
|
|
1448
|
-
//
|
|
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
|
-
//
|
|
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
|
|
69
|
+
* The masked form is `first-3…last-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
|
|
19
|
-
|
|
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 (
|
|
115
|
+
} catch (_error) {
|
|
111
116
|
// secretlint not available - fall back to custom patterns only
|
|
112
117
|
if (global.verboseMode) {
|
|
113
|
-
await log(
|
|
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(
|
|
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
|
-
//
|
|
528
|
-
|
|
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
|
|
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:
|
|
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
|
|
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 {
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
const sanitizedBody = await
|
|
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, $);
|