@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.10.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 4e41ee5: Sanitize credentials consistently across terminal output, logs, development-log artifacts, error telemetry, uploads, and GitHub publication boundaries.
8
+
3
9
  ## 2.10.1
4
10
 
5
11
  ### Patch Changes
package/README.hi.md CHANGED
@@ -86,6 +86,8 @@ Hive Mind में औसत प्रोग्रामर से अलग
86
86
  - कभी भी उत्पादन टोकन या क्रेडेंशियल का उपयोग न करना
87
87
  - इस सिस्टम के साथ उपयोग किए गए सभी टोकन को रद्द और प्रतिस्थापित करने के लिए तैयार रहना
88
88
 
89
+ Hive Mind अपने प्रबंधित terminal, log, telemetry, upload और GitHub publication paths में पहचाने गए credentials को sanitize करता है। Scanner विफल होने पर publication रोक दिया जाता है, लेकिन यह किसी autonomous tool को किसी असंबंधित exfiltration path का उपयोग करने से नहीं रोकता। Masking contract, supported formats, maintenance process और incident-response guidance के लिए [Credential sanitization](./docs/CREDENTIAL-SANITIZATION.hi.md) देखें।
90
+
89
91
  `hive.mjs` चलाने के लिए न्यूनतम सिस्टम आवश्यकताएँ:
90
92
 
91
93
  ```
package/README.md CHANGED
@@ -86,6 +86,8 @@ We strongly recommend:
86
86
  - Never using production tokens or credentials
87
87
  - Being prepared to revoke and replace all tokens used with this system
88
88
 
89
+ Hive Mind sanitizes recognized credentials in its maintained terminal, log, telemetry, upload, and GitHub publication paths. Publication is blocked if the scanner fails, but this does not prevent an autonomous tool from using an unrelated exfiltration path. See [Credential sanitization](./docs/CREDENTIAL-SANITIZATION.md) for the masking contract, supported formats, maintenance process, and incident-response guidance.
90
+
89
91
  Minimum system requirements to run `hive.mjs`:
90
92
 
91
93
  ```
package/README.ru.md CHANGED
@@ -86,6 +86,8 @@ Hive Mind обладает высоким уровнем творчества,
86
86
  - Никогда не использовать производственные токены или учётные данные
87
87
  - Быть готовыми отозвать и заменить все токены, используемые в этой системе
88
88
 
89
+ Hive Mind скрывает распознанные учётные данные в обслуживаемых им путях вывода терминала, журналов, телеметрии, загрузок и публикаций GitHub. При сбое сканера публикация блокируется, однако это не мешает автономному инструменту использовать посторонний канал утечки. Соглашение о маскировании, поддерживаемые форматы, процесс сопровождения и рекомендации по реагированию на инциденты приведены в разделе [Санитизация учётных данных](./docs/CREDENTIAL-SANITIZATION.ru.md).
90
+
89
91
  Минимальные системные требования для запуска `hive.mjs`:
90
92
 
