From f484169932385dd2d9f2d846924402b0d5a650f7 Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Sun, 4 Oct 2026 17:02:04 +0800 Subject: [PATCH 1/2] fix(discover): keep HTTPS installers from vendor sites and add clear selection DeviceSafetyFilter dropped every .exe/.msi/.dmg/.pkg/.apk whose host was not on a short built-in list, so official installers hosted on a vendor's own site vanished after the agent accepted them. Such installers are now scored down over HTTPS and blocked only without HTTPS; hosts that imitate a listed domain, and http(s) URLs with user info, are blocked for every file type. The Discover add bar gets a button that clears the whole selection, including results selected in earlier messages. --- .../ketch/ai/agent/DeviceSafetyFilter.kt | 37 ++++++++++++++++++- .../ketch/ai/agent/DeviceSafetyFilterTest.kt | 29 ++++++++++++++- .../ketch/app/state/AiDiscoverController.kt | 5 +++ .../ketch/app/ui/discover/DiscoverFooter.kt | 17 +++++++-- .../app/state/AiDiscoverControllerTest.kt | 17 +++++++++ 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt b/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt index 924025d7..b57fe269 100644 --- a/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt +++ b/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt @@ -56,11 +56,21 @@ internal class DeviceSafetyFilter { reason = "Piracy signal detected", ) } - if (isHighRiskExtension(ext) && !isTrustedDomain(host)) { + if (impersonatesTrustedDomain(host) || hasWebUserInfo(url)) { return SafetyEvaluation( score = 0f, blocked = true, - reason = "High-risk extension .$ext from untrusted source", + reason = "Looks like a trusted domain: $host", + ) + } + // Vendors publish installers on their own sites, which no list covers, so an installer from + // an unlisted host is only scored down; over plain HTTP anyone on the way could swap it. + val untrustedInstaller = isHighRiskExtension(ext) && !isTrustedDomain(host) + if (untrustedInstaller && !lowerUrl.startsWith("https://")) { + return SafetyEvaluation( + score = 0f, + blocked = true, + reason = "High-risk extension .$ext without HTTPS from untrusted source", ) } @@ -68,6 +78,11 @@ internal class DeviceSafetyFilter { var score = BASE_SCORE val notes = mutableListOf() + if (untrustedInstaller) { + score -= UNTRUSTED_INSTALLER_PENALTY + notes.add("High-risk extension .$ext from unlisted domain") + } + if (lowerUrl.startsWith("https://")) { score += 0.1f notes.add("HTTPS") @@ -131,6 +146,21 @@ internal class DeviceSafetyFilter { return TRUSTED_DOMAINS.any { host == it || host.endsWith(".$it") } } + /** Whether [host] holds a trusted domain without being it or under it: `fake-github.com`. */ + private fun impersonatesTrustedDomain(host: String): Boolean { + return TRUSTED_DOMAINS.any { it in host } && !isTrustedDomain(host) + } + + /** Whether the HTTP(S) [url] carries user info, which can pose as the host before an `@`. */ + private fun hasWebUserInfo(url: String): Boolean { + return try { + val uri = URI(url) + uri.scheme?.lowercase() in WEB_SCHEMES && uri.rawUserInfo != null + } catch (_: Exception) { + false + } + } + private fun hasPiracySignal(text: String): Boolean { return PIRACY_SIGNALS.any { text.contains(it) } } @@ -161,6 +191,9 @@ internal class DeviceSafetyFilter { companion object { private const val BASE_SCORE = 0.7f private const val BLOCK_THRESHOLD = 0.3f + private const val UNTRUSTED_INSTALLER_PENALTY = 0.2f + + private val WEB_SCHEMES = setOf("http", "https") private val URL_SHORTENERS = setOf( "bit.ly", "t.co", "tinyurl.com", "goo.gl", "ow.ly", diff --git a/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt b/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt index 487e042d..72651a72 100644 --- a/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt +++ b/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt @@ -51,13 +51,38 @@ class DeviceSafetyFilterTest { } @Test - fun evaluate_highRiskExtFromUntrusted_blocked() { + fun evaluate_highRiskExtFromUnlistedHttps_penalized() { + val unlisted = filter.evaluate( + url = "https://kuromi.drakeet.cn/PureWriter2-4.4.2-macOS-aarch64.dmg", + sourcePageUrl = "https://writer.drakeet.com/desktop2", + ) + val trusted = filter.evaluate( + url = "https://github.com/app/releases/PureWriter2-4.4.2-macOS-aarch64.dmg", + ) + assertFalse(unlisted.blocked) + assertTrue(unlisted.score < trusted.score) + } + + @Test + fun evaluate_highRiskExtFromUnlistedHttp_blocked() { val result = filter.evaluate( - url = "https://random-site.xyz/setup.exe", + url = "http://random-site.xyz/setup.exe", ) assertTrue(result.blocked) } + @Test + fun evaluate_trustedDomainLookalike_blocked() { + val urls = listOf( + "https://fake-github.com/app/releases/v1.0.zip", + "https://github.com.example.net/app/v1.0.zip", + "https://github.com@evil.example/app/v1.0.zip", + ) + for (url in urls) { + assertTrue(filter.evaluate(url = url).blocked, url) + } + } + @Test fun evaluate_blenderInstaller_allowed() { val result = filter.evaluate( diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt index 632ad9b2..eee4a724 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt @@ -379,6 +379,11 @@ class AiDiscoverController( selected = selected + visibleIn(turnId).map { it.url } } + /** Deselects every result of the shown session, earlier turns' included. */ + fun clearSelection() { + selected = emptySet() + } + /** Deselects every result of the shown session's turn with [turnId]. */ fun clearSelection(turnId: String) { selected = selected - visibleIn(turnId).mapTo(mutableSetOf()) { it.url } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt index ef42a59d..9984e271 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt @@ -42,6 +42,7 @@ import ketch.app.shared.generated.resources.device_free_space import ketch.app.shared.generated.resources.discover_add_now import ketch.app.shared.generated.resources.discover_add_options import ketch.app.shared.generated.resources.discover_add_to +import ketch.app.shared.generated.resources.discover_clear_selection import ketch.app.shared.generated.resources.discover_review_add import ketch.app.shared.generated.resources.discover_review_add_count import ketch.app.shared.generated.resources.discover_select_to_add @@ -51,9 +52,10 @@ import kotlinx.coroutines.Job import org.jetbrains.compose.resources.stringResource /** - * The add bar over the composer: how much of [session] is selected, saying how much of it comes - * from messages before the newest, the device it goes to (with two devices or more), Add now, - * and Review & add, which opens the add sheet with the selection. + * The add bar over the composer: a button that clears the selection, how much of [session] is + * selected, saying how much of it comes from messages before the newest, the device it goes to + * (with two devices or more), Add now, and Review & add, which opens the add sheet with the + * selection. * * On a phone it is one row, "2 selected" with Add now and a button for a menu that holds Review * & add and the devices to add to. @@ -106,6 +108,15 @@ internal fun DiscoverAddBar( horizontalArrangement = Arrangement.spacedBy(spacing.s2), modifier = Modifier.fillMaxWidth().heightIn(min = KetchTheme.density.buttonMedium), ) { + if (selected.isNotEmpty()) { + // Earlier messages' results may be far up the chat; this lets go of them all at once. + KetchIconButton( + icon = KetchIcon.Close, + onClick = state.aiDiscover::clearSelection, + enabled = !busy, + contentDescription = stringResource(Res.string.discover_clear_selection), + ) + } if (phone) { // A phone's row leaves the summary little room beside the buttons, so the size and how // many come from earlier go under the count. diff --git a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt index 4085c92a..6f630595 100644 --- a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt @@ -491,6 +491,23 @@ class AiDiscoverControllerTest { assertEquals(listOf(again), controller.selectedCandidates()) } + @Test + fun clearSelection_resultsOfSeveralTurns_deselectsThemAll() = runTest { + val (first, later) = candidate("a.dmg") to candidate("b.dmg") + val saved = savedSession("blender", ListFixtures.START, found = listOf(first)).let { + it.copy(turns = it.turns + it.turns.single().copy(id = "later", candidates = listOf(later))) + } + val history = InMemoryDiscoverHistoryStore(listOf(saved)) + val controller = controller(FakeAiProvider(), history = history) + controller.open(saved.id) + controller.toggle(first) + controller.toggle(later) + + controller.clearSelection() + + assertEquals(emptyList(), controller.selectedCandidates()) + } + @Test fun retry_failedNewestTurn_runsItAgainInPlace() = runTest { val provider = FakeAiProvider(failure = IllegalStateException("Rate limited")) From 9338af725e841e309a8d4e699ecc6e8ca7931fe5 Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Sun, 4 Oct 2026 17:19:22 +0800 Subject: [PATCH 2/2] fix(discover): keep sibling hosts of trusted sites out of the look-alike block A host that contains a trusted domain but sits under the same site, such as archive-downloads.apache.org beside downloads.apache.org, belongs to the vendor, so it is no longer blocked as an impersonation. --- .../ketch/ai/agent/DeviceSafetyFilter.kt | 19 +++++++++++++++++-- .../ketch/ai/agent/DeviceSafetyFilterTest.kt | 13 ++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt b/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt index b57fe269..1dbb21f9 100644 --- a/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt +++ b/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt @@ -146,9 +146,24 @@ internal class DeviceSafetyFilter { return TRUSTED_DOMAINS.any { host == it || host.endsWith(".$it") } } - /** Whether [host] holds a trusted domain without being it or under it: `fake-github.com`. */ + /** + * Whether [host] holds a trusted domain but lies outside the site that registered it, such as + * `fake-github.com` or `github.com.example.net`. Other hosts of that site, such as + * `archive-downloads.apache.org` beside `downloads.apache.org`, are the vendor's own. + */ private fun impersonatesTrustedDomain(host: String): Boolean { - return TRUSTED_DOMAINS.any { it in host } && !isTrustedDomain(host) + return TRUSTED_DOMAINS.any { trusted -> + trusted in host && !isUnder(host, registrableDomain(trusted)) + } + } + + private fun isUnder(host: String, domain: String): Boolean { + return host == domain || host.endsWith(".$domain") + } + + /** The site [domain] belongs to: its last two labels, as every trusted domain is a gTLD's. */ + private fun registrableDomain(domain: String): String { + return domain.split('.').takeLast(2).joinToString(".") } /** Whether the HTTP(S) [url] carries user info, which can pose as the host before an `@`. */ diff --git a/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt b/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt index 72651a72..b48286a4 100644 --- a/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt +++ b/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt @@ -96,7 +96,7 @@ class DeviceSafetyFilterTest { fun evaluate_blenderLookalikes_blocked() { val urls = listOf( "https://download.blender.org.example.com/blender.dmg", - "https://fake-download.blender.org/blender.dmg", + "https://download.blender.org-mirror.example/blender.dmg", "https://download.blender.org@evil.example/blender.dmg" ) for (url in urls) { @@ -109,6 +109,17 @@ class DeviceSafetyFilterTest { } } + @Test + fun evaluate_siblingHostOfATrustedSite_allowed() { + val urls = listOf( + "https://archive-downloads.apache.org/dist/app-1.0.zip", + "https://fake-download.blender.org/blender.dmg", + ) + for (url in urls) { + assertFalse(filter.evaluate(url = url).blocked, url) + } + } + @Test fun evaluate_piracySignalInContext_blocked() { val result = filter.evaluate(