From 862fed158fab4b2618da51d049709577841942e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 11:50:52 -0300 Subject: [PATCH 1/9] fix(cli): normalize IDN hostnames to punycode in permission dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent homograph attacks where visually identical Unicode characters (e.g. Cyrillic а vs Latin a) could trick users into approving malicious URLs in bash and webfetch permission prompts. --- .../src/components/chat/PermissionDock.tsx | 10 ++++++++- .../cli/cmd/tui/routes/session/permission.tsx | 16 ++++++++++++-- packages/opencode/src/tool/bash.ts | 15 ++++++++++++- packages/opencode/src/tool/webfetch.ts | 21 ++++++++++++++----- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 9e9da21929e..a89716c80cb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -42,7 +42,15 @@ export const PermissionDock: Component<{ const label = (rule: string) => (rule === "*" ? "" : rule.replace(/ \*$/, "")) const command = () => { const cmd = props.request.args?.command - return typeof cmd === "string" ? cmd : undefined + if (typeof cmd !== "string") return undefined + // Normalize IDN/Unicode hostnames to punycode ASCII to prevent homograph attacks. + return cmd.replace(/https?:\/\/\S+/g, (match) => { + try { + return new URL(match).href + } catch { + return match + } + }) } const description = createMemo(() => command() ? null : describePatterns(props.request.toolName, props.request.patterns, language.t), diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index be34dd498ad..9e7ed5b6c49 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -22,6 +22,18 @@ import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocod type PermissionStage = "permission" | "always" | "reject" +// Convert IDN/Unicode hostnames to punycode ASCII in any http/https URLs found +// in the given string, to prevent homograph attacks in permission dialogs. +function normalizeUrls(text: string) { + return text.replace(/https?:\/\/\S+/g, (match) => { + try { + return new URL(match).href + } catch { + return match + } + }) +} + function normalizePath(input?: string) { if (!input) return "" @@ -294,7 +306,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { if (permission === "bash") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" - const command = typeof data.command === "string" ? data.command : "" + const command = normalizeUrls(typeof data.command === "string" ? data.command : "") return { icon: "#", title, @@ -325,7 +337,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } if (permission === "webfetch") { - const url = typeof data.url === "string" ? data.url : "" + const url = normalizeUrls(typeof data.url === "string" ? data.url : "") return { icon: "%", title: `WebFetch ${url}`, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f29230548d1..e8750312a5b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -238,6 +238,19 @@ function preview(text: string) { return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) } +// Normalize any http/https URLs in a command string so that IDN/Unicode hostnames +// are converted to their punycode ASCII form, preventing homograph attacks in +// permission dialogs where "аpitest.com" (Cyrillic) looks identical to "apitest.com". +function normalizeUrls(text: string) { + return text.replace(/https?:\/\/\S+/g, (match) => { + try { + return new URL(match).href + } catch { + return match + } + }) +} + function tail(text: string, maxLines: number, maxBytes: number) { const lines = text.split("\n") if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) { @@ -296,7 +309,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan, permission: "bash", patterns: Array.from(scan.patterns), always: Array.from(scan.always), - metadata: { command }, // kilocode_change + metadata: { command: normalizeUrls(command) }, // kilocode_change }) }) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 71399f1577f..cde4bab8564 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -34,12 +34,23 @@ export const WebFetchTool = Tool.define( throw new Error("URL must start with http:// or https://") } + // Normalize URL: convert IDN/Unicode hostnames to punycode ASCII to prevent + // homograph attacks where visually similar Unicode characters impersonate trusted domains. + // e.g. "аpitest.com" (Cyrillic а) → "xn--pitest-n5b.com" + const url = (() => { + try { + return new URL(params.url).href + } catch { + return params.url + } + })() + yield* ctx.ask({ permission: "webfetch", - patterns: [params.url], + patterns: [url], always: ["*"], metadata: { - url: params.url, + url, format: params.format, timeout: params.timeout, }, @@ -71,7 +82,7 @@ export const WebFetchTool = Tool.define( "Accept-Language": "en-US,en;q=0.9", } - const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers)) + const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers)) // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) const response = yield* httpOk.execute(request).pipe( @@ -82,7 +93,7 @@ export const WebFetchTool = Tool.define( err.reason.response.headers["cf-mitigated"] === "challenge", () => httpOk.execute( - HttpClientRequest.get(params.url).pipe( + HttpClientRequest.get(url).pipe( HttpClientRequest.setHeaders({ ...headers, "User-Agent": "kilo" }), // kilocode_change ), ), @@ -103,7 +114,7 @@ export const WebFetchTool = Tool.define( const contentType = response.headers["content-type"] || "" const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" - const title = `${params.url} (${contentType})` + const title = `${url} (${contentType})` if (isImageAttachment(mime)) { const base64Content = Buffer.from(arrayBuffer).toString("base64") From 9fe37aeb39bcb1e1f0c50732a742e4bd1e0b63ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 11:57:12 -0300 Subject: [PATCH 2/9] refactor: extract normalizeUrls into src/kilocode/util/url.ts Move Kilo-specific URL normalization logic out of shared upstream files and into a mirror file under src/kilocode/, with kilocode_change markers at each call site per the fork merge minimization convention. --- .../cli/cmd/tui/routes/session/permission.tsx | 13 +------------ packages/opencode/src/kilocode/util/url.ts | 18 ++++++++++++++++++ packages/opencode/src/tool/bash.ts | 13 +------------ packages/opencode/src/tool/webfetch.ts | 12 ++---------- 4 files changed, 22 insertions(+), 34 deletions(-) create mode 100644 packages/opencode/src/kilocode/util/url.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9e7ed5b6c49..22febf78e46 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -19,21 +19,10 @@ import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change +import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change type PermissionStage = "permission" | "always" | "reject" -// Convert IDN/Unicode hostnames to punycode ASCII in any http/https URLs found -// in the given string, to prevent homograph attacks in permission dialogs. -function normalizeUrls(text: string) { - return text.replace(/https?:\/\/\S+/g, (match) => { - try { - return new URL(match).href - } catch { - return match - } - }) -} - function normalizePath(input?: string) { if (!input) return "" diff --git a/packages/opencode/src/kilocode/util/url.ts b/packages/opencode/src/kilocode/util/url.ts new file mode 100644 index 00000000000..c9e152c374e --- /dev/null +++ b/packages/opencode/src/kilocode/util/url.ts @@ -0,0 +1,18 @@ +/** + * Normalize any http/https URLs in a string so that IDN/Unicode hostnames are + * converted to their punycode ASCII form, preventing homograph attacks in + * permission dialogs where visually identical Unicode characters (e.g. Cyrillic + * 'а' U+0430) could impersonate trusted domains (e.g. 'apitest.com'). + * + * Example: "curl https://аpitest.com/status" (Cyrillic а) + * → "curl https://xn--pitest-2nf.com/status" + */ +export function normalizeUrls(text: string) { + return text.replace(/https?:\/\/\S+/g, (match) => { + try { + return new URL(match).href + } catch { + return match + } + }) +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e8750312a5b..df171c24ad6 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -20,6 +20,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" +import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" @@ -238,18 +239,6 @@ function preview(text: string) { return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) } -// Normalize any http/https URLs in a command string so that IDN/Unicode hostnames -// are converted to their punycode ASCII form, preventing homograph attacks in -// permission dialogs where "аpitest.com" (Cyrillic) looks identical to "apitest.com". -function normalizeUrls(text: string) { - return text.replace(/https?:\/\/\S+/g, (match) => { - try { - return new URL(match).href - } catch { - return match - } - }) -} function tail(text: string, maxLines: number, maxBytes: number) { const lines = text.split("\n") diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index cde4bab8564..ec4172d8256 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -4,6 +4,7 @@ import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" import { isImageAttachment } from "@/util/media" +import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -34,16 +35,7 @@ export const WebFetchTool = Tool.define( throw new Error("URL must start with http:// or https://") } - // Normalize URL: convert IDN/Unicode hostnames to punycode ASCII to prevent - // homograph attacks where visually similar Unicode characters impersonate trusted domains. - // e.g. "аpitest.com" (Cyrillic а) → "xn--pitest-n5b.com" - const url = (() => { - try { - return new URL(params.url).href - } catch { - return params.url - } - })() + const url = normalizeUrls(params.url) // kilocode_change yield* ctx.ask({ permission: "webfetch", From da92a30007804ec94595bdff23f3bd2b14655729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 12:07:48 -0300 Subject: [PATCH 3/9] fix: add missing kilocode_change markers to all annotated lines --- .../src/cli/cmd/tui/routes/session/permission.tsx | 4 ++-- packages/opencode/src/tool/webfetch.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 22febf78e46..db2997bebc0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -295,7 +295,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { if (permission === "bash") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" - const command = normalizeUrls(typeof data.command === "string" ? data.command : "") + const command = normalizeUrls(typeof data.command === "string" ? data.command : "") // kilocode_change return { icon: "#", title, @@ -326,7 +326,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } if (permission === "webfetch") { - const url = normalizeUrls(typeof data.url === "string" ? data.url : "") + const url = normalizeUrls(typeof data.url === "string" ? data.url : "") // kilocode_change return { icon: "%", title: `WebFetch ${url}`, diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index ec4172d8256..54ed6c407a0 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -39,10 +39,10 @@ export const WebFetchTool = Tool.define( yield* ctx.ask({ permission: "webfetch", - patterns: [url], + patterns: [url], // kilocode_change always: ["*"], metadata: { - url, + url, // kilocode_change format: params.format, timeout: params.timeout, }, @@ -74,7 +74,7 @@ export const WebFetchTool = Tool.define( "Accept-Language": "en-US,en;q=0.9", } - const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers)) + const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers)) // kilocode_change // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) const response = yield* httpOk.execute(request).pipe( @@ -85,7 +85,7 @@ export const WebFetchTool = Tool.define( err.reason.response.headers["cf-mitigated"] === "challenge", () => httpOk.execute( - HttpClientRequest.get(url).pipe( + HttpClientRequest.get(url).pipe( // kilocode_change HttpClientRequest.setHeaders({ ...headers, "User-Agent": "kilo" }), // kilocode_change ), ), @@ -106,7 +106,7 @@ export const WebFetchTool = Tool.define( const contentType = response.headers["content-type"] || "" const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" - const title = `${url} (${contentType})` + const title = `${url} (${contentType})` // kilocode_change if (isImageAttachment(mime)) { const base64Content = Buffer.from(arrayBuffer).toString("base64") From ab8af8009f265add430c473d761ba284199a1585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 12:10:38 -0300 Subject: [PATCH 4/9] update source links --- packages/kilo-docs/source-links.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6a04ffae85b..760daebe32e 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -177,3 +177,7 @@ - +- + +- + From 9c572ccbb278d16c2595a1a406c5f8dffd5f0674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 12:15:16 -0300 Subject: [PATCH 5/9] chore(kilo-docs): exclude example punycode domain from link checker --- packages/kilo-docs/lychee.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index ef52759b953..5d15d940df1 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -43,6 +43,8 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # Example punycode domain used in homograph attack documentation — does not exist + '^https?://xn--pitest', # OpenAI docs return 404 to plain GET link checks but resolve in browsers '^https?://platform\.openai\.com/docs/api-reference/responses/create', ] From 9064478949bbaa193be3845abee8a30b853012e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 12:55:08 -0300 Subject: [PATCH 6/9] test(cli): add unit tests for normalizeUrls homograph protection --- .../opencode/test/kilocode/util/url.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/opencode/test/kilocode/util/url.test.ts diff --git a/packages/opencode/test/kilocode/util/url.test.ts b/packages/opencode/test/kilocode/util/url.test.ts new file mode 100644 index 00000000000..40a28dc6c27 --- /dev/null +++ b/packages/opencode/test/kilocode/util/url.test.ts @@ -0,0 +1,109 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { normalizeUrls } from "../../../src/kilocode/util/url" + +describe("normalizeUrls", () => { + describe("homograph / IDN conversion", () => { + test("converts Cyrillic look-alike in hostname to punycode", () => { + // Cyrillic 'а' (U+0430) is visually identical to Latin 'a' + const input = "https://\u0430pitest.com/status" + const result = normalizeUrls(input) + expect(result).toBe("https://xn--pitest-2nf.com/status") + expect(result).not.toContain("\u0430") + }) + + test("converts mixed-script hostname to punycode", () => { + // Mix of Latin and Cyrillic in the same label + const input = "https://\u0430pitest.com" + expect(normalizeUrls(input)).not.toContain("\u0430") + }) + + test("converts fully unicode TLD to punycode", () => { + const input = "https://example.\u4e2d\u56fd" + const result = normalizeUrls(input) + expect(result).toMatch(/^https:\/\/example\.xn--/) + }) + + test("handles http scheme as well as https", () => { + const input = "http://\u0430pitest.com/path" + const result = normalizeUrls(input) + expect(result).not.toContain("\u0430") + expect(result).toMatch(/^http:\/\/xn--/) + }) + }) + + describe("plain ASCII URLs are unchanged", () => { + test("leaves a clean https URL untouched", () => { + const url = "https://apitest.com/status" + expect(normalizeUrls(url)).toBe(url) + }) + + test("leaves a clean http URL untouched", () => { + const url = "http://example.com/foo?bar=1&baz=2" + expect(normalizeUrls(url)).toBe(url) + }) + + test("leaves localhost URL untouched", () => { + const url = "http://localhost:3000/api" + expect(normalizeUrls(url)).toBe(url) + }) + }) + + describe("URL embedded in a bash command string", () => { + test("normalizes the URL portion of a curl command", () => { + const input = "curl https://\u0430pitest.com/status" + const result = normalizeUrls(input) + expect(result).toMatch(/^curl https:\/\/xn--/) + expect(result).not.toContain("\u0430") + }) + + test("preserves the non-URL parts of the command", () => { + const input = "curl -sSf https://\u0430pitest.com/status | bash" + const result = normalizeUrls(input) + expect(result).toMatch(/^curl -sSf /) + expect(result).toContain("| bash") + }) + + test("normalizes multiple URLs in a single command", () => { + const input = "curl https://\u0430pitest.com/a && curl https://\u0430pitest.com/b" + const result = normalizeUrls(input) + expect(result.match(/xn--/g)?.length).toBe(2) + expect(result).not.toContain("\u0430") + }) + + test("leaves plain-ASCII command entirely unchanged", () => { + const input = "curl -sSf https://kilo.ai/update.sh | bash" + expect(normalizeUrls(input)).toBe(input) + }) + }) + + describe("edge cases", () => { + test("returns empty string unchanged", () => { + expect(normalizeUrls("")).toBe("") + }) + + test("returns text with no URLs unchanged", () => { + const text = "just some plain text without links" + expect(normalizeUrls(text)).toBe(text) + }) + + test("does not alter non-http/https schemes", () => { + const text = "ftp://example.com and file:///tmp/foo" + expect(normalizeUrls(text)).toBe(text) + }) + + test("handles a URL with a path, query, and fragment", () => { + const input = "https://\u0430pitest.com/path?q=1#anchor" + const result = normalizeUrls(input) + expect(result).toMatch(/xn--/) + expect(result).toContain("/path?q=1#anchor") + }) + + test("preserves a URL that fails to parse (e.g. malformed) verbatim", () => { + // A URL that new URL() cannot parse should pass through untouched. + const malformed = "https://[unclosed" + const result = normalizeUrls(malformed) + expect(result).toBe(malformed) + }) + }) +}) From a0dcf76177cd48a72a5167072fddecafdd9d1b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 13:18:49 -0300 Subject: [PATCH 7/9] fix(cli): strip trailing sentence punctuation from URL matches in normalizeUrls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \S+ was greedily consuming trailing punctuation (. ! , ;) into the URL match, causing new URL() to mutate clean ASCII URLs — e.g. "see https://example.com." became "see https://example.com./". Fix: strip trailing sentence punctuation before parsing, extract and replace only the hostname (not href) to avoid adding trailing slashes to bare origins, and restore the stripped tail afterward. --- packages/opencode/src/kilocode/util/url.ts | 22 ++++++- .../opencode/test/kilocode/util/url.test.ts | 57 +++++++++++++++---- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/kilocode/util/url.ts b/packages/opencode/src/kilocode/util/url.ts index c9e152c374e..f6e3c72e3b8 100644 --- a/packages/opencode/src/kilocode/util/url.ts +++ b/packages/opencode/src/kilocode/util/url.ts @@ -6,11 +6,31 @@ * * Example: "curl https://аpitest.com/status" (Cyrillic а) * → "curl https://xn--pitest-2nf.com/status" + * + * Trailing sentence punctuation (. , ! ? ; :) that \S+ would otherwise consume + * into the URL match is stripped before parsing and left in place afterward, so + * plain-text prose like "see https://example.com." is returned unchanged. + * + * Only the hostname is replaced, not the full href, to avoid side-effects such + * as new URL() appending a trailing slash to bare origins. */ export function normalizeUrls(text: string) { return text.replace(/https?:\/\/\S+/g, (match) => { + // Strip trailing sentence punctuation that \S+ greedily consumes but that + // is almost certainly not part of the URL (e.g. "visit https://x.com."). + const stripped = match.replace(/[.,!?;:)"'\]>]+$/, "") + const tail = match.slice(stripped.length) try { - return new URL(match).href + const parsed = new URL(stripped) + // Extract the raw hostname from the stripped string so we can replace + // only that part — using href would add a trailing slash to bare origins. + const afterScheme = stripped.indexOf("//") + 2 + const slashPos = stripped.indexOf("/", afterScheme) + const rawHost = slashPos === -1 ? stripped.slice(afterScheme) : stripped.slice(afterScheme, slashPos) + const colon = rawHost.indexOf(":") + const rawHostname = colon === -1 ? rawHost : rawHost.slice(0, colon) + if (rawHostname === parsed.hostname) return match // plain ASCII — nothing to change + return stripped.replace(rawHostname, parsed.hostname) + tail } catch { return match } diff --git a/packages/opencode/test/kilocode/util/url.test.ts b/packages/opencode/test/kilocode/util/url.test.ts index 40a28dc6c27..876ce571bcc 100644 --- a/packages/opencode/test/kilocode/util/url.test.ts +++ b/packages/opencode/test/kilocode/util/url.test.ts @@ -14,7 +14,7 @@ describe("normalizeUrls", () => { test("converts mixed-script hostname to punycode", () => { // Mix of Latin and Cyrillic in the same label - const input = "https://\u0430pitest.com" + const input = "https://\u0430pitest.com/path" expect(normalizeUrls(input)).not.toContain("\u0430") }) @@ -33,20 +33,57 @@ describe("normalizeUrls", () => { }) describe("plain ASCII URLs are unchanged", () => { - test("leaves a clean https URL untouched", () => { + test("leaves a URL with a path untouched", () => { const url = "https://apitest.com/status" expect(normalizeUrls(url)).toBe(url) }) - test("leaves a clean http URL untouched", () => { + test("leaves a URL with path and query string untouched", () => { const url = "http://example.com/foo?bar=1&baz=2" expect(normalizeUrls(url)).toBe(url) }) - test("leaves localhost URL untouched", () => { + test("leaves a localhost URL with port untouched", () => { const url = "http://localhost:3000/api" expect(normalizeUrls(url)).toBe(url) }) + + test("leaves a bare origin untouched (no trailing slash added)", () => { + // Regression: new URL("https://example.com").href === "https://example.com/" + // The old implementation using href would mutate bare origins by adding "/". + const url = "https://example.com" + expect(normalizeUrls(url)).toBe(url) + }) + }) + + describe("trailing sentence punctuation is not consumed into the URL", () => { + test("period at end of sentence is not consumed (was: adds trailing slash)", () => { + // "see https://example.com." — the period ends the sentence, not the URL. + // Old behaviour (bug): returned "see https://example.com./" + expect(normalizeUrls("see https://example.com.")).toBe("see https://example.com.") + }) + + test("exclamation mark at end of sentence is not consumed", () => { + expect(normalizeUrls("visit https://example.com!")).toBe("visit https://example.com!") + }) + + test("comma after URL in a list is not consumed", () => { + expect(normalizeUrls("check https://example.com, then continue")).toBe( + "check https://example.com, then continue", + ) + }) + + test("closing parenthesis after URL is not consumed", () => { + expect(normalizeUrls("(see https://example.com)")).toBe("(see https://example.com)") + }) + + test("trailing punctuation after an IDN URL is stripped correctly and punycode applied", () => { + // Trailing period on a homograph URL: period is sentence punctuation, not part of the URL. + const input = "see https://\u0430pitest.com." + const result = normalizeUrls(input) + expect(result).toBe("see https://xn--pitest-2nf.com.") + expect(result).not.toContain("\u0430") + }) }) describe("URL embedded in a bash command string", () => { @@ -57,7 +94,7 @@ describe("normalizeUrls", () => { expect(result).not.toContain("\u0430") }) - test("preserves the non-URL parts of the command", () => { + test("preserves flags and pipe around the URL", () => { const input = "curl -sSf https://\u0430pitest.com/status | bash" const result = normalizeUrls(input) expect(result).toMatch(/^curl -sSf /) @@ -71,7 +108,7 @@ describe("normalizeUrls", () => { expect(result).not.toContain("\u0430") }) - test("leaves plain-ASCII command entirely unchanged", () => { + test("leaves a plain-ASCII command entirely unchanged", () => { const input = "curl -sSf https://kilo.ai/update.sh | bash" expect(normalizeUrls(input)).toBe(input) }) @@ -92,18 +129,16 @@ describe("normalizeUrls", () => { expect(normalizeUrls(text)).toBe(text) }) - test("handles a URL with a path, query, and fragment", () => { + test("preserves path, query string, and fragment after IDN conversion", () => { const input = "https://\u0430pitest.com/path?q=1#anchor" const result = normalizeUrls(input) expect(result).toMatch(/xn--/) expect(result).toContain("/path?q=1#anchor") }) - test("preserves a URL that fails to parse (e.g. malformed) verbatim", () => { - // A URL that new URL() cannot parse should pass through untouched. + test("preserves a URL that fails to parse verbatim", () => { const malformed = "https://[unclosed" - const result = normalizeUrls(malformed) - expect(result).toBe(malformed) + expect(normalizeUrls(malformed)).toBe(malformed) }) }) }) From 0200183174be9eb1195437d7c2434427788e81ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 13:27:26 -0300 Subject: [PATCH 8/9] refactor(vscode): import normalizeUrls from shared kilocode util in PermissionDock --- .../webview-ui/src/components/chat/PermissionDock.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index a89716c80cb..66f05f92503 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -21,6 +21,7 @@ import { describePatterns, resolveLabel, savedRuleStates, type RuleDecision } fr import { PermissionCommand } from "./PermissionCommand" import { PermissionDiff } from "./PermissionDiff" import { permissionDiffs } from "./permission-diff-utils" +import { normalizeUrls } from "../../../../../opencode/src/kilocode/util/url" import type { PermissionRequest } from "../../types/messages" let rulesExpandedPreference = false @@ -44,13 +45,7 @@ export const PermissionDock: Component<{ const cmd = props.request.args?.command if (typeof cmd !== "string") return undefined // Normalize IDN/Unicode hostnames to punycode ASCII to prevent homograph attacks. - return cmd.replace(/https?:\/\/\S+/g, (match) => { - try { - return new URL(match).href - } catch { - return match - } - }) + return normalizeUrls(cmd) } const description = createMemo(() => command() ? null : describePatterns(props.request.toolName, props.request.patterns, language.t), From 6308e7d548a4de4880ca15cfd3dd68bd5ce83688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 11 May 2026 13:29:22 -0300 Subject: [PATCH 9/9] update source links --- packages/kilo-docs/source-links.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 760daebe32e..d4c3079937a 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -177,6 +177,8 @@ - +- + - -