91
93
  ```
package/README.zh.md CHANGED
@@ -86,6 +86,8 @@ Hive Mind 具备与普通程序员无异的高度创造力。当需求不明确
86
86
  - 切勿使用生产环境的 Token 或凭证
87
87
  - 做好随时撤销并替换本系统所用所有 Token 的准备
88
88
 
89
+ Hive Mind 会在其维护的终端、日志、遥测、上传和 GitHub 发布路径中清理可识别的凭证。如果扫描器失败,发布会被阻止;但这无法阻止自主工具通过不相关的路径泄露数据。有关掩码约定、支持的格式、维护流程和事件响应指南,请参阅[凭证清理](./docs/CREDENTIAL-SANITIZATION.zh.md)。
90
+
89
91
  运行 `hive.mjs` 的最低系统要求:
90
92
 
91
93
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -601,10 +601,6 @@ export const executeClaudeCommand = async params => {
601
601
  $,
602
602
  log,
603
603
  verbose: argv.verbose,
604
- // Issue #1745: thread the three independent dangerous-skip flags through
605
- // so the comment-posting path can honor them; flags default to false.
606
- skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
607
- skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
608
604
  // Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
609
605
  imageUploadEnabled: argv['interactive-image-upload'] !== false,
610
606
  });
package/src/cleanup.mjs CHANGED
@@ -39,6 +39,10 @@ import { isConfirmationYes, readConfirmationLine } from './confirmation.lib.mjs'
39
39
  import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES, formatEntryContext, formatTaskSummary, DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE, describeDockerIsolationReason, formatDockerIsolationContainerSummary, normalizeDockerIsolationCleanupMode, planDockerIsolationCleanup } from './cleanup.lib.mjs';
40
40
  import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees, listDockerIsolationContainers, removeDockerContainer } from './cleanup.os.lib.mjs';
41
41
  import { formatProcessDebugReport } from './process-debug.lib.mjs';
42
+ import { setupStdioLogInterceptor } from './lib.mjs';
43
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
44
+
45
+ setupStdioLogInterceptor();
42
46
 
43
47
  const args = process.argv.slice(2);
44
48
 
@@ -179,15 +183,22 @@ const scriptDir = path.dirname(process.argv[1]);
179
183
  const logFile = path.join(scriptDir, `cleanup-${timestamp}.log`);
180
184
 
181
185
  async function log(message, { level = 'info' } = {}) {
182
- await fsp.appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`).catch(() => {});
183
- if (level === 'error') console.error(message);
184
- else if (level === 'warn' || level === 'warning') console.warn(message);
185
- else console.log(message);
186
+ const sanitizedMessage = sanitizeCredentialText(message);
187
+ await fsp
188
+ .appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${sanitizedMessage}\n`, { mode: 0o600 })
189
+ .then(() => fsp.chmod(logFile, 0o600))
190
+ .catch(() => {});
191
+ if (level === 'error') console.error(sanitizedMessage);
192
+ else if (level === 'warn' || level === 'warning') console.warn(sanitizedMessage);
193
+ else console.log(sanitizedMessage);
186
194
  }
187
195
 
188
196
  function vlog(message) {
189
197
  if (options.verbose) return log(message);
190
- return fsp.appendFile(logFile, `[${new Date().toISOString()}] [DEBUG] ${message}\n`).catch(() => {});
198
+ return fsp
199
+ .appendFile(logFile, `[${new Date().toISOString()}] [DEBUG] ${sanitizeCredentialText(message)}\n`, { mode: 0o600 })
200
+ .then(() => fsp.chmod(logFile, 0o600))
201
+ .catch(() => {});
191
202
  }
192
203
 
193
204
  /**
@@ -210,7 +221,8 @@ function computeSelfPaths(tempRoot) {
210
221
  }
211
222
 
212
223
  async function main() {
213
- await fsp.writeFile(logFile, `# Cleanup Log - ${new Date().toISOString()}\n\n`).catch(() => {});
224
+ await fsp.writeFile(logFile, `# Cleanup Log - ${new Date().toISOString()}\n\n`, { mode: 0o600 }).catch(() => {});
225
+ await fsp.chmod(logFile, 0o600).catch(() => {});
214
226
 
215
227
  const tempRoot = getTempRoot();
216
228
  await log('🧹 hive-mind cleanup');
package/src/codex.lib.mjs CHANGED
@@ -911,10 +911,6 @@ export const executeCodexCommand = async params => {
911
911
  $,
912
912
  log,
913
913
  verbose: argv.verbose,
914
- // Issue #1745: pass the three independent dangerous-skip flags so the
915
- // comment-posting path can honor them. All default to false.
916
- skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
917
- skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
918
914
  // Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
919
915
  imageUploadEnabled: argv['interactive-image-upload'] !== false,
920
916
  });
@@ -15,6 +15,9 @@
15
15
  */
16
16
 
17
17
  import { CONFIGURE_CLAUDE_HELP, formatVerifyReport, parseConfigureClaudeArgs, resolveSettingsPath, runConfigureClaude, verifyConfigureClaude } from './configure-claude.lib.mjs';
18
+ import { setupStdioLogInterceptor } from './lib.mjs';
19
+
20
+ setupStdioLogInterceptor();
18
21
 
19
22
  const args = parseConfigureClaudeArgs(process.argv.slice(2));
