diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 4175f1d38..40dbe551c 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; +import { sanitizeError } from "../../remote-questions/manager.ts"; test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ @@ -131,3 +132,24 @@ test("isValidChannelId rejects invalid Discord channel IDs", () => { assert.equal(isValidChannelId("discord", "11234567890123456789"), true); }); +test("sanitizeError strips Slack token patterns from error messages", () => { + assert.equal( + sanitizeError("Auth failed: xoxb-1234-5678-abcdef"), + "Auth failed: [REDACTED]", + ); + assert.equal( + sanitizeError("Bad token xoxp-abc-def-ghi in request"), + "Bad token [REDACTED] in request", + ); +}); + +test("sanitizeError strips long opaque secrets", () => { + const fakeDiscordToken = "MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.G1x2y3.abcdefghijklmnop"; + assert.ok(!sanitizeError(`Token: ${fakeDiscordToken}`).includes(fakeDiscordToken)); +}); + +test("sanitizeError preserves short safe messages", () => { + assert.equal(sanitizeError("HTTP 401: Unauthorized"), "HTTP 401: Unauthorized"); + assert.equal(sanitizeError("Connection refused"), "Connection refused"); +}); + diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 544675a1c..a3f84e0f0 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -114,7 +114,9 @@ export class DiscordAdapter implements ChannelAdapter { if (response.status === 204) return {}; if (!response.ok) { const text = await response.text().catch(() => ""); - throw new Error(`Discord API HTTP ${response.status}: ${text}`); + // Limit error body length to avoid leaking verbose Discord error responses + const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text; + throw new Error(`Discord API HTTP ${response.status}: ${safeText}`); } return response.json(); } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index 9baabbd58..511668deb 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -36,7 +36,7 @@ export async function tryRemoteQuestions( try { await adapter.validate(); } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel); } @@ -45,7 +45,7 @@ export async function tryRemoteQuestions( dispatch = await adapter.sendPrompt(prompt); markPromptDispatched(prompt.id, dispatch.ref); } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel); } @@ -128,7 +128,7 @@ async function pollUntilDone( updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); if (answer) return answer; } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return null; } @@ -163,9 +163,25 @@ function formatForTool(answer: RemoteAnswer): Record