@link-assistant/hive-mind 1.64.2 → 1.64.4
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 +23 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +12 -1
- package/src/codex.lib.mjs +12 -1
- package/src/github.lib.mjs +2 -2
- package/src/interactive-mode.lib.mjs +104 -8
- package/src/isolation-runner.lib.mjs +49 -0
- package/src/lib.mjs +3 -3
- package/src/post-finish-sanitization-sweep.lib.mjs +201 -0
- package/src/solve.config.lib.mjs +15 -0
- package/src/solve.results.lib.mjs +52 -0
- package/src/telegram-bot.mjs +41 -0
- package/src/telegram-leak-notifier.lib.mjs +79 -0
- package/src/telegram-start-stop-command.lib.mjs +139 -3
- package/src/telegram-tokens-command.lib.mjs +151 -0
- package/src/token-sanitization.lib.mjs +355 -18
- package/src/tool-comments.lib.mjs +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.64.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 20f5898: Add `/stop <UUID>` and reply-to-message-with-UUID modes to the Telegram bot (#524). Sending `/stop <uuid>` (or replying with `/stop` to a message containing a UUID) forwards CTRL+C to the matching isolated `/solve` or `/hive` session via `$ --stop <uuid>` from link-foundation/start (link-foundation/start#112), so individual screen/tmux/docker sessions can be cancelled from Telegram. Mirrors the existing `/log` and `/terminal_watch` UUID-resolution pattern. Bare `/stop` retains its existing chat-pause behaviour (#1081).
|
|
8
|
+
|
|
9
|
+
## 1.64.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- dd52682: Sanitize all user-facing output to prevent token leaks (#1745).
|
|
14
|
+
- All comment-posting paths (`postComment`, `editComment`, `postTrackedComment`) run bodies through `sanitizeOutput` (canonical name) / `sanitizeCommentBody` (active-token wrapper). `sanitizeLogContent` is preserved as a backward-compatible alias.
|
|
15
|
+
- `KNOWN_LOCAL_TOKEN_ENV_VARS` registry masks tokens by exact env value (Telegram, GitHub, Anthropic/Claude, OpenAI/Codex, Gemini/Google, Qwen/Dashscope, OpenCode, AgentCLI, HuggingFace).
|
|
16
|
+
- Three independent CLI flags: `--dangerously-skip-output-sanitization`, `--dangerously-skip-code-output-sanitization`, `--dangerously-skip-active-tokens-output-sanitization` — all default false; active-tokens skip stays separate so the broad skip flag still keeps active-token masking on.
|
|
17
|
+
- Process-wide sanitization counters (`getSanitizationStats`, `formatSanitizationSummary`) print a one-line summary at the end of each run with a hint to use `--dangerously-skip-output-sanitization` when masking blocks the user's workflow.
|
|
18
|
+
- `extractTokensFromUserContent` carve-out helper: tokens already present in user-provided content (issue body, non-bot comments, pre-existing code) are passed as `excludeTokens` so the sanitizer leaves them untouched while still masking active local tokens.
|
|
19
|
+
- Post-finish sweep (`runPostFinishSweep`) re-reads bot-authored PR comments and the PR description after the AI session completes and edits in place if a leak slipped past the live sanitizer.
|
|
20
|
+
- ESLint guardrail (`gh-rate-limit/require-sanitized-output`) flags raw `gh pr comment`, `gh issue comment`, `gh pr edit`, and `gh api .../comments` calls that bypass the sanitizer.
|
|
21
|
+
- Out-of-band Telegram leak DM with masked summaries when a known-local token is detected in an outbound comment.
|
|
22
|
+
- Hidden owner-only `/tokens` Telegram command lists configured tokens (always masked, private chat only).
|
|
23
|
+
- `maskToken` defaults to 3+3 characters per issue requirements.
|
|
24
|
+
- secretlint preset (best-of-breed) runs alongside our custom patterns; mismatch warnings surface gaps.
|
|
25
|
+
|
|
3
26
|
## 1.64.2
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -682,7 +682,18 @@ export const executeClaudeCommand = async params => {
|
|
|
682
682
|
let interactiveHandler = null;
|
|
683
683
|
if (argv.interactiveMode && owner && repo && prNumber) {
|
|
684
684
|
await log('🔌 Interactive mode: Creating handler for real-time PR comments', { verbose: true });
|
|
685
|
-
interactiveHandler = createInteractiveHandler({
|
|
685
|
+
interactiveHandler = createInteractiveHandler({
|
|
686
|
+
owner,
|
|
687
|
+
repo,
|
|
688
|
+
prNumber,
|
|
689
|
+
$,
|
|
690
|
+
log,
|
|
691
|
+
verbose: argv.verbose,
|
|
692
|
+
// Issue #1745: thread the three independent dangerous-skip flags through
|
|
693
|
+
// so the comment-posting path can honor them; flags default to false.
|
|
694
|
+
skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
|
|
695
|
+
skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
|
|
696
|
+
});
|
|
686
697
|
} else if (argv.interactiveMode) {
|
|
687
698
|
await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
|
|
688
699
|
}
|
package/src/codex.lib.mjs
CHANGED
|
@@ -792,7 +792,18 @@ export const executeCodexCommand = async params => {
|
|
|
792
792
|
let interactiveHandler = null;
|
|
793
793
|
if (argv.interactiveMode && owner && repo && prNumber) {
|
|
794
794
|
await log('🔌 Interactive mode: Creating handler for real-time PR comments', { verbose: true });
|
|
795
|
-
interactiveHandler = createInteractiveHandler({
|
|
795
|
+
interactiveHandler = createInteractiveHandler({
|
|
796
|
+
owner,
|
|
797
|
+
repo,
|
|
798
|
+
prNumber,
|
|
799
|
+
$,
|
|
800
|
+
log,
|
|
801
|
+
verbose: argv.verbose,
|
|
802
|
+
// Issue #1745: pass the three independent dangerous-skip flags so the
|
|
803
|
+
// comment-posting path can honor them. All default to false.
|
|
804
|
+
skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
|
|
805
|
+
skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
|
|
806
|
+
});
|
|
796
807
|
} else if (argv.interactiveMode) {
|
|
797
808
|
await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
|
|
798
809
|
}
|
package/src/github.lib.mjs
CHANGED
|
@@ -6,8 +6,8 @@ import { log, maskToken, cleanErrorMessage, isENOSPC, ghCmdRetry } from './lib.m
|
|
|
6
6
|
import { reportError } from './sentry.lib.mjs';
|
|
7
7
|
import { githubLimits, timeouts } from './config.lib.mjs';
|
|
8
8
|
import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
|
|
9
|
-
import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeLogContent } from './token-sanitization.lib.mjs';
|
|
10
|
-
export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeLogContent }; // Re-export for backward compatibility
|
|
9
|
+
import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent } from './token-sanitization.lib.mjs';
|
|
10
|
+
export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent }; // Re-export for backward compatibility
|
|
11
11
|
import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
|
|
12
12
|
import { formatResetTimeWithRelative } from './usage-limit.lib.mjs'; // See: https://github.com/link-assistant/hive-mind/issues/1236
|
|
13
13
|
// Import model info helpers (Issue #1225)
|
|
@@ -38,6 +38,13 @@ import { CONFIG, createCollapsible, createRawJsonSection, escapeMarkdown, execFi
|
|
|
38
38
|
// Use the session-started marker as the single source of truth for the
|
|
39
39
|
// header string, keeping posting and filtering in lock-step.
|
|
40
40
|
import { INTERACTIVE_SESSION_STARTED_MARKER, trackToolCommentId } from './tool-comments.lib.mjs';
|
|
41
|
+
// Issue #1745: every comment body posted by the AI bridge MUST flow through
|
|
42
|
+
// sanitizeCommentBody() before leaving the process. The leak in
|
|
43
|
+
// xlab2016/space_db_private#20 happened because raw bash-tool stdout
|
|
44
|
+
// (including TELEGRAM_BOT_TOKEN=...) was published verbatim. See
|
|
45
|
+
// docs/case-studies/issue-1745/analysis.md for the full timeline.
|
|
46
|
+
import { containsKnownToken, getAllKnownLocalTokens, sanitizeCommentBody } from './token-sanitization.lib.mjs';
|
|
47
|
+
import { reportInteractiveLeak } from './telegram-leak-notifier.lib.mjs';
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
50
|
* Creates an interactive mode handler for processing Claude/Codex CLI events
|
|
@@ -52,7 +59,23 @@ import { INTERACTIVE_SESSION_STARTED_MARKER, trackToolCommentId } from './tool-c
|
|
|
52
59
|
* @returns {Object} Handler object with event processing methods
|
|
53
60
|
*/
|
|
54
61
|
export const createInteractiveHandler = options => {
|
|
55
|
-
const {
|
|
62
|
+
const {
|
|
63
|
+
owner,
|
|
64
|
+
repo,
|
|
65
|
+
prNumber,
|
|
66
|
+
log,
|
|
67
|
+
verbose = false,
|
|
68
|
+
execFile: execFileFn,
|
|
69
|
+
// Issue #1745: dangerous-skip flags. All default to false; passing them
|
|
70
|
+
// through lets the operator opt out of pattern-based sanitization (for
|
|
71
|
+
// controlled debugging in private repos) while keeping active-token
|
|
72
|
+
// masking on by default.
|
|
73
|
+
skipOutputSanitization = false,
|
|
74
|
+
skipActiveTokensOutputSanitization = false,
|
|
75
|
+
// Pre-existing user content carve-out (issue body / non-bot comments /
|
|
76
|
+
// pre-existing code). When provided, sanitizer leaves these tokens untouched.
|
|
77
|
+
excludeTokens = [],
|
|
78
|
+
} = options;
|
|
56
79
|
// Use injected execFile for testability, or the real one by default
|
|
57
80
|
const runGhApi = execFileFn || execFileAsync;
|
|
58
81
|
|
|
@@ -88,6 +111,71 @@ export const createInteractiveHandler = options => {
|
|
|
88
111
|
editsFailed: 0,
|
|
89
112
|
};
|
|
90
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Sanitize a comment body and warn the chat owner when a known-local token
|
|
116
|
+
* was about to be published. Issue #1745. The returned string is what we
|
|
117
|
+
* actually send to GitHub.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} body
|
|
120
|
+
* @returns {Promise<string>} sanitized body
|
|
121
|
+
* @private
|
|
122
|
+
*/
|
|
123
|
+
const sanitizeAndWarn = async body => {
|
|
124
|
+
if (typeof body !== 'string' || body.length === 0) return body;
|
|
125
|
+
|
|
126
|
+
let knownTokens;
|
|
127
|
+
try {
|
|
128
|
+
knownTokens = await getAllKnownLocalTokens();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Best-effort: if token lookup fails, fall back to regex/secretlint only.
|
|
131
|
+
knownTokens = [];
|
|
132
|
+
if (verbose) {
|
|
133
|
+
await log(`⚠️ Interactive mode: getAllKnownLocalTokens failed: ${err.message}`, { verbose: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let hits = [];
|
|
138
|
+
try {
|
|
139
|
+
hits = await containsKnownToken(body, knownTokens);
|
|
140
|
+
} catch {
|
|
141
|
+
hits = [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let sanitized = body;
|
|
145
|
+
try {
|
|
146
|
+
sanitized = await sanitizeCommentBody(body, {
|
|
147
|
+
knownTokens,
|
|
148
|
+
skipOutputSanitization,
|
|
149
|
+
skipActiveTokensOutputSanitization,
|
|
150
|
+
excludeTokens,
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
await log(`⚠️ Interactive mode: sanitizeCommentBody failed: ${err.message} — falling back to raw body MASKED`);
|
|
154
|
+
// Fail closed: if sanitization fails entirely, drop the body to a safe
|
|
155
|
+
// placeholder rather than leaking. Better to lose detail than secrets.
|
|
156
|
+
sanitized = '[redacted: sanitization failed]';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (hits.length > 0) {
|
|
160
|
+
await log(`🚨 Interactive mode: known-local token(s) detected in outbound comment — sanitizer masked them. Sources: ${hits.map(h => h.source).join(', ')}`);
|
|
161
|
+
try {
|
|
162
|
+
await reportInteractiveLeak({
|
|
163
|
+
owner,
|
|
164
|
+
repo,
|
|
165
|
+
prNumber,
|
|
166
|
+
tokenHits: hits,
|
|
167
|
+
log,
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (verbose) {
|
|
171
|
+
await log(`⚠️ Interactive mode: leak notifier failed: ${err.message}`, { verbose: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return sanitized;
|
|
177
|
+
};
|
|
178
|
+
|
|
91
179
|
/**
|
|
92
180
|
* Post a comment to the PR (with rate limiting)
|
|
93
181
|
* @param {string} body - Comment body
|
|
@@ -104,12 +192,16 @@ export const createInteractiveHandler = options => {
|
|
|
104
192
|
return null;
|
|
105
193
|
}
|
|
106
194
|
|
|
195
|
+
// Issue #1745: sanitize BEFORE rate-limit queuing so queued bodies are
|
|
196
|
+
// also safe (the queue persists across reconnects).
|
|
197
|
+
const safeBody = await sanitizeAndWarn(body);
|
|
198
|
+
|
|
107
199
|
const now = Date.now();
|
|
108
200
|
const timeSinceLastComment = now - state.lastCommentTime;
|
|
109
201
|
|
|
110
202
|
if (timeSinceLastComment < CONFIG.MIN_COMMENT_INTERVAL) {
|
|
111
203
|
// Queue the comment for later with toolId/taskId for tracking
|
|
112
|
-
state.commentQueue.push({ body, toolId, taskId });
|
|
204
|
+
state.commentQueue.push({ body: safeBody, toolId, taskId });
|
|
113
205
|
if (verbose) {
|
|
114
206
|
await log(`📝 Interactive mode: Comment queued (${state.commentQueue.length} in queue)${toolId ? ` [tool: ${toolId}]` : ''}${taskId ? ` [task: ${taskId}]` : ''}`, { verbose: true });
|
|
115
207
|
}
|
|
@@ -122,7 +214,7 @@ export const createInteractiveHandler = options => {
|
|
|
122
214
|
// with complex markdown bodies containing backticks, quotes, etc.
|
|
123
215
|
// See: https://github.com/link-assistant/hive-mind/issues/1458
|
|
124
216
|
const apiUrl = `repos/${owner}/${repo}/issues/${prNumber}/comments`;
|
|
125
|
-
const jsonPayload = JSON.stringify({ body });
|
|
217
|
+
const jsonPayload = JSON.stringify({ body: safeBody });
|
|
126
218
|
const { stdout } = await runGhApi('gh', ['api', apiUrl, '-X', 'POST', '--input', '-'], {
|
|
127
219
|
input: jsonPayload,
|
|
128
220
|
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
@@ -147,13 +239,13 @@ export const createInteractiveHandler = options => {
|
|
|
147
239
|
trackToolCommentId(commentId);
|
|
148
240
|
|
|
149
241
|
if (verbose) {
|
|
150
|
-
await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''} (body: ${
|
|
242
|
+
await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''} (body: ${safeBody.length} chars)`, { verbose: true });
|
|
151
243
|
}
|
|
152
244
|
return commentId;
|
|
153
245
|
} catch (error) {
|
|
154
246
|
state.commentsFailed++;
|
|
155
247
|
// Issue #1472: Always log comment failures (not just verbose) — silent failures cause zero-comment bugs
|
|
156
|
-
await log(`⚠️ Interactive mode: Failed to post comment: ${error.message} (body: ${
|
|
248
|
+
await log(`⚠️ Interactive mode: Failed to post comment: ${error.message} (body: ${safeBody.length} chars)`);
|
|
157
249
|
return null;
|
|
158
250
|
}
|
|
159
251
|
};
|
|
@@ -173,25 +265,29 @@ export const createInteractiveHandler = options => {
|
|
|
173
265
|
return false;
|
|
174
266
|
}
|
|
175
267
|
|
|
268
|
+
// Issue #1745: sanitize before sending. editComment is the path that
|
|
269
|
+
// leaked TELEGRAM_BOT_TOKEN in xlab2016/space_db_private#20.
|
|
270
|
+
const safeBody = await sanitizeAndWarn(body);
|
|
271
|
+
|
|
176
272
|
state.editsAttempted++;
|
|
177
273
|
try {
|
|
178
274
|
// Edit comment via gh api with stdin to avoid shell quoting issues
|
|
179
275
|
// with complex markdown bodies containing backticks, quotes, etc.
|
|
180
276
|
// See: https://github.com/link-assistant/hive-mind/issues/1458
|
|
181
277
|
const apiUrl = `repos/${owner}/${repo}/issues/comments/${commentId}`;
|
|
182
|
-
const jsonPayload = JSON.stringify({ body });
|
|
278
|
+
const jsonPayload = JSON.stringify({ body: safeBody });
|
|
183
279
|
await runGhApi('gh', ['api', apiUrl, '-X', 'PATCH', '--input', '-'], {
|
|
184
280
|
input: jsonPayload,
|
|
185
281
|
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
186
282
|
});
|
|
187
283
|
state.editsSucceeded++;
|
|
188
284
|
if (verbose) {
|
|
189
|
-
await log(`✅ Interactive mode: Comment ${commentId} updated (body: ${
|
|
285
|
+
await log(`✅ Interactive mode: Comment ${commentId} updated (body: ${safeBody.length} chars, payload: ${jsonPayload.length} chars)`, { verbose: true });
|
|
190
286
|
}
|
|
191
287
|
return true;
|
|
192
288
|
} catch (error) {
|
|
193
289
|
state.editsFailed++;
|
|
194
|
-
await log(`⚠️ Interactive mode: Failed to edit comment ${commentId}: ${error.message} (body: ${
|
|
290
|
+
await log(`⚠️ Interactive mode: Failed to edit comment ${commentId}: ${error.message} (body: ${safeBody.length} chars)`);
|
|
195
291
|
return false;
|
|
196
292
|
}
|
|
197
293
|
};
|
|
@@ -255,6 +255,55 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Ask the `$` CLI to gracefully stop an isolated session by sending CTRL+C.
|
|
260
|
+
*
|
|
261
|
+
* Wraps `$ --stop <uuid>` from start-command (link-foundation/start#112).
|
|
262
|
+
* Works for any isolation backend (screen, tmux, docker, …) — `$` knows the
|
|
263
|
+
* backend it launched with and forwards the interrupt accordingly.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} sessionId - UUID of the session to stop
|
|
266
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
267
|
+
* @returns {Promise<{success: boolean, output: string, error: string|null}>}
|
|
268
|
+
*/
|
|
269
|
+
export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
270
|
+
const binPath = await findStartCommandBinary();
|
|
271
|
+
if (!binPath) {
|
|
272
|
+
if (verbose) {
|
|
273
|
+
console.log('[VERBOSE] isolation-runner: Cannot stop session - $ binary not found');
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
output: '',
|
|
278
|
+
error: '`$` (start-command) binary not found on PATH. Install link-foundation/start to use /stop <UUID>.',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
|
|
284
|
+
const stdout = result.stdout?.toString() || '';
|
|
285
|
+
const stderr = result.stderr?.toString() || '';
|
|
286
|
+
if (verbose) {
|
|
287
|
+
console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stdout: ${stdout.substring(0, 300)}`);
|
|
288
|
+
if (stderr) {
|
|
289
|
+
console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stderr: ${stderr.substring(0, 300)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { success: true, output: stdout || stderr, error: null };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const stderr = error?.stderr?.toString?.() || '';
|
|
295
|
+
const stdout = error?.stdout?.toString?.() || '';
|
|
296
|
+
if (verbose) {
|
|
297
|
+
console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} failed: ${error.message}`);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
success: false,
|
|
301
|
+
output: stdout,
|
|
302
|
+
error: stderr.trim() || error?.message || String(error),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
258
307
|
/**
|
|
259
308
|
* Check if a screen session exists via `screen -ls`.
|
|
260
309
|
* Used as a fallback when `$ --status` fails to find or correctly track
|
package/src/lib.mjs
CHANGED
|
@@ -334,12 +334,12 @@ export const setupStdioLogInterceptor = () => {
|
|
|
334
334
|
* @param {string} token - Token to mask
|
|
335
335
|
* @param {Object} options - Masking options
|
|
336
336
|
* @param {number} [options.minLength=12] - Minimum length to mask
|
|
337
|
-
* @param {number} [options.startChars=
|
|
338
|
-
* @param {number} [options.endChars=
|
|
337
|
+
* @param {number} [options.startChars=3] - Number of characters to show at start
|
|
338
|
+
* @param {number} [options.endChars=3] - Number of characters to show at end
|
|
339
339
|
* @returns {string} Masked token
|
|
340
340
|
*/
|
|
341
341
|
export const maskToken = (token, options = {}) => {
|
|
342
|
-
const { minLength = 12, startChars =
|
|
342
|
+
const { minLength = 12, startChars = 3, endChars = 3 } = options;
|
|
343
343
|
|
|
344
344
|
if (!token || token.length < minLength) {
|
|
345
345
|
return token; // Don't mask very short strings
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Issue #1745 — post-finish sanitization sweep.
|
|
4
|
+
*
|
|
5
|
+
* Comment #4364642786 requirement: "after AI finishes whatever the content
|
|
6
|
+
* was ... we should by default go and mask the token by editing comments,
|
|
7
|
+
* pull requests".
|
|
8
|
+
*
|
|
9
|
+
* This module re-reads bot-authored comments and the PR description after the
|
|
10
|
+
* AI session finishes, runs the body through `sanitizeOutput`, and edits the
|
|
11
|
+
* comment / PR in place if a difference is detected. It is intentionally
|
|
12
|
+
* conservative:
|
|
13
|
+
*
|
|
14
|
+
* - We only touch content authored by the running gh user (the bot).
|
|
15
|
+
* - We never touch issue bodies (those belong to the human).
|
|
16
|
+
* - History rewriting (force-pushing to delete commits) is NOT performed
|
|
17
|
+
* here. The risk to a shared branch is too high; that step requires the
|
|
18
|
+
* operator to opt in explicitly via a future flag, and is documented in
|
|
19
|
+
* docs/case-studies/issue-1745/analysis.md.
|
|
20
|
+
*
|
|
21
|
+
* @module post-finish-sanitization-sweep
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { sanitizeOutput, getSanitizationStats } from './token-sanitization.lib.mjs';
|
|
25
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): caller passes $ already wrapped through wrapDollarWithGhRetry
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Determine the bot's gh login name. The function returns null on any error
|
|
29
|
+
* so the sweep degrades gracefully when offline / unauthenticated.
|
|
30
|
+
*
|
|
31
|
+
* @param {Function} $
|
|
32
|
+
* @returns {Promise<string|null>}
|
|
33
|
+
*/
|
|
34
|
+
const detectBotLogin = async $ => {
|
|
35
|
+
try {
|
|
36
|
+
const result = await $`gh api user --jq .login`;
|
|
37
|
+
if (result && result.code === 0 && result.stdout) {
|
|
38
|
+
const login = result.stdout.toString().trim();
|
|
39
|
+
return login || null;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
/* swallow */
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sanitize bot-authored PR conversation comments (the issuecomment endpoint).
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} args
|
|
51
|
+
* @param {Function} args.$ command-stream helper
|
|
52
|
+
* @param {string} args.owner
|
|
53
|
+
* @param {string} args.repo
|
|
54
|
+
* @param {number|string} args.prNumber
|
|
55
|
+
* @param {string} args.botLogin
|
|
56
|
+
* @param {Function} [args.log]
|
|
57
|
+
* @param {Object} [args.sanitizationOptions] forwarded to sanitizeOutput
|
|
58
|
+
* @returns {Promise<{scanned:number, edited:number, errors:number}>}
|
|
59
|
+
*/
|
|
60
|
+
export const sweepPrConversationComments = async ({ $, owner, repo, prNumber, botLogin, log = async () => {}, sanitizationOptions = {} }) => {
|
|
61
|
+
const stats = { scanned: 0, edited: 0, errors: 0 };
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
response = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
await log(`⚠️ post-finish sweep: failed to list comments: ${err.message || err}`);
|
|
67
|
+
stats.errors++;
|
|
68
|
+
return stats;
|
|
69
|
+
}
|
|
70
|
+
if (!response || response.code !== 0) {
|
|
71
|
+
stats.errors++;
|
|
72
|
+
return stats;
|
|
73
|
+
}
|
|
74
|
+
let comments;
|
|
75
|
+
try {
|
|
76
|
+
comments = JSON.parse(response.stdout.toString());
|
|
77
|
+
} catch {
|
|
78
|
+
stats.errors++;
|
|
79
|
+
return stats;
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(comments)) return stats;
|
|
82
|
+
|
|
83
|
+
for (const c of comments) {
|
|
84
|
+
if (!c || !c.user || c.user.login !== botLogin) continue;
|
|
85
|
+
if (typeof c.body !== 'string' || c.body.length === 0) continue;
|
|
86
|
+
stats.scanned++;
|
|
87
|
+
let sanitized;
|
|
88
|
+
try {
|
|
89
|
+
sanitized = await sanitizeOutput(c.body, sanitizationOptions);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await log(`⚠️ post-finish sweep: sanitize comment ${c.id} failed: ${err.message || err}`);
|
|
92
|
+
stats.errors++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (sanitized === c.body) continue;
|
|
96
|
+
try {
|
|
97
|
+
const payload = JSON.stringify({ body: sanitized });
|
|
98
|
+
const edit = await $({ stdin: payload })`gh api repos/${owner}/${repo}/issues/comments/${c.id} -X PATCH --input -`;
|
|
99
|
+
if (edit && edit.code === 0) {
|
|
100
|
+
stats.edited++;
|
|
101
|
+
await log(`🔒 post-finish sweep: edited comment ${c.id} to mask leaked token(s)`);
|
|
102
|
+
} else {
|
|
103
|
+
stats.errors++;
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
await log(`⚠️ post-finish sweep: edit comment ${c.id} failed: ${err.message || err}`);
|
|
107
|
+
stats.errors++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return stats;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Sanitize the PR description if needed.
|
|
115
|
+
*
|
|
116
|
+
* @param {Object} args
|
|
117
|
+
* @returns {Promise<{scanned:number, edited:number, errors:number}>}
|
|
118
|
+
*/
|
|
119
|
+
export const sweepPrDescription = async ({ $, owner, repo, prNumber, log = async () => {}, sanitizationOptions = {} }) => {
|
|
120
|
+
const stats = { scanned: 0, edited: 0, errors: 0 };
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
response = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}`;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
await log(`⚠️ post-finish sweep: failed to fetch PR ${prNumber}: ${err.message || err}`);
|
|
126
|
+
stats.errors++;
|
|
127
|
+
return stats;
|
|
128
|
+
}
|
|
129
|
+
if (!response || response.code !== 0) {
|
|
130
|
+
stats.errors++;
|
|
131
|
+
return stats;
|
|
132
|
+
}
|
|
133
|
+
let pr;
|
|
134
|
+
try {
|
|
135
|
+
pr = JSON.parse(response.stdout.toString());
|
|
136
|
+
} catch {
|
|
137
|
+
stats.errors++;
|
|
138
|
+
return stats;
|
|
139
|
+
}
|
|
140
|
+
const body = typeof pr.body === 'string' ? pr.body : '';
|
|
141
|
+
if (body.length === 0) return stats;
|
|
142
|
+
stats.scanned++;
|
|
143
|
+
let sanitized;
|
|
144
|
+
try {
|
|
145
|
+
sanitized = await sanitizeOutput(body, sanitizationOptions);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
await log(`⚠️ post-finish sweep: sanitize PR body failed: ${err.message || err}`);
|
|
148
|
+
stats.errors++;
|
|
149
|
+
return stats;
|
|
150
|
+
}
|
|
151
|
+
if (sanitized === body) return stats;
|
|
152
|
+
try {
|
|
153
|
+
const payload = JSON.stringify({ body: sanitized });
|
|
154
|
+
const edit = await $({ stdin: payload })`gh api repos/${owner}/${repo}/pulls/${prNumber} -X PATCH --input -`;
|
|
155
|
+
if (edit && edit.code === 0) {
|
|
156
|
+
stats.edited++;
|
|
157
|
+
await log('🔒 post-finish sweep: edited PR description to mask leaked token(s)');
|
|
158
|
+
} else {
|
|
159
|
+
stats.errors++;
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
await log(`⚠️ post-finish sweep: edit PR body failed: ${err.message || err}`);
|
|
163
|
+
stats.errors++;
|
|
164
|
+
}
|
|
165
|
+
return stats;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run the full post-finish sweep: bot-authored PR comments + PR description.
|
|
170
|
+
* Idempotent and safe to call multiple times.
|
|
171
|
+
*
|
|
172
|
+
* @param {Object} args
|
|
173
|
+
* @returns {Promise<{comments:Object, prBody:Object, totalEdited:number, sanitizationStatsBefore:Object, sanitizationStatsAfter:Object}>}
|
|
174
|
+
*/
|
|
175
|
+
export const runPostFinishSweep = async ({ $, owner, repo, prNumber, log = async () => {}, sanitizationOptions = {}, botLogin: providedBotLogin }) => {
|
|
176
|
+
const sanitizationStatsBefore = getSanitizationStats();
|
|
177
|
+
const botLogin = providedBotLogin || (await detectBotLogin($));
|
|
178
|
+
const result = {
|
|
179
|
+
comments: { scanned: 0, edited: 0, errors: 0, skipped: !botLogin },
|
|
180
|
+
prBody: { scanned: 0, edited: 0, errors: 0 },
|
|
181
|
+
totalEdited: 0,
|
|
182
|
+
sanitizationStatsBefore,
|
|
183
|
+
sanitizationStatsAfter: sanitizationStatsBefore,
|
|
184
|
+
};
|
|
185
|
+
if (!owner || !repo || !prNumber) return result;
|
|
186
|
+
if (botLogin) {
|
|
187
|
+
result.comments = await sweepPrConversationComments({ $, owner, repo, prNumber, botLogin, log, sanitizationOptions });
|
|
188
|
+
} else {
|
|
189
|
+
await log('⚠️ post-finish sweep: could not determine bot login; skipping comment sweep.');
|
|
190
|
+
}
|
|
191
|
+
result.prBody = await sweepPrDescription({ $, owner, repo, prNumber, log, sanitizationOptions });
|
|
192
|
+
result.totalEdited = result.comments.edited + result.prBody.edited;
|
|
193
|
+
result.sanitizationStatsAfter = getSanitizationStats();
|
|
194
|
+
return result;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export default {
|
|
198
|
+
sweepPrConversationComments,
|
|
199
|
+
sweepPrDescription,
|
|
200
|
+
runPostFinishSweep,
|
|
201
|
+
};
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -115,6 +115,21 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
115
115
|
description: 'Upload the solution draft log file to the Pull Request on completion (⚠️ WARNING: May expose sensitive data)',
|
|
116
116
|
default: false,
|
|
117
117
|
},
|
|
118
|
+
'dangerously-skip-output-sanitization': {
|
|
119
|
+
type: 'boolean',
|
|
120
|
+
description: 'DANGEROUS: skip pattern-based sanitization of generated output. Active local token masking stays enabled unless --dangerously-skip-active-tokens-output-sanitization is also set.',
|
|
121
|
+
default: false,
|
|
122
|
+
},
|
|
123
|
+
'dangerously-skip-code-output-sanitization': {
|
|
124
|
+
type: 'boolean',
|
|
125
|
+
description: 'DANGEROUS: allow generated code/file output to keep pattern-matched token-looking strings. Active local token masking stays enabled unless explicitly disabled.',
|
|
126
|
+
default: false,
|
|
127
|
+
},
|
|
128
|
+
'dangerously-skip-active-tokens-output-sanitization': {
|
|
129
|
+
type: 'boolean',
|
|
130
|
+
description: 'DANGEROUS: skip masking known active local tokens in output. This is separate from other sanitization skip flags and should only be used for controlled debugging.',
|
|
131
|
+
default: false,
|
|
132
|
+
},
|
|
118
133
|
'auto-close-pull-request-on-fail': {
|
|
119
134
|
type: 'boolean',
|
|
120
135
|
description: 'Automatically close the pull request if execution fails',
|
|
@@ -28,6 +28,14 @@ import { safeExit } from './exit-handler.lib.mjs';
|
|
|
28
28
|
const githubLib = await import('./github.lib.mjs');
|
|
29
29
|
const { sanitizeLogContent, attachLogToGitHub } = githubLib;
|
|
30
30
|
|
|
31
|
+
// Issue #1745: process-wide sanitization counters used to print a one-line
|
|
32
|
+
// "we masked N secrets" summary at the end of each run.
|
|
33
|
+
const { formatSanitizationSummary } = await import('./token-sanitization.lib.mjs');
|
|
34
|
+
// Issue #1745: post-finish retroactive sanitization of bot-authored PR
|
|
35
|
+
// comments and the PR description. Runs by default; can be skipped via
|
|
36
|
+
// --dangerously-skip-output-sanitization.
|
|
37
|
+
const { runPostFinishSweep } = await import('./post-finish-sanitization-sweep.lib.mjs');
|
|
38
|
+
|
|
31
39
|
// Import continuation functions (session resumption, PR detection)
|
|
32
40
|
const autoContinue = await import('./solve.auto-continue.lib.mjs');
|
|
33
41
|
const { autoContinueWhenLimitResets } = autoContinue;
|
|
@@ -556,6 +564,17 @@ export const cleanupClaudeFile = async (tempDir, branchName, claudeCommitHash =
|
|
|
556
564
|
export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl, tempDir, shouldAttachLogs = false) => {
|
|
557
565
|
await log('\n=== Session Summary ===');
|
|
558
566
|
|
|
567
|
+
// Issue #1745: report how many tokens were masked during this run, with the
|
|
568
|
+
// "use --dangerously-skip-output-sanitization to skip" hint when > 0.
|
|
569
|
+
try {
|
|
570
|
+
const sanitizationSummary = formatSanitizationSummary();
|
|
571
|
+
if (sanitizationSummary) {
|
|
572
|
+
await log(sanitizationSummary);
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
/* never fail the summary because of this */
|
|
576
|
+
}
|
|
577
|
+
|
|
559
578
|
if (sessionId) {
|
|
560
579
|
await log(`✅ Session ID: ${sessionId}`);
|
|
561
580
|
// Always use absolute path for log file display
|
|
@@ -622,6 +641,39 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
|
|
|
622
641
|
const logFilePath = path.resolve(getLogFile());
|
|
623
642
|
await log(`📁 Log file available: ${logFilePath}`);
|
|
624
643
|
}
|
|
644
|
+
|
|
645
|
+
// Issue #1745: post-finish retroactive sanitization sweep. Re-reads
|
|
646
|
+
// bot-authored PR comments and the PR description, runs them through
|
|
647
|
+
// sanitizeOutput, and edits in place if a leak slipped past the live
|
|
648
|
+
// sanitizer. Honors --dangerously-skip-output-sanitization and the related
|
|
649
|
+
// active-tokens flag.
|
|
650
|
+
try {
|
|
651
|
+
const owner = argv.owner;
|
|
652
|
+
const repo = argv.repo;
|
|
653
|
+
const prNumber = argv.prNumber;
|
|
654
|
+
const skipOutputSanitization = argv['dangerously-skip-output-sanitization'] === true;
|
|
655
|
+
const skipActiveTokensOutputSanitization = argv['dangerously-skip-active-tokens-output-sanitization'] === true;
|
|
656
|
+
if (owner && repo && prNumber && !skipOutputSanitization) {
|
|
657
|
+
const sweepResult = await runPostFinishSweep({
|
|
658
|
+
$,
|
|
659
|
+
owner,
|
|
660
|
+
repo,
|
|
661
|
+
prNumber,
|
|
662
|
+
log,
|
|
663
|
+
sanitizationOptions: {
|
|
664
|
+
warnOnMismatch: false,
|
|
665
|
+
skipActiveTokensOutputSanitization,
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
if (sweepResult.totalEdited > 0) {
|
|
669
|
+
await log(`🔒 Post-finish sweep: edited ${sweepResult.totalEdited} bot-authored item(s) to mask leaked tokens.`);
|
|
670
|
+
const followup = formatSanitizationSummary(sweepResult.sanitizationStatsAfter);
|
|
671
|
+
if (followup) await log(followup);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
} catch (sweepErr) {
|
|
675
|
+
await log(`⚠️ Post-finish sanitization sweep failed: ${sweepErr.message || sweepErr}`);
|
|
676
|
+
}
|
|
625
677
|
};
|
|
626
678
|
|
|
627
679
|
// Verify results by searching for new PRs and comments
|