feat(code): add dcode:// URL scheme support - #6005
Conversation
Register dcode as the operating system's handler for `dcode://` links, so a page can offer to open a project or resume a thread in dcode. `dcode url install` writes a user-scoped handler per platform: an AppleScript applet in `~/Applications` on macOS, an XDG desktop entry on Linux, and the `HKCU` protocol key on Windows. `uninstall` and `status` complete the group, and `dcode url open <link>` is the verb a registration points at. Registration is opt-in and never happens on package install, since claiming a URL scheme changes how the user's browser behaves. `url_scheme.request` is the trust boundary: one action, a closed parameter set (`dir`, `thread`, `agent`, `prompt`), and refusal — rather than silent repair — of unknown or repeated keys, non-UUID thread ids, relative or absent directories, and text carrying control characters or deceptive Unicode. `url_scheme.handler` then shows the whole request in a terminal and waits for an explicit approval with the cancel row preselected, because a browser's own prompt names an application rather than a request and can be remembered away. The launch argv holds only `-a`, `-r`, and `-m`, so a link cannot alter the session's approval mode, model, or sandbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
An individual commit message matches Found: FixIf the trailer appears in an individual commit, rewrite that commit message to remove the offending line. |
|
⛔ This PR edits a project README but is not a
Change the PR title type to |
| identifier = _bundle_identifier(bundle) | ||
| if identifier is not None and identifier != BUNDLE_ID: | ||
| msg = ( | ||
| f"Refusing to replace {bundle}: it belongs to another application " | ||
| f"({identifier}). Move it aside and run this command again." | ||
| ) | ||
| raise RegistrationError(msg) | ||
| try: | ||
| shutil.rmtree(bundle) |
There was a problem hiding this comment.
🔴 Install can delete an unrelated app bundle
When ~/Applications/dcode.app exists but its Info.plist is missing, malformed, unreadable, or simply lacks CFBundleIdentifier, _bundle_identifier() returns None. This condition only refuses a non-None foreign identifier, so installation proceeds to recursively delete the entire existing bundle. Refuse replacement unless the identifier is exactly BUNDLE_ID; otherwise an explicit install can destroy unrelated user data at this path.
(Refers to lines 313-321)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
| try: | ||
| parser.read(path, encoding="utf-8") | ||
| except (OSError, configparser.Error): | ||
| logger.warning("Could not parse %s; leaving it alone", path, exc_info=True) | ||
| return configparser.RawConfigParser(delimiters=("=",)) |
There was a problem hiding this comment.
🟠 Malformed MIME config gets overwritten
If mimeapps.list is unreadable or malformed, this returns a fresh empty parser after logging that the file will be “left alone.” On systems without a working xdg-mime, _write_mimeapps_default() then adds only dcode's entry and _save_mimeapps() overwrites the original file, discarding all of the user's existing default-application associations. Propagate a read/parse failure (or skip the fallback write) instead of representing it as an empty configuration.
(Refers to lines 329-333)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
| def _prompt_preview(prompt: str) -> list[str]: | ||
| """Split a prompt into display lines, summarizing an overlong tail. | ||
|
|
||
| Args: | ||
| prompt: The prompt text. | ||
|
|
||
| Returns: | ||
| Lines to print. | ||
| """ | ||
| lines = prompt.splitlines() or [prompt] | ||
| if len(lines) <= _PROMPT_PREVIEW_LINES: | ||
| return lines | ||
| hidden = len(lines) - _PROMPT_PREVIEW_LINES | ||
| return [ | ||
| *lines[:_PROMPT_PREVIEW_LINES], | ||
| f"... {hidden} more line{'s' if hidden != 1 else ''} not shown", | ||
| ] |
There was a problem hiding this comment.
🟠 Hidden prompt tail bypasses confirmation
A URL can put benign text in the first 12 lines and attacker-controlled instructions after it. This preview replaces that tail with a count, but _launch still passes the complete prompt via -m, so the user can approve a first message they were never shown. That breaks the confirmation gate for the most security-sensitive field; render every accepted line (or reject prompts that cannot be shown in full) before offering approval.
(Refers to lines 208-224)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
| _reject_control_chars(value, field="dir", allowed=frozenset()) | ||
| try: | ||
| expanded = Path(value).expanduser() |
There was a problem hiding this comment.
🟠 Deceptive Unicode remains allowed in directories
The directory is the value the warning explicitly asks the user to recognize, but this path only rejects C0/C1 controls. An existing directory whose name contains bidi overrides or invisible characters therefore renders misleadingly in the confirmation and is then used as the working directory, even though the same URL rejects those characters in prompts to keep displayed and acted-on text identical. Apply the dangerous-Unicode validation to dir before resolving it as well.
(Refers to lines 291-293)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
There was a problem hiding this comment.
The new dcode:// URL handler truncates prompt previews at 12 lines during the confirmation dialog, but passes the complete prompt to the session on approval — allowing an attacker to hide malicious instructions in lines beyond the visible window while the user sees only benign content.
| if len(lines) <= _PROMPT_PREVIEW_LINES: | ||
| return lines | ||
| hidden = len(lines) - _PROMPT_PREVIEW_LINES | ||
| return [ |
There was a problem hiding this comment.
The confirmation dialog shows at most 12 lines of the URL-supplied prompt, but _launch() passes the full text unconditionally. An attacker can hide malicious instructions beyond the visible window and obtain approval for content the user never read.
lines = prompt.splitlines() or [prompt]
if len(lines) <= _PROMPT_PREVIEW_LINES:
return lines
hidden = len(lines) - _PROMPT_PREVIEW_LINES
return [
*lines[:_PROMPT_PREVIEW_LINES],
f"... {hidden} more line{'s' if hidden != 1 else ''} not shown",
]Remediation: Either reject prompts with more lines than _PROMPT_PREVIEW_LINES (add a line-count check in _prompt() in request.py), or display the full prompt in the confirmation UI. Do not submit URL-supplied prompt text that was not shown in full before approval.
Attack Path
- Attacker crafts a
dcode://open?dir=/victim/project&prompt=<payload>link where the first 12 lines are benign and line 13+ contains malicious instructions. - Victim clicks the link; OS passes it to the
dcodeURL-scheme handler. parse_open_url()validates the URL and returns anOpenRequestwith the full prompt intact._print_request()calls_prompt_preview(), which shows only the first 12 lines.- Victim approves based on the visible benign content.
_launch()passes the full prompt via-mtodcode, including the hidden instructions.
For more details, see the finding in Corridor.
Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.
Pages can now offer to open a project in dcode: run
dcode url installand adcode://open?dir=/path/to/projectlink opens a session there, after your browser asks whether to hand the link over and dcode shows you the whole request and waits for your approval.Editors, chat clients, and Claude Code itself are reachable from a browser through a private URL scheme, and dcode had no equivalent. This adds one, as a
dcode urlcommand group:install,uninstall, andstatus, plus theopenverb a registration points at.A registered handler is a remote-reachable entry point — any page, chat message, or email can emit a link — so the design puts the interesting parts in two places and keeps everything else dumb.
url_scheme.requestis the trust boundary. It admits one action and a closed parameter set (dir,thread,agent,prompt) and refuses rather than repairs: an unknown or repeated key, a non-UUID thread id, a relative or non-existent directory, prompt text carrying control characters or deceptive Unicode. Refusing unknown keys trades forward compatibility for the guarantee that a link written for a newer dcode cannot be half-honored by an older one. Refusing rather than stripping deceptive text means the request the user reads is the request the session receives. Requiring a UUID also keeps the-r __MOST_RECENT__sentinel out of a link's reach.url_scheme.handleris the gate. A browser's own prompt names an application rather than a request — it does not show which directory would be opened or what would be typed — and browsers offer to remember the answer, after which it stops appearing. So dcode asks its own question in the terminal it was given: the resolved directory, agent, thread, and full prompt text, with the cancel row preselected, failing closed on a non-interactive terminal or an unavailable picker. There is no flag that skips it. The launch argv holds only-a,-r, and-m, so a link cannot change the session's approval mode, model, or sandbox; everything a link can ask for is something the session still gates on its own terms, including the project-hook, MCP, and extension trust prompts.Registration is per-user and needs no elevation, one backend per desktop stack — an AppleScript applet in
~/ApplicationsdeclaringCFBundleURLTypes, an XDG desktop entry withTerminal=true, theHKCUprotocol key — and it never happens on package install, because claiming a URL scheme changes how the user's browser behaves. Each backend also avoids handing link text to a shell:%uand"%1"expand into an argument vector, and the macOS applet quotes the dcode path withshlex.quotewhen generated and the link with AppleScript's ownquoted form ofat dispatch. Uninstall only removes artifacts dcode created, and refuses a bundle or registry key belonging to something else.macOS resolves an application's AppleScript terminology at build time, so
--terminal itermis checked before the applet is compiled andautodegrades to Terminal.app; otherwise an absent iTerm surfaced as a compiler error.pyproject.tomlgains atyoverride scoped to the Windows-only backend:winregships only on Windows andtyresolves the standard library against the platform it checks on, so every member access there is unresolvable on Linux and macOS runners. It is recorded as a whole-module policy rather than twenty per-line suppressions, since the module is reachable only undersys.platform == "win32".THREAT_MODEL.mdrecords the new boundary (TB15), flow (DF30), and threat (T18) with its controls, and separates the two consequences worth distinguishing: opening an attacker-named directory is the same exposure as running dcode there by hand, while an attacker-written first message is the sharper case.No new dependencies — the whole feature is standard library (
plistlib,configparser,winreg,urllib.parse).Testing
82 unit tests in
tests/unit_tests/test_url_scheme.py; the full package suite passes (7744 passed, 1 skipped). Coverage includes the accepted and refused link shapes, attempts to smuggle session-altering parameters, launcher resolution, the Linux desktop entry and itsmimeapps.listfallback (including leaving a foreign association alone), the macOS applet's quoting and plist, and the handler's fail-closed paths and launch argv.Verified by hand on macOS beyond the suite: the generated applet compiles under
osacompileand round-trips install/status/uninstall against a throwawayHOME; on a real pty the confirmation renders, a bare Enter declines with exit 1, and approving replaces the process with a real dcode session in the same pid.One note for reviewers: the new help screens write
[options]in their usage line, which Rich swallows as markup — the existingdcode tools --helpand friends have the same behavior, so these match the surrounding code rather than fixing it here.