Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/developers/daemon/09-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ These events are workspace-keyed, not session-keyed. The session reducer treats

### Pending prompt queue

| Type | Direction | Trigger | Key payload fields |
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `pending_prompt_added` | S->C | A prompt was appended to the session's prompt FIFO and is waiting behind the active turn. Published only when the prompt is *genuinely queued* — the first prompt on an idle session starts immediately and emits no queue events. | `sessionId, promptId, text, queuedAt` |
| `pending_prompt_started` | S->C | The queued prompt reached the head of the FIFO and was promoted to running (also emitted for a promoted mid-turn message that starts immediately). Skipped when the prompt is aborted before promotion. | `sessionId, promptId, text` |
| Type | Direction | Trigger | Key payload fields |
| -------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `pending_prompt_added` | S->C | A prompt was appended to the session's prompt FIFO and is waiting behind the active turn. Published only when the prompt is _genuinely queued_ — the first prompt on an idle session starts immediately and emits no queue events. | `sessionId, promptId, text, queuedAt` |
| `pending_prompt_started` | S->C | The queued prompt reached the head of the FIFO and was promoted to running (also emitted for a promoted mid-turn message that starts immediately). Skipped when the prompt is aborted before promotion. | `sessionId, promptId, text` |
| `pending_prompt_completed` | S->C | A queued prompt settled — `state: 'completed'` (it ran; execution errors still report `completed` and ride the terminal `turn_error` frame) or `state: 'removed'` (removed from the queue and never ran). Only emitted for prompts that were genuinely queued (an `added` was published). This is queue-view bookkeeping, **not a turn terminal**; correlate `turn_complete` / `turn_error` by `promptId` for completion. | `sessionId, promptId, state: 'completed' \| 'removed'` |

### Turn lifecycle / assistant pushes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,85 @@ describe('ToolApproval accessibility', () => {
expect(pre.getAttribute('title')).toBe(rendered);
});

it('neutralises bidi and C0 controls in the model-supplied description text', () => {
// `.desc` renders `getDescriptionText`, which returns `rawInput.description`
// verbatim and otherwise falls back to `request.title` — on the daemon/ACP
// path that title is built from `ShellTool.getDescription()`, i.e. the raw
// command. #13566: this sibling of the sanitised command block was still
// rendered raw, in its body, in its tooltip and in the `aria-describedby`
// target a screen reader reads.
const craftedDescription = [
'Delete temporary data\u202e', // U+202E RIGHT-TO-LEFT OVERRIDE
'ls \u001b[31m--all\u001b[0m\t--color', // C0 ANSI escape, tab kept
'rm -rf /tmp/data\u0007', // BEL
].join('\n');
render(undefined, {
...execRequest,
rawInput: {
command: 'rm -rf /tmp/data',
description: craftedDescription,
},
});
// `.desc` is a <div>, not a <pre>: reach it through the `aria-describedby`
// IDREF that references it, which also asserts the IDREF still resolves.
const panel = container!.querySelector('[role="alertdialog"]')!;
const descEl = panel
.getAttribute('aria-describedby')!
.split(' ')
.map((id) => document.getElementById(id))
.find(
(el): el is HTMLElement =>
el?.tagName === 'DIV' && el.hasAttribute('title'),
)!;
expect(descEl).toBeTruthy();
const rendered = descEl.textContent!;
expect(rendered).not.toContain('\u202e');
expect(rendered).not.toContain('\u001b');
expect(rendered).not.toContain('\u0007');
// `\n` and `\t` stay unescaped (the helper's own pin lives at
// toolFormatting.test.ts:51) so a multi-line description stays legible.
expect(rendered).toBe(
[
'Delete temporary data\\u202e',
'ls \\u001b[31m--all\\u001b[0m\t--color',
'rm -rf /tmp/data\\u0007',
].join('\n'),
);
expect(descEl.getAttribute('title')).toBe(rendered);
});

