fix(cli): sanitize ANSI escape sequences in /tasks output

deepseek's review pass flagged that monitor description / error fields
are user / process-supplied strings rendered directly to the terminal.
A maliciously-crafted tool description or spawn error containing raw
ANSI control sequences (clear-screen, cursor-move, colour) would
otherwise reach stdout verbatim and corrupt display.

Same risk applies to agent error / description and shell error /
command — all already-existing renderers with the same exposure that
this PR didn't introduce but inherits. So instead of per-field
sprinkling, wrap the joined output once with `escapeAnsiCtrlCodes`
(no-op when no control chars present, so cost is zero in the common
case). One line change in the renderer covers every kind including
any future one.

Test pins the behaviour: a monitor entry with `\x1b[2J` /
`\x1b[31m...` content produces output with no raw ESC bytes and
visible escaped `[...]` sequences.
This commit is contained in:
Shaojin Wen 2026-05-03 12:41:02 +08:00
parent 040dab2ada
commit 0840e32f6b
2 changed files with 36 additions and 1 deletions

View file

@ -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([

View file

@ -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')),
};
},
};