diff --git a/packages/cli/src/ui/commands/tasksCommand.test.ts b/packages/cli/src/ui/commands/tasksCommand.test.ts index 104285ea1..58e299013 100644 --- a/packages/cli/src/ui/commands/tasksCommand.test.ts +++ b/packages/cli/src/ui/commands/tasksCommand.test.ts @@ -324,6 +324,32 @@ describe('tasksCommand', () => { expect(result.content).toContain('Background tasks (1 total)'); }); + it('escapes ANSI control sequences embedded in entry fields', async () => { + // A monitor whose description / error contain raw ANSI escape codes + // (e.g. `\x1b[2J` clear-screen, `\x1b[31m...` colour) must not reach + // the terminal verbatim — a malicious tool description or spawn + // error otherwise could corrupt display. `escapeAnsiCtrlCodes` + // converts them to literal `[...]` sequences that render as + // text instead of being interpreted by the terminal. + getMonitors.mockReturnValue([ + monitorEntry({ + monitorId: 'mon_evil', + description: 'bad \x1b[2Jthing', + status: 'failed', + error: 'spawn \x1b[31mENOENT\x1b[0m', + eventCount: 0, + }), + ]); + const result = await tasksCommand.action!(context, ''); + if (!result || result.type !== 'message') throw new Error('no result'); + // No raw ESC byte in the output. + expect(result.content).not.toContain('\x1b'); + // The escaped form (literal backslash-u-001b) should appear, proving + // the sanitizer ran rather than dropping the chars. + expect(result.content).toContain('\\u001b[2J'); + expect(result.content).toContain('\\u001b[31m'); + }); + it('merges all three kinds and orders them by startTime', async () => { const now = Date.now(); getAgents.mockReturnValue([ diff --git a/packages/cli/src/ui/commands/tasksCommand.ts b/packages/cli/src/ui/commands/tasksCommand.ts index 553b0ab68..0c8f7fa98 100644 --- a/packages/cli/src/ui/commands/tasksCommand.ts +++ b/packages/cli/src/ui/commands/tasksCommand.ts @@ -14,6 +14,7 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; import { formatDuration } from '../utils/formatters.js'; +import { escapeAnsiCtrlCodes } from '../utils/textUtils.js'; type AgentTaskEntry = BackgroundTaskEntry & { kind: 'agent'; @@ -225,10 +226,18 @@ export const tasksCommand: SlashCommand = { } } + // Defense in depth: registry entries carry user-supplied / process- + // supplied strings (description, command, error from spawn / settle). + // A maliciously-crafted value containing raw ANSI escape sequences + // could otherwise reach the terminal verbatim and corrupt display. + // `escapeAnsiCtrlCodes` is a no-op when the string has no control + // chars, so wrapping the joined output is cheap and covers every + // field — including any future kind's fields — without per-site + // sanitization sprawl. return { type: 'message' as const, messageType: 'info' as const, - content: lines.join('\n'), + content: escapeAnsiCtrlCodes(lines.join('\n')), }; }, };