Summary
shell.openExternal is handed whatever URL the renderer sends, with no scheme check:
// apps/desktop/src/main.ts:285
mainBridge.handle(MAIN_CHANNELS.APP_OPEN_EXTERNAL, ({ url }) => shell.openExternal(url));
The chat's markdown renderer sends anchor.href there for any link the assistant writes (apps/web/src/agent-chat/markdown.tsx:30). In a packaged build the renderer is loaded with loadFile (main.ts:252), so the document base is file:// and a relative markdown link resolves to an absolute file:// URL, ../ included.
This is a hardening report, not a live privilege escalation in the default configuration. Details on that below, I would rather be precise about it than oversell.
What I verified
I ran the repo's exact render() and DOMPurify hook from markdown.tsx, bundled against the repo's own dompurify and marked, inside an Electron 43 BrowserWindow loaded over file://, and read back anchor.href for each case.
The sanitizer holds up well. These all had the href attribute stripped, so the click handler would pass an empty string:
| markdown |
result |
[x](file:///C:/Windows/System32/calc.exe) |
href stripped |
[x](javascript:alert(1)) |
href stripped |
[x](diffusion://auth/callback?code=x) |
href stripped |
[x](ms-msdt:/id) |
href stripped |
Relative links are the gap, because a relative URL is not a scheme for DOMPurify to reject:
| markdown |
anchor.href |
[x](payload.exe) |
file:///.../web/payload.exe |
[x](../../../Users/me/Downloads/payload.exe) |
file:///C:/Users/me/Downloads/payload.exe |
The traversal escaped the page's directory, so the resulting path is not confined to the app bundle.
For the second half of the chain I checked that Electron does not reject file:// here. shell.openExternal on a file:// URL pointing at a harmless .txt resolved without error and the OS handler launched. Verified on Windows 11, Electron 43, at b317412.
Why I am filing this as hardening and not as a vulnerability
In the default configuration the chat harness already runs with permissionMode: "bypassPermissions" and allowDangerouslySkipPermissions: true for Claude (packages/agent-chat/src/host/claude.ts), and approvalPolicy: "never" with sandbox: "danger-full-access" for Codex. An agent that can write that markdown link can already run the payload directly. Nothing here gives an attacker capability they do not already have, and the chain still needs a user click plus a file already on disk at a path reachable by traversal.
Where it would matter
The part I think is worth your attention is the degraded mode the code already implements. When an org policy refuses bypass, tightenPolicy() steps down to auto or default, and canUseTool then denies every tool except AskUserQuestion. In that configuration the agent cannot run anything. It can still emit assistant text, and this sink is not gated by the permission mode at all.
The Codex equivalent steps down to workspaceWrite, where the agent may write inside the project folder but not execute outside the sandbox. It knows its own cwd, so it can compute a relative path from the app bundle to a file it just wrote.
I want to be clear that I verified the sanitizer behaviour and the openExternal behaviour, but I did not run either restricted mode end to end. That part is reading the code, not a demonstration.
Only assistant text reaches Markdown. Tool output and reasoning render as plain text, so the source is the model's own words, which is still influenced by whatever it reads: file names, transcripts, pages it researches.
Suggested fix
Validate in main, since that is the trust boundary:
mainBridge.handle(MAIN_CHANNELS.APP_OPEN_EXTERNAL, ({ url }) => {
const { protocol } = new URL(url);
if (protocol !== "http:" && protocol !== "https:") return;
return shell.openExternal(url);
});
Wrap the parse, an unparseable string should be dropped rather than thrown at the shell. If mailto links are wanted in chat, add mailto:. Checking anchor.protocol in the renderer as well would give a nicer no-op on click, but the main side check is the one that holds if the renderer is ever wrong.
I filed this in the open because private vulnerability reporting is not enabled on the repo and there is no SECURITY.md, and because in the shipped default configuration this does not hand an attacker anything the full access agent does not already have. Happy to send the fix as a PR if useful.
🤖 Generated with Claude Code
Summary
shell.openExternalis handed whatever URL the renderer sends, with no scheme check:The chat's markdown renderer sends
anchor.hrefthere for any link the assistant writes (apps/web/src/agent-chat/markdown.tsx:30). In a packaged build the renderer is loaded withloadFile(main.ts:252), so the document base isfile://and a relative markdown link resolves to an absolutefile://URL,../included.This is a hardening report, not a live privilege escalation in the default configuration. Details on that below, I would rather be precise about it than oversell.
What I verified
I ran the repo's exact
render()and DOMPurify hook frommarkdown.tsx, bundled against the repo's owndompurifyandmarked, inside an Electron 43BrowserWindowloaded overfile://, and read backanchor.hreffor each case.The sanitizer holds up well. These all had the
hrefattribute stripped, so the click handler would pass an empty string:[x](file:///C:/Windows/System32/calc.exe)[x](javascript:alert(1))[x](diffusion://auth/callback?code=x)[x](ms-msdt:/id)Relative links are the gap, because a relative URL is not a scheme for DOMPurify to reject:
anchor.href[x](payload.exe)file:///.../web/payload.exe[x](../../../Users/me/Downloads/payload.exe)file:///C:/Users/me/Downloads/payload.exeThe traversal escaped the page's directory, so the resulting path is not confined to the app bundle.
For the second half of the chain I checked that Electron does not reject
file://here.shell.openExternalon afile://URL pointing at a harmless.txtresolved without error and the OS handler launched. Verified on Windows 11, Electron 43, at b317412.Why I am filing this as hardening and not as a vulnerability
In the default configuration the chat harness already runs with
permissionMode: "bypassPermissions"andallowDangerouslySkipPermissions: truefor Claude (packages/agent-chat/src/host/claude.ts), andapprovalPolicy: "never"withsandbox: "danger-full-access"for Codex. An agent that can write that markdown link can already run the payload directly. Nothing here gives an attacker capability they do not already have, and the chain still needs a user click plus a file already on disk at a path reachable by traversal.Where it would matter
The part I think is worth your attention is the degraded mode the code already implements. When an org policy refuses bypass,
tightenPolicy()steps down toautoordefault, andcanUseToolthen denies every tool exceptAskUserQuestion. In that configuration the agent cannot run anything. It can still emit assistant text, and this sink is not gated by the permission mode at all.The Codex equivalent steps down to
workspaceWrite, where the agent may write inside the project folder but not execute outside the sandbox. It knows its owncwd, so it can compute a relative path from the app bundle to a file it just wrote.I want to be clear that I verified the sanitizer behaviour and the
openExternalbehaviour, but I did not run either restricted mode end to end. That part is reading the code, not a demonstration.Only assistant text reaches
Markdown. Tool output and reasoning render as plain text, so the source is the model's own words, which is still influenced by whatever it reads: file names, transcripts, pages it researches.Suggested fix
Validate in main, since that is the trust boundary:
Wrap the parse, an unparseable string should be dropped rather than thrown at the shell. If mailto links are wanted in chat, add
mailto:. Checkinganchor.protocolin the renderer as well would give a nicer no-op on click, but the main side check is the one that holds if the renderer is ever wrong.I filed this in the open because private vulnerability reporting is not enabled on the repo and there is no SECURITY.md, and because in the shipped default configuration this does not hand an attacker anything the full access agent does not already have. Happy to send the fix as a PR if useful.
🤖 Generated with Claude Code