20
23
 
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Dependency-free credential sanitization primitives.
3
+ *
4
+ * This module deliberately has no project imports. It is safe to use from the
5
+ * lowest-level terminal and logging code, before asynchronous scanners such as
6
+ * Secretlint have been initialized.
7
+ */
8
+
9
+ import { StringDecoder } from 'node:string_decoder';
10
+
11
+ export const CREDENTIAL_SANITIZATION_ERROR_CODE = 'ERR_CREDENTIAL_SANITIZATION';
12
+ export const CREDENTIAL_SANITIZATION_FAILURE_MESSAGE = 'Credential sanitization failed; publication was blocked.';
13
+
14
+ const MASKED_VALUE = /^(?:\[REDACTED\]|.{3}….{3})$/u;
15
+
16
+ export const maskToken = token => {
17
+ const value = String(token ?? '');
18
+ if (value.length <= 12) return '[REDACTED]';
19
+ return `${value.slice(0, 3)}…${value.slice(-3)}`;
20
+ };
21
+
22
+ const maskValue = value => {
23
+ if (!value || MASKED_VALUE.test(value)) return value;
24
+ return maskToken(value);
25
+ };
26
+
27
+ const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+
29
+ const VENDOR_PATTERNS = Object.freeze([
30
+ // GitHub: classic, fine-grained, OAuth, user-to-server, server-to-server,
31
+ // refresh, and the stateless server token family.
32
+ /\b(?:github_pat_|gh[pousr]_)[A-Za-z0-9_]{16,}\b/g,
33
+ /\bghs_[A-Za-z0-9_]{16,}\b/g,
34
+ // GitLab personal/project/group/access, OAuth, deploy, runner, CI,
35
+ // feature-flag, session and agent token families.
36
+ /\b(?:glpat|gloas|gldt|glrt|glrtr|glcbt|glptt|glft|glimt|glagent|glwt|glsoat|glffct)-[A-Za-z0-9_-]{16,}\b/g,
37
+ // OpenAI and Anthropic.
38
+ /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{16,}\b/g,
39
+ /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{16,}\b/g,
40
+ // AWS access-key IDs. Secret keys and session tokens are caught by their
41
+ // assignment/header context below.
42
+ /\b(?:A3T[A-Z0-9]|AKIA|AGPA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b/g,
43
+ // Google/GCP API keys and OAuth client secrets.
44
+ /\bAIza[0-9A-Za-z_-]{32,40}\b/g,
45
+ /\bGOCSPX-[0-9A-Za-z_-]{16,}\b/g,
46
+ // Azure application secrets are additionally caught by generic key names
47
+ // such as AccountKey and client_secret. SAS values need a structured rule
48
+ // below so the SharedAccessSignature scheme remains visible.
49
+ // Slack app, bot, user, workspace and configuration tokens.
50
+ /\b(?:xox[a-z]|xapp|xwfp)-[0-9A-Za-z-]{20,}\b/g,
51
+ // Stripe secret/restricted/publishable keys and webhook signing secrets.
52
+ /\b(?:sk|rk|pk)_(?:live|test)_[0-9A-Za-z]{16,}\b/g,
53
+ /\bwhsec_[0-9A-Za-z]{16,}\b/g,
54
+ // SendGrid, Twilio, npm and PyPI.
55
+ /\bSG\.[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{20,}\b/g,
56
+ /\bSK[0-9a-fA-F]{32}\b/g,
57
+ /\bnpm_[0-9A-Za-z]{20,}\b/g,
58
+ /\bpypi-[0-9A-Za-z_-]{30,}\b/g,
59
+ // Telegram, Discord, Hugging Face and common additional service formats.
60
+ /\b[0-9]{8,12}:[0-9A-Za-z_-]{30,}\b/g,
61
+ /\b[MN][0-9A-Za-z_-]{23,}\.[0-9A-Za-z_-]{6}\.[0-9A-Za-z_-]{20,}\b/g,
62
+ /\bhf_[0-9A-Za-z]{20,}\b/g,
63
+ /\bshpat_[0-9a-fA-F]{32}\b/g,
64
+ /\bdapi[0-9a-fA-F]{32}\b/g,
65
+ /\bsq0(?:atp|csp)-[0-9A-Za-z_-]{20,}\b/g,
66
+ // JWT/JWS access, ID, and CI job tokens.
67
+ /\beyJ[0-9A-Za-z_-]{8,}\.eyJ[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}\b/g,
68
+ // Opaque CI/test credentials sometimes carry only an all-caps semantic
69
+ // marker and appear without a useful assignment name.
70
+ /\b(?:[A-Z0-9]+[_-])+(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API[_-]?KEY)(?:[_-][A-Z0-9]+)+\b/g,
71
+ ]);
72
+
73
+ const SENSITIVE_KEY = String.raw`(?:[A-Za-z0-9_.-]*(?:api[-_]?key|account[-_]?key|client[-_]?secret|consumer[-_]?secret|webhook[-_]?secret|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|passwd|pwd|private[-_]?key|secret|token|session[-_]?key|session[-_]?token|cookie|docker[-_]?auth|registry[-_]?auth|shared[-_]?access[-_]?signature|sas[-_]?token)[A-Za-z0-9_.-]*|auth|authorization)`;
74
+ const SENSITIVE_ENV_NAME = /(?:API_?KEY|ACCOUNT_?KEY|CLIENT_?SECRET|CONSUMER_?SECRET|WEBHOOK_?SECRET|ACCESS_?TOKEN|REFRESH_?TOKEN|AUTH_?TOKEN|PASSWORD|PASSWD|PRIVATE_?KEY|SECRET|TOKEN|COOKIE|AUTH)$/i;
75
+ const QUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
76
+ const UNQUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(?!["']|(?:Bearer|Basic|SharedAccessSignature)\\s)([^\\s,;}&'"\\r\\n]+)`, 'gi');
77
+ const XML_CREDENTIAL = new RegExp(`(<(${SENSITIVE_KEY})\\b[^>]*>)([\\s\\S]*?)(<\\/\\2\\s*>)`, 'gi');
78
+ const CLI_CREDENTIAL_QUOTED = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
79
+ const CLI_CREDENTIAL = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(?!["'])([^\\s"'\\r\\n]+)`, 'gi');
80
+ const QUERY_CREDENTIAL = new RegExp(`([?&]${SENSITIVE_KEY}=)([^&#\\s]+)`, 'gi');
81
+
82
+ const replaceVendorSecrets = text => {
83
+ let output = text;
84
+ for (const pattern of VENDOR_PATTERNS) {
85
+ pattern.lastIndex = 0;
86
+ output = output.replace(pattern, match => maskValue(match));
87
+ }
88
+ return output;
89
+ };
90
+
91
+ const replaceCookieHeader = (_match, prefix, cookieText) => {
92
+ const sanitized = cookieText.replace(/(^|;\s*)([^=;\s]+)(=)([^;\r\n]*)/g, (pair, separator, name, equals, value) => {
93
+ return `${separator}${name}${equals}${maskValue(value.trim())}`;
94
+ });
95
+ return `${prefix}${sanitized}`;
96
+ };
97
+
98
+ /**
99
+ * Synchronously sanitize known vendor credentials and credential-like
100
+ * structured values. The operation is deterministic and idempotent.
101
+ */
102
+ export const sanitizeCredentialText = (input, options = {}) => {
103
+ let output = String(input ?? '');
104
+
105
+ // Known active credentials are the strongest signal and are replaced before
106
+ // format matching, regardless of their shape or length.
107
+ const environmentTokens =
108
+ options.includeEnvironmentCredentials === false
109
+ ? []
110
+ : Object.entries(process.env)
111
+ .filter(([name, value]) => SENSITIVE_ENV_NAME.test(name) && typeof value === 'string' && value.length > 0)
112
+ .map(([, value]) => value);
113
+ for (const token of [...environmentTokens, ...(options.knownTokens || [])]) {
114
+ const value = typeof token === 'string' ? token : token?.value;
115
+ if (!value || !output.includes(value)) continue;
116
+ output = output.replace(new RegExp(escapeRegExp(value), 'g'), maskValue(value));
117
+ }
118
+
119
+ // Keep PEM markers for diagnostic context, but never preserve key material.
120
+ output = output.replace(/(-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----)[\s\S]*?(-----END \2-----)/g, (_match, begin, _label, end) => `${begin}\n[REDACTED]\n${end}`);
121
+ // An interrupted process may never print the closing PEM marker. Treat the
122
+ // remainder as key material instead of releasing it during stream flush.
123
+ output = output.replace(/(-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----)(?![\s\S]*?-----END \2-----)[\s\S]*/g, '$1\n[REDACTED]');
124
+
125
+ output = replaceVendorSecrets(output);
126
+
127
+ // Authentication headers and URL credentials.
128
+ output = output.replace(/((?:Proxy-)?Authorization\s*:\s*(?:Bearer|Basic)\s+)([^\s"',;]+)/gi, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
129
+ output = output.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, (_match, prefix, value, suffix) => `${prefix}${maskValue(value)}${suffix}`);
130
+ output = output.replace(/(\bSharedAccessSignature\s+)(sr=[^\s,;]+(?:&[^\s,;]+)+)/gi, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
131
+ output = output.replace(/(\bSharedAccessSignature\s*=\s*)([^\s"',;]+)/gi, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
132
+
133
+ // Cookie values are credentials at output boundaries even when their names
134
+ // are vendor-specific and therefore unknown to us.
135
+ output = output.replace(/((?:Set-)?Cookie\s*:\s*)([^\r\n]*)/gi, replaceCookieHeader);
136
+
137
+ // XML and JSON/YAML/TOML/INI/shell-style assignments.
138
+ output = output.replace(XML_CREDENTIAL, (_match, start, _key, value, end) => `${start}${maskValue(value.trim())}${end}`);
139
+ output = output.replace(QUOTED_ASSIGNMENT, (_match, prefix, quote, value) => `${prefix}${quote}${maskValue(value)}${quote}`);
140
+ output = output.replace(UNQUOTED_ASSIGNMENT, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
141
+
142
+ // CLI arguments and sensitive query parameters.
143
+ output = output.replace(CLI_CREDENTIAL_QUOTED, (_match, prefix, quote, value) => `${prefix}${quote}${maskValue(value)}${quote}`);
144
+ output = output.replace(CLI_CREDENTIAL, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
145
+ output = output.replace(QUERY_CREDENTIAL, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
146
+
147
+ // Vendor webhook URLs encode credentials in their path rather than a named
148
+ // field. Preserve the service endpoint and sanitize only the credential.
149
+ output = output.replace(/(https:\/\/hooks\.slack\.com\/services\/)([0-9A-Za-z/_-]+)/gi, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
150
+ output = output.replace(/(https:\/\/(?:canary\.)?discord(?:app)?\.com\/api\/webhooks\/)([0-9A-Za-z/_-]+)/gi, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
151
+
152
+ return output;
153
+ };
154
+
155
+ /**
156
+ * Return a non-sensitive indication that another sanitizer pass would change
157
+ * the text. Callers never receive the matching value.
158
+ */
159
+ export const findCredentialResiduals = input => {
160
+ const text = String(input ?? '');
161
+ return sanitizeCredentialText(text) === text ? [] : [{ ruleId: 'credential-pattern' }];
162
+ };
163
+
164
+ /**
165
+ * Record-oriented sanitizer for child-process stdout/stderr chunks. An
166
+ * incomplete record is retained until a newline arrives (or flush is called),
167
+ * so credentials split at arbitrary chunk boundaries are never emitted raw.
168
+ */
169
+ export const createCredentialStreamSanitizer = options => {
170
+ let pending = '';
171
+ let decoder = new StringDecoder('utf8');
172
+
173
+ const appendChunk = chunk => {
174
+ if (Buffer.isBuffer(chunk) || ArrayBuffer.isView(chunk)) {
175
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
176
+ pending += decoder.write(bytes);
177
+ return;
178
+ }
179
+ pending += decoder.end();
180
+ decoder = new StringDecoder('utf8');
181
+ pending += String(chunk ?? '');
182
+ };
183
+
184
+ const drain = () => {
185
+ let output = '';
186
+ const beginPattern = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/;
187
+
188
+ while (pending) {
189
+ const begin = beginPattern.exec(pending);
190
+ if (begin) {
191
+ const endMarker = `-----END ${begin[1]}-----`;
192
+ const endIndex = pending.indexOf(endMarker, begin.index + begin[0].length);
193
+ if (endIndex < 0) {
194
+ // Text before the marker can be released, but the marker and every
195
+ // following byte stay private until the matching end or flush.
196
+ if (begin.index > 0) {
197
+ output += sanitizeCredentialText(pending.slice(0, begin.index), options);
198
+ pending = pending.slice(begin.index);
199
+ }
200
+ break;
201
+ }
202
+
203
+ const keyEnd = endIndex + endMarker.length;
204
+ output += sanitizeCredentialText(pending.slice(0, keyEnd), options);
205
+ pending = pending.slice(keyEnd);
206
+ continue;
207
+ }
208
+
209
+ const boundary = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
210
+ if (boundary < 0) break;
211
+ output += sanitizeCredentialText(pending.slice(0, boundary + 1), options);
212
+ pending = pending.slice(boundary + 1);
213
+ }
214
+
215
+ return output;
216
+ };
217
+
218
+ return {
219
+ write(chunk) {
220
+ appendChunk(chunk);
221
+ return drain();
222
+ },
223
+ flush() {
224
+ pending += decoder.end();
225
+ decoder = new StringDecoder('utf8');
226
+ const complete = pending;
227
+ pending = '';
228
+ return sanitizeCredentialText(complete, options);
229
+ },
230
+ };
231
+ };
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
- import { createReadStream, createWriteStream } from 'node:fs';
3
2
  import os from 'node:os';
4
3
  import path from 'node:path';
5
- import { pipeline } from 'node:stream/promises';
4
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
6
5
 
7
6
  const sanitizePathSegment = (value, fallback) => {
8
7
  const raw = value === null || value === undefined || value === '' ? fallback : String(value);
@@ -87,9 +86,21 @@ const fileExists = async filePath => {
87
86
  }
88
87
  };
89
88
 
89
+ const writePrivatePublicationFile = async (destinationPath, content) => {
90
+ const sanitized = await sanitizeForPublication(content);
91
+ await fs.writeFile(destinationPath, sanitized, { encoding: 'utf8', mode: 0o600 });
92
+ // writeFile preserves the mode of an existing file, so enforce it after
93
+ // every write as well.
94
+ await fs.chmod(destinationPath, 0o600);
95
+ };
96
+
90
97
  const copyIfExists = async ({ sourcePath, destinationPath }) => {
91
98
  if (!(await fileExists(sourcePath))) return false;
92
- await fs.copyFile(sourcePath, destinationPath);
99
+ // Raw local audit sources remain available to the operator but must not be
100
+ // group/world-readable. Only the sanitized copy enters the repository.
101
+ await fs.chmod(sourcePath, 0o600);
102
+ const content = await fs.readFile(sourcePath, 'utf8');
103
+ await writePrivatePublicationFile(destinationPath, content);
93
104
  return true;
94
105
  };
95
106
 
@@ -122,13 +133,15 @@ const findCodexSessionFile = async ({ sessionId, homeDir }) => {
122
133
  // prefix. Returns the byte offset right after the copied slice.
123
134
  const copyLogSlice = async ({ logFile, destinationPath, logStartByte = 0 }) => {
124
135
  const stat = await fs.stat(logFile);
136
+ await fs.chmod(logFile, 0o600);
125
137
  // The log was rotated/truncated since the previous collection: copy it whole.
126
138
  const start = Number.isFinite(logStartByte) && logStartByte > 0 && logStartByte <= stat.size ? logStartByte : 0;
127
139
  if (stat.size === 0 || start >= stat.size) {
128
- await fs.writeFile(destinationPath, '');
140
+ await writePrivatePublicationFile(destinationPath, '');
129
141
  return { logStartByte: start, logEndByte: stat.size };
130
142
  }
131
- await pipeline(createReadStream(logFile, { start, end: stat.size - 1 }), createWriteStream(destinationPath));
143
+ const bytes = await fs.readFile(logFile);
144
+ await writePrivatePublicationFile(destinationPath, bytes.subarray(start, stat.size).toString('utf8'));
132
145
  return { logStartByte: start, logEndByte: stat.size };
133
146
  };
134
147
 
@@ -245,7 +258,7 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
245
258
  },
246
259
  };
247
260
 
248
- await fs.writeFile(path.join(repositoryPath, metadataRelativePath), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
261
+ await writePrivatePublicationFile(path.join(repositoryPath, metadataRelativePath), `${JSON.stringify(metadata, null, 2)}\n`);
249
262
 
250
263
  return {
251
264
  developmentLogDirectory,
@@ -260,6 +273,21 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
260
273
  };
261
274
  };
262
275
 
276
+ const verifyDevelopmentLogDirectory = async directoryPath => {
277
+ const entries = await fs.readdir(directoryPath, { recursive: true, withFileTypes: true });
278
+ for (const entry of entries) {
279
+ if (!entry.isFile()) continue;
280
+ const parentPath = entry.parentPath || entry.path;
281
+ const filePath = path.join(parentPath, entry.name);
282
+ const exactBytes = await fs.readFile(filePath, 'utf8');
283
+ const rescanned = await sanitizeForPublication(exactBytes);
284
+ if (rescanned !== exactBytes) {
285
+ throw new Error('Development-log publication rescan found residual credential material.');
286
+ }
287
+ await fs.chmod(filePath, 0o600);
288
+ }
289
+ };
290
+
263
291
  const getCommandOutput = result => (result?.stderr?.toString?.() || result?.stdout?.toString?.() || '').trim();
264
292
 
265
293
  export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, $, log }) => {
@@ -295,6 +323,11 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
295
323
  return { ...artifacts, committed: false, pushed: false };
296
324
  }
297
325
 
326
+ // Scan the exact directory bytes immediately before staging. This catches
327
+ // future artifacts added by this workflow even if their writer forgot to
328
+ // call the publication helper.
329
+ await verifyDevelopmentLogDirectory(path.join(repositoryPath, artifacts.relativeDirectory));
330
+
298
331
  const addResult = await $({ cwd: repositoryPath })`git add -f -- ${artifacts.relativeDirectory}`;
299
332
  if (addResult.code !== 0) {
300
333
  await log?.(`⚠️ Could not stage development log: ${getCommandOutput(addResult)}`, { level: 'warning' });
package/src/fix.mjs CHANGED
@@ -17,6 +17,9 @@ import path from 'path';
17
17
  import { spawn } from 'child_process';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle, buildSolveArgs, partitionFixArgs, summarizeRunFailures } from './fix.ci-cd.lib.mjs';
20
+ import { setupStdioLogInterceptor } from './lib.mjs';
21
+
22
+ setupStdioLogInterceptor();
20
23
 
21
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
25
 
@@ -8,6 +8,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
8
8
  import { createInterface } from 'readline';
9
9
  import { log, cleanErrorMessage, getAbsoluteLogPath } from './lib.mjs';
10
10
  import { reportError, isSentryEnabled } from './sentry.lib.mjs';
11
+ import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
11
12
 
12
13
  if (typeof globalThis.use === 'undefined') {
13
14
  await ensureUseM();
@@ -85,14 +86,13 @@ const getCurrentGitHubUser = async () => {
85
86
  * @returns {Promise<string|null>} Gist URL or null on failure
86
87
  */
87
88
  const createSecretGist = async (logContent, filename) => {
89
+ const tempFile = `/tmp/${filename}`;
88
90
  try {
89
- const tempFile = `/tmp/${filename}`;
90
- await fs.writeFile(tempFile, logContent);
91
+ await writeSanitizedPublicationFile(tempFile, logContent);
91
92
 
92
93
  const result = await $`gh gist create ${tempFile} --secret --desc "Error log for hive-mind"`;
93
94
  if (result.exitCode === 0) {
94
95
  const gistUrl = result.stdout.toString().trim();
95
- await fs.unlink(tempFile).catch(() => {});
96
96
  return gistUrl;
97
97
  }
98
98
  } catch (error) {
@@ -100,6 +100,8 @@ const createSecretGist = async (logContent, filename) => {
100
100
  context: 'create_secret_gist',
101
101
  operation: 'gh_gist_create',
102
102
  });
103
+ } finally {
104
+ await fs.unlink(tempFile).catch(() => {});
103
105
  }
104
106
  return null;
105
107
  };
@@ -183,7 +185,7 @@ export const createIssueForError = async options => {
183
185
 
184
186
  await log('\n🔄 Creating GitHub issue...');
185
187
 
186
- const issueTitle = error.message || errorMessage || `${errorType} in hive-mind`;
188
+ const issueTitle = await sanitizeForPublication(error.message || errorMessage || `${errorType} in hive-mind`);
187
189
 
188
190
  let issueBody = '## Error Details\n\n';
189
191
  issueBody += `**Type**: ${errorType}\n`;
@@ -232,11 +234,14 @@ export const createIssueForError = async options => {
232
234
  issueBody += `*This issue was automatically created by @${currentUser} using hive-mind error reporting*\n`;
233
235
 
234
236
  const tempBodyFile = `/tmp/hive-mind-issue-body-${Date.now()}.md`;
235
- await fs.writeFile(tempBodyFile, issueBody);
237
+ await writeSanitizedPublicationFile(tempBodyFile, issueBody);
236
238
 
237
- const result = await $`gh issue create --repo link-assistant/hive-mind --title ${issueTitle} --body-file ${tempBodyFile} --label bug`;
238
-
239
- await fs.unlink(tempBodyFile).catch(() => {});
239
+ let result;
240
+ try {
241
+ result = await $`gh issue create --repo link-assistant/hive-mind --title ${issueTitle} --body-file ${tempBodyFile} --label bug`;
242
+ } finally {
243
+ await fs.unlink(tempBodyFile).catch(() => {});
244
+ }
240
245
 
241
246
  if (result.exitCode === 0) {
242
247
  const issueUrl = result.stdout.toString().trim();
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { prClosesIssue, extractLinkedIssueNumber } from './github-linking.lib.mjs';
29
29
  import { wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs';
30
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
30
31
 
31
32
  /**
32
33
  * Determine whether GitHub will automatically close a PR's linked issues when
@@ -216,7 +217,7 @@ export async function ensureLinkedIssueClosedAfterMerge({ $: rawDollar, log = nu
216
217
  }
217
218
 
218
219
  // Close the issue explicitly, leaving an explanatory trail.
219
- const comment = [`Closed by #${prNumber}, which targeted the non-default branch \`${baseBranch}\` (repository default: \`${defaultBranch}\`).`, '', 'GitHub only auto-closes linked issues for pull requests merged into the default branch,', 'so hive-mind closed this issue explicitly after the merge.', '', '_Automated by hive-mind ([#1895](https://github.com/link-assistant/hive-mind/issues/1895))._'].join('\n');
220
+ const comment = await sanitizeForPublication([`Closed by #${prNumber}, which targeted the non-default branch \`${baseBranch}\` (repository default: \`${defaultBranch}\`).`, '', 'GitHub only auto-closes linked issues for pull requests merged into the default branch,', 'so hive-mind closed this issue explicitly after the merge.', '', '_Automated by hive-mind ([#1895](https://github.com/link-assistant/hive-mind/issues/1895))._'].join('\n'));
220
221
 
221
222
  const closeResult = await $`gh issue close ${issueNumber} --repo ${owner}/${repo} --reason completed --comment ${comment}`;
222
223
  if (closeResult.code === 0) {
@@ -22,6 +22,7 @@ import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
22
22
  import { extractLinkedIssueNumber } from './github-linking.lib.mjs';
23
23
  import { classifyIssueLinkStatus } from './github-issue-auto-close.lib.mjs';
24
24
  import { getDefaultBranch } from './github-merge.lib.mjs';
25
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
25
26
 
26
27
  const execRaw = promisify(execCallback);
27
28
 
@@ -76,7 +77,7 @@ export async function closeLinkedIssueIfNotAutoClosed(owner, repo, prNumber, ver
76
77
  // If state lookup fails, fall through and attempt the close.
77
78
  }
78
79
 
79
- const comment = `Closed by #${prNumber}, which targeted the non-default branch \`${baseBranch}\` (repository default: \`${defaultBranch}\`).\n\nGitHub only auto-closes linked issues for pull requests merged into the default branch, so hive-mind closed this issue explicitly after the merge.\n\n_Automated by hive-mind ([#1895](https://github.com/link-assistant/hive-mind/issues/1895))._`;
80
+ const comment = await sanitizeForPublication(`Closed by #${prNumber}, which targeted the non-default branch \`${baseBranch}\` (repository default: \`${defaultBranch}\`).\n\nGitHub only auto-closes linked issues for pull requests merged into the default branch, so hive-mind closed this issue explicitly after the merge.\n\n_Automated by hive-mind ([#1895](https://github.com/link-assistant/hive-mind/issues/1895))._`);
80
81
  await exec(`gh issue close ${issueNumber} --repo ${owner}/${repo} --reason completed --comment ${JSON.stringify(comment)}`);
81
82
  if (verbose) console.log(`[VERBOSE] /merge: Closed issue #${issueNumber} explicitly (PR #${prNumber} merged into non-default branch '${baseBranch}')`);
82
83
  return { closed: true, skipped: false, reason: 'closed-explicitly', issueNumber: Number(issueNumber) };