diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts index e4c1e805ea..2df38bb4ae 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts +++ b/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts @@ -366,4 +366,52 @@ describe("DashboardLogSink system-log history", () => { expect(stored).not.toContain("sk-abcdef0123456789abcdef"); expect(seen[0]).toBe(stored); }); + + it("redacts secrets on the TUI/console output path too", () => { + const secret = "sk-abcdef0123456789abcdef"; + // Non-TTY sink → forwards to console.*; assert the raw secret never prints. + const consoleSpies = { + log: vi.spyOn(console, "log").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; + try { + const sink = new DashboardLogSink(); + sink.log(`token=${secret}`); + sink.warn(`token=${secret}`, "engine"); + sink.error(`Authorization: Bearer ${secret}`); + + const allPrinted = [ + ...consoleSpies.log.mock.calls, + ...consoleSpies.warn.mock.calls, + ...consoleSpies.error.mock.calls, + ] + .flat() + .join(" "); + expect(allPrinted).not.toContain(secret); + expect(allPrinted).toContain("[REDACTED]"); + } finally { + consoleSpies.log.mockRestore(); + consoleSpies.warn.mockRestore(); + consoleSpies.error.mockRestore(); + } + }); + + it("redacts secrets forwarded to the TUI target", () => { + const secret = "ghp_abcdef0123456789ABCDEF"; + const lines: string[] = []; + const tuiTarget = { + running: true, + log: (m: string) => lines.push(m), + warn: (m: string) => lines.push(m), + error: (m: string) => lines.push(m), + }; + const sink = new DashboardLogSink(tuiTarget); + sink.log(`leaked ${secret}`); + sink.error(`boom ${secret}`, "engine"); + + const joined = lines.join(" "); + expect(joined).not.toContain(secret); + expect(joined).toContain("[REDACTED]"); + }); }); diff --git a/packages/cli/src/commands/dashboard-tui/log-sink.ts b/packages/cli/src/commands/dashboard-tui/log-sink.ts index d82bb5d1ff..67b32da06e 100644 --- a/packages/cli/src/commands/dashboard-tui/log-sink.ts +++ b/packages/cli/src/commands/dashboard-tui/log-sink.ts @@ -119,11 +119,8 @@ export class DashboardLogSink { } private record(level: LogEntry["level"], message: string, prefix?: string): void { - // Redact before storing/broadcasting: the System panel serves this history - // over /system/logs + /system/logs/stream and into diagnostics/bug reports, - // so any secret that reaches a log line would otherwise be resurfaceable to - // a dashboard client. Mask before it enters the ring buffer or listeners. - const entry: LogEntry = { timestamp: new Date(), level, message: redactSecrets(message), prefix }; + // `message` is already redacted by the public log/warn/error entry points. + const entry: LogEntry = { timestamp: new Date(), level, message, prefix }; this.history.push(entry); for (const listener of this.entryListeners) { try { @@ -150,6 +147,10 @@ export class DashboardLogSink { log(message: string, prefix?: string): void { if (this.silenced) return; + // Redact once at the entry point so BOTH the recorded history (served over + // /system/logs) and the TUI/console output are masked — a secret must not + // leak on either path. + message = redactSecrets(message); this.record("info", message, prefix); const line = prefix ? `[${prefix}] ${message}` : message; if (this.tui && this.isTTY) { @@ -163,6 +164,7 @@ export class DashboardLogSink { warn(message: string, prefix?: string): void { if (this.silenced) return; + message = redactSecrets(message); this.record("warn", message, prefix); const line = prefix ? `[${prefix}] ${message}` : message; if (this.tui && this.isTTY) { @@ -176,6 +178,7 @@ export class DashboardLogSink { error(message: string, prefix?: string): void { if (this.silenced) return; + message = redactSecrets(message); this.record("error", message, prefix); const line = prefix ? `[${prefix}] ${message}` : message; if (this.tui && this.isTTY) {