it('neutralises bidi and C0 controls in the exec warnings block', () => {
// The warnings text is model-influenced: `buildOutsideWorkspaceWarning`
// (packages/core/src/utils/shell-utils.ts:2093) interpolates the raw
// `directory` argument verbatim, and real text content blocks reach
// `contentText` unescaped. #13566: this sibling <pre>, inside the same
// `isExec && command` fragment as the sanitised command block, was raw.
const craftedDirectory = '/tmp/\u202eevil\u001b[31m\u0007';
const warnings = `Runs outside the workspace in ${craftedDirectory}`;
render(undefined, {
...execRequest,
content: [{ type: 'text', text: warnings }],
rawInput: { command: 'ls -la', description: 'List files' },
});
const warningPre = Array.from(container!.querySelectorAll('pre')).find(
(el) => el.textContent!.includes('Runs outside the workspace'),
)!;
expect(warningPre).toBeTruthy();
const rendered = warningPre.textContent!;
expect(rendered).not.toContain('\u202e');
expect(rendered).not.toContain('\u001b');
expect(rendered).not.toContain('\u0007');
expect(rendered).toBe(
'Runs outside the workspace in /tmp/\\u202eevil\\u001b[31m\\u0007',
);
expect(warningPre.getAttribute('title')).toBe(rendered);
// The command block next to it keeps its own, separate payload intact.
const commandPre = Array.from(container!.querySelectorAll('pre')).find(
(el) => el.textContent === 'ls -la',
)!;
expect(commandPre).toBeTruthy();
});

it('renders exec warnings alongside the command block', () => {
const adapted = extractPendingPermission([
{
Expand Down
34 changes: 23 additions & 11 deletions packages/web-shell/client/components/messages/ToolApproval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -565,14 +565,21 @@ export function ToolApproval({
[request.content, hostOwnsEditDiffPreview],
);
const command = getCommandFromRawInput(request);
// The command block renders `rawInput.command`, which every producer leaves
// verbatim — so this is the last choke point before an approver reads what
// they are about to authorize. Neutralise invisible controls here (C0/ANSI,
// C1, and the bidi embedding/isolate controls) so a crafted command cannot
// display one string while authorizing different bytes. Same helper
// `ShellToolOutput` already uses for shell data. The raw `command` is kept
// for execution and for the explain button below.
// `rawInput.command` is model-supplied and reaches this block verbatim, so
// neutralise the invisible controls `sanitizeControlChars` covers — C0/ANSI,
// C1, and the bidi embedding/isolate controls — before an approver reads it.
// That is the whole of its coverage: zero-width and line/paragraph separator
// code points are not in its character class and still pass through here.
// The raw `command` is kept for execution and for the explain button below.
const commandDisplay = sanitizeControlChars(command ?? '');
// The description is model-supplied too (`rawInput.description`, or a title
// built from the tool's own `getDescription()`), and renders one element
// above the command block, in its tooltip and in the `aria-describedby`
// target. Sanitise the text only: the element gate below must stay on the
// raw value or the IDREF dangles.
const descriptionDisplay = descriptionText
Comment thread
yiliang114 marked this conversation as resolved.
? sanitizeControlChars(descriptionText)
: undefined;
const showsCommandBlock =
!isGoal && Boolean((isExec && command) || showsContent);
// Exec warnings (e.g. command-substitution notices) arrive as real content
Expand All @@ -582,6 +589,11 @@ export function ToolApproval({
isExec && command && showsContent && !request.contentIsInput
? contentText
: null;
// The warnings text interpolates the model's raw `directory` argument
// (see `buildOutsideWorkspaceWarning`), so treat it like the command.
const execWarningsDisplay = execWarningsText
Comment thread
yiliang114 marked this conversation as resolved.
? sanitizeControlChars(execWarningsText)
: undefined;
const questionText = isGoal
? t('approval.goal.hint')
: showsPlanWorkflow
Expand Down Expand Up @@ -634,8 +646,8 @@ export function ToolApproval({
</div>

{descriptionText && (
<div className={styles.desc} id={descId} title={descriptionText}>
{descriptionText}
<div className={styles.desc} id={descId} title={descriptionDisplay}>
{descriptionDisplay}
</div>
)}

Expand All @@ -661,9 +673,9 @@ export function ToolApproval({
<pre
className={styles.content}
id={contentId}
title={execWarningsText}
title={execWarningsDisplay}
>
{execWarningsText}
{execWarningsDisplay}
</pre>
)}
</>
Expand Down
Loading