
Why Claude Code's Deeplink Handler Allowed RCE (and How to Fix It in Your Own CLI Tools)
What the Claude Code deeplink flaw actually did
The bug was not in the AI model. It was in the trust boundary around a deeplink handler inside a developer tool.
Claude Code accepted specially crafted URLs and used them to trigger actions on the local machine. In the vulnerable flow, a malicious deeplink could influence how the tool built and ran a command in the terminal. That is the line that matters: once untrusted URL data reaches command execution, remote code execution is on the table.
The reported issue was patched, which is the right outcome. But the pattern is bigger than one product. Any CLI tool that registers a URL scheme, parses arguments from the browser, or shells out for convenience can drift into the same failure mode.
A URL is not a command. If you treat one like a command, you need the same threat model you would use for any remote input.
Why a CLI URL handler can become an RCE path
Trusted URLs versus attacker-controlled input
A lot of dev tools assume deeplinks are “internal” because they come from a browser tab, a local redirect, or a signed-in workflow. That assumption is fragile.
If the scheme is registered on the system, then any site, email, chat app, or document can try to open it. At that point, the handler is exposed to attacker-controlled parameters. The handler may still look like a product feature, but from a security point of view it is a network-facing input parser.
The distinction that matters is simple:
- trusted source: a value generated by your own app and never modified
- attacker-controlled input: anything someone else can place into the URL
Where shell execution usually sneaks in
RCE often appears when a handler does one of these things:
- builds a shell string like
sh -c "tool --arg ${value}" - passes a URL parameter into
exec()orspawn(..., { shell: true }) - concatenates a command that includes file paths, flags, or subcommands
- opens a terminal command that inherits metacharacters from the input
The bug is not always obvious in code review. The handler may be a small wrapper around something that looks safe, such as a browser-open helper or a terminal launcher. The dangerous part is what happens after parsing.
Reconstructing the attack flow safely
How a malicious deeplink reaches the terminal
A realistic flow looks like this:
- The attacker crafts a deeplink with a parameter that looks harmless at a glance.
- The developer clicks it in a browser, chat app, or issue tracker.
- The OS dispatches the URL to the registered CLI handler.
- The handler parses the URL and forwards one or more parameters into a command path.
- The terminal runs the command with attacker influence still intact.
The exact payload format is less important than the boundary crossing. The risk comes from moving from URL parsing to execution without a hard validation step in between.
The unsafe boundary between parsing and execution
I usually look for the first point where data changes from “string in a URL” into “argument passed to a process.” That boundary needs to be explicit.
If the code does this, it is already too loose:
const target = new URL(input).searchParams.get("target");
exec(`claude code ${target}`);
Even if target is validated for length or character class, the use of a shell string keeps the attack surface open. Validation helps, but it does not fix command construction.
What to test in your own CLI tool
URL scheme allowlists
Start by asking a basic question: which schemes can your app actually handle?
- only accept a narrow set of schemes
- reject unknown paths and actions
- require exact parameter names
- treat missing or duplicate parameters as invalid
If your handler accepts a general-purpose redirect or callback format, you need stricter rules. “We only use this internally” is not a control.
Shell escaping and argument handling
Use process APIs that pass arguments as data, not as a shell string.
const args = ["open", "--project", projectPath];
const child = spawn("claude-code", args, {
shell: false,
stdio: "inherit",
});
That does not make all input safe, but it removes the easiest command-injection path. Then validate each field before building the array.
Confirmation prompts for external actions
If a deeplink can trigger a local action with side effects, prompt first.
Good prompts are specific:
- show the action name
- show the destination or file path
- show the origin domain if available
- require an explicit yes/no response
A confirmation step is not a substitute for validation, but it helps when a tool is expected to bridge browser input and local execution.
Safer implementation patterns in JavaScript
Open links without invoking a shell
If the only goal is to open a URL, use a library or platform API that does not route through a shell. Keep the browser handoff separate from command execution.
function openUrl(url) {
const parsed = new URL(url);
if (!["https:", "http:"].includes(parsed.protocol)) {
throw new Error("unsupported protocol");
}
spawn("open", [parsed.toString()], { shell: false, stdio: "ignore" });
}
Treat deeplink payloads as data, not commands
Do not embed user-controlled fields into a command template. Parse the URL, map it to a known action, and only pass normalized values forward.
function handleDeeplink(raw) {
const url = new URL(raw);
if (url.protocol !== "claude-code:") {
throw new Error("bad scheme");
}
const action = url.hostname;
const project = url.searchParams.get("project");
if (action !== "open-project") throw new Error("bad action");
if (!project || !/^[a-z0-9/_-]+$/i.test(project)) {
throw new Error("bad project");
}
return { action, project };
}
Log and reject malformed or unexpected parameters
Rejection should be loud enough to notice in tests and logs:
- unexpected scheme
- unknown action
- duplicate query parameters
- control characters
- encoded separators that change meaning after decoding
That logging becomes useful when you are fuzzing the handler later.
Defense in depth for AI-augmented devtools
AI tools tend to sit close to high-trust workflows: terminals, repositories, tokens, local files, and package managers. That makes convenience features especially sensitive.
My baseline for these tools is:
- no shell interpolation for untrusted input
- no automatic execution from external content
- clear action review before local side effects
- strict separation between URL parsing and command launch
- fuzz tests for malformed URLs and weird encodings
If a feature can be triggered from outside your app, write tests for it like it was an API endpoint.
Conclusion: small convenience features need real threat modeling
The Claude Code issue is a good reminder that “developer tool” is not a security category. A CLI with a deeplink handler is still accepting external input, and external input becomes dangerous the moment it can steer a shell.
If you build these integrations, test them the same way you test webhook handlers or auth callbacks. Keep URL parsing, validation, and execution in separate layers. Use argument arrays instead of shell strings. Add prompts for actions that touch the local machine.
That is not overengineering. It is the minimum needed to keep a convenience feature from becoming an RCE path.


