diff --git a/config/scripts/check-job-log-consumer-oracles.mjs b/config/scripts/check-job-log-consumer-oracles.mjs index dd8c524bb10..a3af7a3bec5 100644 --- a/config/scripts/check-job-log-consumer-oracles.mjs +++ b/config/scripts/check-job-log-consumer-oracles.mjs @@ -16,7 +16,7 @@ export async function loadAnsiConsumers(root = process.cwd()) { ['parseCodexLoginAuthUrl', 'main/codex-accounts/codex-login-auth-url'], ['TerminalCommandPaint', 'main/runtime/terminal-command-paint'], ['claudePermissionSubject', 'main/claude/claude-permission-presentation'], - ['terminalSafe', 'cli/agent-session-search-format'], + ['terminalSafe', 'shared/terminal-safe-text'], [ 'createAutomationRunOutputSnapshotBuffer', 'renderer/src/components/automations/automation-run-output-snapshot' diff --git a/src/cli/agent-session-search-format.test.ts b/src/cli/agent-session-search-format.test.ts index 2c5de26591c..7833a069d53 100644 --- a/src/cli/agent-session-search-format.test.ts +++ b/src/cli/agent-session-search-format.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatSessionSearchResponse, - formatSessionSearchStatus, - terminalSafe + formatSessionSearchStatus } from './agent-session-search-format' import type { AiVaultSearchHit, @@ -174,11 +173,6 @@ describe('formatSessionSearchResponse: non-result answers', () => { }) describe('terminal safety', () => { - it('strips escape sequences a transcript could carry into the reader terminal', () => { - expect(terminalSafe('before\u001b]52;c;cGF5bG9hZA==\u0007after')).toBe('beforeafter') - expect(terminalSafe('red \u001b[31mtext\u001b[0m')).toBe('red text') - }) - it.each([ [ 'updatedAt', diff --git a/src/cli/agent-session-search-format.ts b/src/cli/agent-session-search-format.ts index d1953c6de34..09e68ecaeb7 100644 --- a/src/cli/agent-session-search-format.ts +++ b/src/cli/agent-session-search-format.ts @@ -1,13 +1,10 @@ -import { - stripAnsiEscapeSequences, - TERMINAL_CONTROL_CHARACTER_PATTERN -} from '../shared/ansi-escape-sequences' import { aiVaultAgentLabel } from '../shared/ai-vault-types' import type { AiVaultSearchHit, AiVaultSearchResponse, AiVaultSearchStatus } from '../shared/ai-vault-search-types' +import { terminalSafeLine } from '../shared/terminal-safe-text' type SessionSearchResults = Extract /** Malformed cursors are a caller mistake and leave through the CLI error channel. */ @@ -16,15 +13,8 @@ export type PrintableSessionSearchResponse = Exclude< { kind: 'malformed-cursor' } > -// Strip terminal controls from untrusted transcripts, including OSC 52 clipboard writes. -export function terminalSafe(value: string): string { - return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') -} - function oneLine(value: string): string { - return terminalSafe(value) - .replaceAll(/[\r\n]+/g, ' ') - .trim() + return terminalSafeLine(value).trim() } function formatHit(hit: AiVaultSearchHit): string { diff --git a/src/cli/linear-format.test.ts b/src/cli/linear-format.test.ts index ded8870028e..0b33783b41e 100644 --- a/src/cli/linear-format.test.ts +++ b/src/cli/linear-format.test.ts @@ -191,4 +191,116 @@ describe('linear-format', () => { expect(formatLinearCreate(result)).toBe('Created ENG-123 in Launch: Follow up.') }) + + const OSC_52 = '\u001b]52;c;cGF5bG9hZA==\u0007' + const CLEAR_SCREEN = '\u001b[2J' + + it('strips escape sequences, C1 bytes and line breaks from issue fields', () => { + const result: LinearIssueContextResult = { + issue: { + id: 'issue-1', + identifier: 'ENG-123', + title: `Fix${OSC_52} auth\r\nState: Done`, + url: 'https://linear.app/acme/issue/ENG-123', + state: { name: `In Progress${CLEAR_SCREEN}` }, + assignee: { displayName: 'Ada\u009b31m' }, + project: { id: 'project-1', name: 'Launch' }, + labels: [{ id: 'label-1', name: `bug${OSC_52}` }], + priority: 2, + estimate: 5 + }, + meta: { + requested: { + current: false, + include: { + comments: false, + children: false, + attachments: false, + relations: false, + activity: false + }, + depth: 0 + }, + resolved: { + id: 'issue-1', + identifier: 'ENG-123', + workspaceId: 'workspace-1', + workspaceName: 'Acme' + }, + partial: false, + includeErrors: [], + sections: {} + } + } + + expect(formatLinearIssue(result)).toBe( + [ + 'ENG-123 Fix auth State: Done', + 'URL: https://linear.app/acme/issue/ENG-123', + 'State: In Progress', + 'Assignee: Ada', + 'Project: Launch', + 'Priority: high', + 'Estimate: 5', + 'Labels: bug' + ].join('\n') + ) + }) + + it('keeps one aligned row per issue when a title carries a line break', () => { + const output = formatLinearMcpIssueList({ + issues: [ + { + id: 'issue-1', + identifier: 'ENG-1', + title: `Fix auth\r\nENG-2 Done${CLEAR_SCREEN}`, + url: 'https://linear.app/acme/issue/ENG-1', + labels: [], + state: { name: `In${OSC_52} Progress` }, + assignee: { displayName: 'Ada\u009d0;pwned\u009c' }, + workspace: { id: 'workspace-1', name: 'Acme' } + } + ], + truncated: false, + meta: { + limit: 50, + returned: 1, + hasMore: false, + orderBy: 'updatedAt', + workspaceId: 'workspace-1', + partial: false, + workspaceErrors: [] + } + }) + + expect(output).toBe( + `${'ENG-1'.padEnd(10)} ${'In Progress'.padEnd(14)} ${'Ada'.padEnd(18)} Fix auth ENG-2 Done` + ) + }) + + it('strips control sequences from Linear-provided warning text', () => { + printLinearMcpIssueListWarnings({ + issues: [], + truncated: false, + meta: { + limit: 20, + returned: 0, + hasMore: false, + orderBy: 'updatedAt', + workspaceId: 'all', + partial: true, + workspaceErrors: [ + { + workspace: { id: 'workspace-1', name: `Acme${OSC_52}` }, + code: 'linear_rate_limited', + message: `rate limited\r\nwarning: fake${CLEAR_SCREEN}` + } + ] + } + }) + + expect(console.error).toHaveBeenCalledWith( + 'warning: Acme unavailable for Linear: rate limited warning: fake' + ) + }) }) diff --git a/src/cli/linear-format.ts b/src/cli/linear-format.ts index 53e48657290..869cf13436c 100644 --- a/src/cli/linear-format.ts +++ b/src/cli/linear-format.ts @@ -25,28 +25,29 @@ import { formatLinearProjectListRows, linearProjectListWarningLines } from '../shared/linear/project-list-format' +import { terminalSafeLine as safe } from '../shared/terminal-safe-text' export function formatLinearIssue(result: LinearIssueContextResult): string { const issue = result.issue const lines = [ - `${issue.identifier} ${issue.title}`, - `URL: ${issue.url}`, - `State: ${issue.state?.name ?? 'unknown'}`, - `Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`, - `Project: ${issue.project?.name ?? 'none'}` + `${safe(issue.identifier)} ${safe(issue.title)}`, + `URL: ${safe(issue.url)}`, + `State: ${safe(issue.state?.name ?? 'unknown')}`, + `Assignee: ${safe(issue.assignee?.displayName ?? 'unassigned')}`, + `Project: ${safe(issue.project?.name ?? 'none')}` ] lines.push(`Priority: ${formatPriority(issue.priority)}`) lines.push(`Estimate: ${issue.estimate ?? 'none'}`) if (issue.labels.length > 0) { lines.push( `Labels: ${issue.labels - .map((label) => label.name) + .map((label) => safe(label.name ?? '')) .filter(Boolean) .join(', ')}` ) } if (issue.dueDate) { - lines.push(`Due: ${issue.dueDate}`) + lines.push(`Due: ${safe(issue.dueDate)}`) } const sections = result.meta.sections if (sections.comments) { @@ -87,35 +88,38 @@ export function formatLinearTeamList(result: LinearTeamListResult): string { } return result.teams .map((team) => { - const workspace = team.workspace ? ` ${team.workspace.name}` : '' - return `${team.key.padEnd(10)} ${team.name}${workspace}` + const workspace = team.workspace ? ` ${safe(team.workspace.name)}` : '' + return `${safe(team.key).padEnd(10)} ${safe(team.name)}${workspace}` }) .join('\n') } export function formatLinearTeamMembers(result: LinearTeamMembersResult): string { if (result.members.length === 0) { - return `No Linear members found for ${result.team.key}.` + return `No Linear members found for ${safe(result.team.key)}.` } return result.members - .map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`) + .map((member) => `${safe(member.displayName ?? 'unknown').padEnd(24)} ${safe(member.id ?? '')}`) .join('\n') } export function formatLinearTeamStates(result: LinearTeamStatesResult): string { if (result.states.length === 0) { - return `No Linear workflow states found for ${result.team.key}.` + return `No Linear workflow states found for ${safe(result.team.key)}.` } return result.states - .map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`) + .map( + (state) => + `${safe(state.name).padEnd(24)} ${safe(state.type ?? '').padEnd(12)} ${safe(state.id)}` + ) .join('\n') } export function formatLinearTeamLabels(result: LinearTeamLabelsResult): string { if (result.labels.length === 0) { - return `No Linear labels found for ${result.team.key}.` + return `No Linear labels found for ${safe(result.team.key)}.` } - return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n') + return result.labels.map((label) => `${safe(label.name).padEnd(24)} ${safe(label.id)}`).join('\n') } export function formatLinearIssueList(result: LinearIssueListResult): string { @@ -144,14 +148,16 @@ export function printLinearMcpIssueListWarnings(result: LinearMcpIssueListResult if (result.meta.hasMore) { const workspaceHint = result.meta.nextCursor && result.meta.workspaceId !== 'all' && result.meta.workspaceId - ? `; continue with --workspace ${result.meta.workspaceId}` + ? `; continue with --workspace ${safe(result.meta.workspaceId)}` : '' console.error( - `warning: more results available; next cursor: ${result.meta.nextCursor ?? 'n/a'}${workspaceHint}` + `warning: more results available; next cursor: ${safe(result.meta.nextCursor ?? 'n/a')}${workspaceHint}` ) } for (const error of result.meta.workspaceErrors) { - console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`) + console.error( + `warning: ${safe(error.workspace.name)} unavailable for Linear: ${safe(error.message)}` + ) } } @@ -161,44 +167,44 @@ export function formatLinearProjectList(result: LinearProjectListResult): string export function formatLinearStatusSet(result: LinearStatusSetResult): string { const suffix = result.meta.alreadyInState ? ' (already set)' : '' - return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.` + return `Set ${safe(result.issue.identifier)} to ${safe(result.state.name)}${suffix}.` } export function formatLinearCommentAdd(result: LinearCommentAddResult): string { const suffix = result.meta.deduplicated ? ' (already posted)' : '' - return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.` + return `Added comment ${safe(result.comment.id)} to ${safe(result.issue.identifier)}${suffix}.` } export function formatLinearCommentEdit(result: LinearCommentEditResult): string { - return `Edited comment ${result.comment.id} on ${result.issue.identifier}.` + return `Edited comment ${safe(result.comment.id)} on ${safe(result.issue.identifier)}.` } export function formatLinearCommentDelete(result: LinearCommentDeleteResult): string { - return `Deleted comment ${result.comment.id} from ${result.issue.identifier}.` + return `Deleted comment ${safe(result.comment.id)} from ${safe(result.issue.identifier)}.` } export function formatLinearAttach(result: LinearAttachResult): string { const suffix = result.meta.deduplicated ? ' (already attached)' : '' - return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.` + return `Attached ${safe(result.attachment.title)} to ${safe(result.issue.identifier)}${suffix}.` } export function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string { - const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : '' - const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : '' + const parent = result.issue.parent ? ` under ${safe(result.issue.parent.identifier)}` : '' + const project = result.issue.project?.name ? ` in ${safe(result.issue.project.name)}` : '' const suffix = result.meta.deduplicated ? ' (already created)' : '' - return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.` + return `Created ${safe(result.issue.identifier)}${parent}${project}: ${safe(result.issue.title)}${suffix}.` } export function formatLinearSaveIssue(result: LinearSaveIssueResult): string { if (result.meta.created) { return formatLinearCreate(result) } - return `Saved ${result.issue.identifier}: ${result.issue.title}.` + return `Saved ${safe(result.issue.identifier)}: ${safe(result.issue.title)}.` } export function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string { const suffix = result.meta.alreadySet ? ' (already set)' : '' - return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.` + return `Updated ${safe(result.issue.identifier)} ${taskOperationLabel(result.operation)}${suffix}.` } export function formatLinearRelationWrite(result: LinearIssueRelationWriteResult): string { @@ -208,12 +214,12 @@ export function formatLinearRelationWrite(result: LinearIssueRelationWriteResult ? ' (already present)' : ' (already absent)' : '' - return `${verb} ${result.issue.identifier} ${result.relation.relationship} ${result.relatedIssue.identifier}${suffix}.` + return `${verb} ${safe(result.issue.identifier)} ${result.relation.relationship} ${safe(result.relatedIssue.identifier)}${suffix}.` } export function printLinearIssueWarnings(result: LinearIssueContextResult): void { for (const error of result.meta.includeErrors) { - console.error(`warning: ${error.include} unavailable: ${error.message}`) + console.error(`warning: ${error.include} unavailable: ${safe(error.message)}`) } for (const [name, meta] of Object.entries(result.meta.sections)) { if (meta?.capReached) { @@ -228,7 +234,7 @@ export function printLinearSearchWarnings(result: LinearSearchResult): void { } for (const error of result.meta.workspaceErrors ?? []) { console.error( - `warning: ${error.workspace.name} unavailable for Linear search: ${error.message}` + `warning: ${safe(error.workspace.name)} unavailable for Linear search: ${safe(error.message)}` ) } } @@ -244,7 +250,9 @@ export function printLinearListWarnings( console.error(`warning: showing first ${meta.returned} Linear issues`) } for (const error of meta.workspaceErrors ?? []) { - console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`) + console.error( + `warning: ${safe(error.workspace.name)} unavailable for Linear: ${safe(error.message)}` + ) } } @@ -255,9 +263,9 @@ export function printLinearProjectListWarnings(result: LinearProjectListResult): } function formatSearchRow(issue: LinearSearchIssueSummary): string { - const state = issue.state?.name ?? 'unknown' - const assignee = issue.assignee?.displayName ?? 'unassigned' - return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}` + const state = safe(issue.state?.name ?? 'unknown') + const assignee = safe(issue.assignee?.displayName ?? 'unassigned') + return `${safe(issue.identifier).padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${safe(issue.title)}` } function formatPriority(priority: number | null | undefined): string { diff --git a/src/shared/linear/project-list-format.ts b/src/shared/linear/project-list-format.ts index eea2846e8c1..15e7d4a9be1 100644 --- a/src/shared/linear/project-list-format.ts +++ b/src/shared/linear/project-list-format.ts @@ -1,5 +1,6 @@ import type { LinearProjectListResult } from './agent-result-types' import { appendLinearListTruncation } from './list-truncation-format' +import { terminalSafeLine as safe } from '../terminal-safe-text' // Why: non-JSON project output aligns ids with the existing compact Linear tables. const LINEAR_PROJECT_NAME_COLUMN_WIDTH = 28 @@ -14,12 +15,12 @@ export function formatLinearProjectListRows(result: LinearProjectListResult): st project.teams ?.map((team) => { const key = team.key?.trim() - return key ? key : team.name + return safe(key ? key : team.name) }) .filter(Boolean) .join(',') || 'no-teams' - const workspace = project.workspaceName ? ` ${project.workspaceName}` : '' - return `${project.name.padEnd(LINEAR_PROJECT_NAME_COLUMN_WIDTH)} ${project.id} ${teams}${workspace}` + const workspace = project.workspaceName ? ` ${safe(project.workspaceName)}` : '' + return `${safe(project.name).padEnd(LINEAR_PROJECT_NAME_COLUMN_WIDTH)} ${safe(project.id)} ${teams}${workspace}` }) .join('\n') return appendLinearListTruncation( @@ -35,7 +36,9 @@ export function linearProjectListWarningLines(result: LinearProjectListResult): warnings.push(`warning: showing first ${result.meta.returned} Linear projects`) } for (const error of result.meta.workspaceErrors ?? []) { - warnings.push(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`) + warnings.push( + `warning: ${safe(error.workspace.name)} unavailable for Linear: ${safe(error.message)}` + ) } return warnings } diff --git a/src/shared/terminal-safe-text.ts b/src/shared/terminal-safe-text.ts new file mode 100644 index 00000000000..d85dbd15b2e --- /dev/null +++ b/src/shared/terminal-safe-text.ts @@ -0,0 +1,14 @@ +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from './ansi-escape-sequences' + +// Strip terminal controls from untrusted remote text, including OSC 52 clipboard writes. +export function terminalSafe(value: string): string { + return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') +} + +/** Why: a line break inside a single-line field could spoof a separate output line. */ +export function terminalSafeLine(value: string): string { + return terminalSafe(value).replaceAll(/[\r\n]+/g, ' ') +}