feat(research): WebFetchTool — bounded network fetch → extract (research-workflow §3)
The first tool that punches through the sandbox to the network. T2, declares NETWORK_ACCESS with url marked NETWORK_TARGET, so egress is enforced by the harness (NetworkHostRule, plane 2) — not tool courtesy. Header-first guards (§3): content-type checked before the body is read (binary/media rejected at the header via the extractor's shared content-type policy); declared Content-Length capped; the read itself is bounded so a server lying about length can't blow the cap (default 10 MB). On success returns the extracted markdown plus metadata for citation lineage + replay: url, content_sha256 (over the raw bytes), fetched_bytes, extractor_version, quality. Consumes slice-1's HtmlMarkdownExtractor. Tested with Ktor MockEngine. NOT yet registered in the live tool registry: with an empty networkAllowedHosts the host rule allows everything, so exposing a fetch tool before the egress allowlist (SearXNG endpoint + approved-source hosts, per-session) is wired would be an open-egress hole. Registration + egress config land with the research workflow slice.
This commit is contained in:
@@ -4,9 +4,19 @@ plugins {
|
|||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext {
|
||||||
|
ktor_version = '3.0.3'
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(":core:events")
|
implementation project(":core:events")
|
||||||
|
implementation project(":core:tools")
|
||||||
|
implementation project(":core:approvals")
|
||||||
implementation 'org.jsoup:jsoup:1.20.1'
|
implementation 'org.jsoup:jsoup:1.20.1'
|
||||||
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
|
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||||
|
|
||||||
|
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure deterministic extraction utility; coverage gate is enforced via its own thorough
|
// Pure deterministic extraction utility; coverage gate is enforced via its own thorough
|
||||||
|
|||||||
+7
@@ -38,6 +38,13 @@ class HtmlMarkdownExtractor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a body of this content-type can be extracted at all — the header check a fetcher runs
|
||||||
|
* before downloading the body (§3: "binary/media rejected at the header"). Single source of
|
||||||
|
* truth for the content-type policy, shared with the fetch tool.
|
||||||
|
*/
|
||||||
|
fun supports(contentType: String?): Boolean = classify(contentType) != ContentKind.UNSUPPORTED
|
||||||
|
|
||||||
private fun classify(contentType: String?): ContentKind {
|
private fun classify(contentType: String?): ContentKind {
|
||||||
val type = contentType?.substringBefore(';')?.trim()?.lowercase()
|
val type = contentType?.substringBefore(';')?.trim()?.lowercase()
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package com.correx.infrastructure.research.web
|
||||||
|
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.contract.ParamRole
|
||||||
|
import com.correx.core.tools.contract.Tool
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import com.correx.core.tools.contract.ToolExecutor
|
||||||
|
import com.correx.core.tools.contract.ToolResult
|
||||||
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
|
import com.correx.infrastructure.research.extract.ExtractionQuality
|
||||||
|
import com.correx.infrastructure.research.extract.HtmlMarkdownExtractor
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.utils.io.readAvailable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches an external URL and returns its main content as clean markdown
|
||||||
|
* (research-workflow-spec §3/§5). The first tool that punches through the sandbox to the network,
|
||||||
|
* so it is [Tier.T2] and declares [ToolCapability.NETWORK_ACCESS] with the URL marked
|
||||||
|
* [ParamRole.NETWORK_TARGET] — egress is enforced by the harness ([NetworkHostRule], plane 2),
|
||||||
|
* not by this tool's courtesy.
|
||||||
|
*
|
||||||
|
* Header-first guards (§3): content-type is checked before the body is read (binary/media rejected
|
||||||
|
* at the header) and the declared size is capped; the read itself is bounded so a server that lies
|
||||||
|
* about Content-Length cannot blow the cap. Extraction (§5) runs before anything leaves the tool;
|
||||||
|
* the result carries the content hash + pinned extractor version for citation lineage and replay.
|
||||||
|
*/
|
||||||
|
class WebFetchTool(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val extractor: HtmlMarkdownExtractor = HtmlMarkdownExtractor(),
|
||||||
|
private val maxBytes: Long = DEFAULT_MAX_BYTES,
|
||||||
|
) : Tool, ToolExecutor {
|
||||||
|
|
||||||
|
override val name: String = "web_fetch"
|
||||||
|
override val description: String = "Fetch a URL and return its main content as clean markdown."
|
||||||
|
override val tier: Tier = Tier.T2
|
||||||
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS)
|
||||||
|
override val paramRoles: Map<String, ParamRole> = mapOf("url" to ParamRole.NETWORK_TARGET)
|
||||||
|
override val parametersSchema: JsonObject = buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
putJsonObject("properties") {
|
||||||
|
putJsonObject("url") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "Absolute http(s) URL to fetch.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put("required", buildJsonArray { add(JsonPrimitive("url")) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||||
|
val url = request.parameters["url"] as? String
|
||||||
|
return when {
|
||||||
|
url.isNullOrBlank() -> ValidationResult.Invalid("Missing 'url' parameter.")
|
||||||
|
!isHttpUrl(url) -> ValidationResult.Invalid("Only http(s) URLs are supported: $url")
|
||||||
|
else -> ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||||
|
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||||
|
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||||
|
val url = request.parameters["url"] as String
|
||||||
|
return runCatching { fetch(request.invocationId, url) }
|
||||||
|
.getOrElse { ToolResult.Failure(request.invocationId, "fetch failed: ${it.message}", recoverable = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetch(invocationId: ToolInvocationId, url: String): ToolResult =
|
||||||
|
httpClient.prepareGet(url).execute { response ->
|
||||||
|
val contentType = response.headers[HttpHeaders.ContentType]
|
||||||
|
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
|
||||||
|
when {
|
||||||
|
!response.status.isSuccess() ->
|
||||||
|
fail(invocationId, "HTTP ${response.status.value} for $url", recoverable = true)
|
||||||
|
declaredLength != null && declaredLength > maxBytes ->
|
||||||
|
fail(invocationId, "content too large: $declaredLength bytes (cap $maxBytes)")
|
||||||
|
!extractor.supports(contentType) ->
|
||||||
|
fail(invocationId, "unsupported content-type: ${contentType ?: "unknown"}")
|
||||||
|
else -> readAndExtract(invocationId, url, contentType, response.bodyAsChannel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readAndExtract(
|
||||||
|
invocationId: ToolInvocationId,
|
||||||
|
url: String,
|
||||||
|
contentType: String?,
|
||||||
|
channel: io.ktor.utils.io.ByteReadChannel,
|
||||||
|
): ToolResult {
|
||||||
|
val bytes = readBounded(channel)
|
||||||
|
?: return fail(invocationId, "content exceeded cap of $maxBytes bytes")
|
||||||
|
val result = extractor.extract(bytes.decodeToString(), contentType)
|
||||||
|
return if (result.quality == ExtractionQuality.REJECTED) {
|
||||||
|
fail(invocationId, result.reason ?: "rejected content")
|
||||||
|
} else {
|
||||||
|
ToolResult.Success(
|
||||||
|
invocationId = invocationId,
|
||||||
|
output = result.markdown,
|
||||||
|
metadata = mapOf(
|
||||||
|
"url" to url,
|
||||||
|
"content_type" to (contentType ?: ""),
|
||||||
|
"content_sha256" to sha256(bytes),
|
||||||
|
"fetched_bytes" to bytes.size.toString(),
|
||||||
|
"extractor_version" to result.extractorVersion,
|
||||||
|
"quality" to result.quality.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(invocationId: ToolInvocationId, reason: String, recoverable: Boolean = false): ToolResult.Failure =
|
||||||
|
ToolResult.Failure(invocationId, reason, recoverable)
|
||||||
|
|
||||||
|
/** Reads the channel up to [maxBytes]; returns null if the body exceeds the cap. */
|
||||||
|
private suspend fun readBounded(channel: io.ktor.utils.io.ByteReadChannel): ByteArray? {
|
||||||
|
val out = ArrayList<Byte>()
|
||||||
|
val buffer = ByteArray(READ_CHUNK)
|
||||||
|
while (true) {
|
||||||
|
val read = channel.readAvailable(buffer, 0, buffer.size)
|
||||||
|
if (read == -1) break
|
||||||
|
if (out.size + read > maxBytes) return null
|
||||||
|
for (i in 0 until read) out.add(buffer[i])
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isHttpUrl(url: String): Boolean =
|
||||||
|
runCatching { java.net.URI(url).scheme?.lowercase() in setOf("http", "https") }.getOrDefault(false)
|
||||||
|
|
||||||
|
private fun sha256(bytes: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake
|
||||||
|
private const val READ_CHUNK = 8_192
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user