diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index bc8b648d..893d6ee8 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -11,6 +11,7 @@ import { CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.ts' +import { parseJsonRedacted } from './json.ts' import { type LogLevel, log, logger } from './logger.ts' import { isTransientNetworkError } from './network-errors.ts' @@ -907,7 +908,10 @@ async function readJsonIfPresent(path: string): Promise<{ value: unknown }> { try { - return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) } + return { + exists: true, + value: parseJsonRedacted(await readFile(path, 'utf8')), + } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { exists: false, value: null } diff --git a/packages/core/src/dump.ts b/packages/core/src/dump.ts index 5eb8cc5d..560adbea 100644 --- a/packages/core/src/dump.ts +++ b/packages/core/src/dump.ts @@ -11,6 +11,7 @@ import { import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { extractBillingHeaderCCH } from './cch.ts' +import { parseJsonRedacted } from './json.ts' import { isSecretKey, logger, relayLog } from './logger.ts' type DumpHeaders = ConstructorParameters[0] @@ -535,7 +536,7 @@ function diffSummary(previousBodyText: string | undefined, bodyText: string) { function parseBody(bodyText: string): Record | null { try { - const parsed = JSON.parse(bodyText) + const parsed = parseJsonRedacted(bodyText) as Record | null return parsed != null && typeof parsed === 'object' && !Array.isArray(parsed) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1beb9db2..31207aed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from './commands/account.ts' export * from './constants.ts' export * from './dump.ts' export * from './fast.ts' +export * from './json.ts' export * from './killswitch.ts' export * from './logger.ts' export * from './logging.ts' diff --git a/packages/core/src/json.ts b/packages/core/src/json.ts new file mode 100644 index 00000000..9abaebc1 --- /dev/null +++ b/packages/core/src/json.ts @@ -0,0 +1,8 @@ +export function parseJsonRedacted(text: string): unknown { + try { + return JSON.parse(text) + } catch (error) { + if (error instanceof SyntaxError) throw new SyntaxError('invalid JSON') + throw error + } +} diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 8cf26500..ace20d78 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -382,6 +382,37 @@ beforeEach(async () => { process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath }) +test('does not echo secrets from corrupt account stores', async () => { + const expectRedactedCorruption = async (path: string, content: string) => { + await writeFile(path, content, 'utf8') + let caught: unknown + try { + await loadAccounts(accountPath) + } catch (error) { + caught = error + } + + expect(caught instanceof Error).toBe(true) + const error = caught as Error + expect(error.message.includes('CANARY')).toBe(false) + expect(String(error.cause ?? '').includes('CANARY')).toBe(false) + expect(error.message).not.toContain('Unexpected identifier') + expect(error.message).toContain(path) + expect(error.message).toContain('fix or remove it') + } + + await writeFile(accountPath, JSON.stringify(baseStorage()), 'utf8') + await expectRedactedCorruption( + getAccountStatePath(accountPath), + '{"main":{"access":sk-ant-oat-CANARY-SECRET-0000}}', + ) + + await expectRedactedCorruption( + accountPath, + '{"main":{"claustrumHandle":claustrumHandle-CANARY-SECRET-0000}}', + ) +}) + afterEach(async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE await rm(tempDir, { recursive: true, force: true })