feat(tools): web_fetch builtin — fetch a URL as clean, LLM-readable content (#266)#307
Conversation
…loses #266) Agents had web_search (results, not contents) and http_request (raw response), but no 'read this page' tool — so reading a linked spec / changelog / API doc meant fetching raw HTML via http_request and hand-parsing it (expensive, unreliable). Add web_fetch: - Read-only GET over the SAME egress-controlled path as http_request (context egress transport + SafeRedirectPolicy + optional R9 JIT credential injector) — no new network path, honors the allowlist. - HTML -> clean text/markdown via golang.org/x/net/html: drops script/style/nav/footer/aside chrome, keeps title/headings (# ), list items (- ), and links (text (href)). Non-HTML textual payloads (text/*, json, xml) pass through as-is. - Content-type guard: refuses binary (image/pdf/octet-stream/...) rather than streaming a blob into context. Redirect cap (5) + byte cap (2/8MB relaxed) + max_chars cap (default 50k) with a truncation marker. - Returns {url (final, post-redirect), content_type, status, content, truncated?}. Registered default-on in builtins.All() (wired to the same HTTPCredentialInjector as http_request). Added to the Skill Builder prompt (required by the ListsEveryDefaultBuiltin drift-pin). Tests: HTML extraction (chrome stripped, structure kept), truncation + marker, plain-text passthrough, binary content-type rejection, egress transport routing (denying transport fails the fetch), malformed-HTML resilience. Also fixes doc-sync debt: .claude/skills/forge-skill-builder.md had drifted several prompt rewrites behind skillBuilderPromptBase (#252/#270/ #297 never re-ported — it was missing the whole Built-in Tools section). Re-ported the current prompt verbatim and added TestForgeSkillBuilderMD_MirrorsPrompt so the port can never silently rot again. golang.org/x/net promoted from indirect to direct (already in the module graph; no new external dependency).
initializ-mk
left a comment
There was a problem hiding this comment.
Review: web_fetch builtin
The architecture is right: it reuses http_request's exact egress plumbing — EgressTransportFromContext + SafeRedirectPolicy + the R9 JIT credential injector — rather than opening a second network path, so the allowlist/SSRF protections and per-domain credentials apply identically (verified against http_request on main — same Transport: EgressTransportFromContext(ctx) pattern). HTML→text via x/net/html adds no new external dependency, and the forge-plugins-is-the-wrong-direction reasoning is sound. Tests cover extraction, truncation, passthrough, binary rejection, egress routing, malformed HTML. CI fully green.
Two Medium findings + two minors inline, plus a standout bonus:
The doc-drift fix is the highlight. Finding that .claude/skills/forge-skill-builder.md had drifted several prompt rewrites behind skillBuilderPromptBase (missing the entire "Built-in Tools" section from #252/#270/#297), re-porting it, and adding TestForgeSkillBuilderMD_MirrorsPrompt to pin the md to the Go constant — same anti-rot pin class as #297's builtin-list drift test, now applied to the skill-builder mirror. Real latent debt paid down. And the #297 ListsEveryDefaultBuiltin pin automatically enforces web_fetch is advertised in the prompt — the earlier test paying off as intended.
Nits (no inline anchor)
- Non-2xx responses return the error page's body as
contentwithstatusset — defensible and consistent withhttp_request, but a one-line doc note ("a 404 yields the 404 page's text") sets expectations. - Only
http/httpshrefs are emitted astext (href); relative/anchor links are dropped. Resolving relatives againstfinalURLwould preserve them — out of scope, minor content loss.
Test gaps (finding 4)
Untested branches: the 2 MiB LimitReader byte cap, the empty-Content-Type→HTML path (a documented behavior worth pinning), the redirect cap, and that WithCredentialInjector actually stamps the materialized headers. Each is a few lines against httptest.
Verdict
Merge-ready pending finding 1 (code-block whitespace mangling — degrades the tool's primary doc-reading use case; fix is a preformatted-depth flag). Finding 2 (fail-open to DefaultTransport) is a defense-in-depth hardening I'd want tested-and-documented if not fixed — it mirrors http_request, so a shared-seam follow-up is acceptable. Findings 3–4 are follow-ups. Core design is exactly right.
| walk = func(n *html.Node) { | ||
| switch n.Type { | ||
| case html.TextNode: | ||
| b.WriteString(collapseInlineWS(n.Data)) |
There was a problem hiding this comment.
Medium (content quality): <pre>/<code> whitespace is collapsed, mangling code blocks.
collapseInlineWS runs on every text node, including inside <pre>/<code>. <pre> gets a newline before/after (it's a blockTag), but its INTERNAL newlines and indentation collapse to single spaces — so a fetched code sample or config snippet returns as one unstructured run of tokens. This matters because the tool's headline use case is "read this spec / API doc / changelog," and those pages are exactly where code blocks live.
Fix: track a preformatted-depth during the walk; inside <pre>/<code>, emit the raw text (preserve \n and space runs) instead of calling collapseInlineWS. Worth a test with a <pre> block asserting the indentation survives.
| } | ||
|
|
||
| client := &http.Client{ | ||
| Transport: security.EgressTransportFromContext(ctx), |
There was a problem hiding this comment.
Medium (security, defense-in-depth): fails OPEN to http.DefaultTransport when the context has no egress client.
EgressTransportFromContext(ctx) returns nil when no egress client is installed, and http.Client{Transport: nil} silently falls back to http.DefaultTransport — bypassing the allowlist and SSRF/DNS-rebinding protections entirely. The egress-routing test only proves the deny path WHEN a transport is present.
This mirrors http_request's existing behavior (not a regression, and the runtime installs the egress client via WithEgressClient before tools run), but web_fetch is a new, default-on, LLM-URL-driven surface — the exact shape where fail-open is worst.
Recommend: when EgressTransportFromContext(ctx) == nil, refuse the fetch (or fall back to SafeTransport, never DefaultTransport), and add a test pinning the no-egress-client path. If you'd rather keep strict parity with http_request, at minimum file a follow-up to harden both at the shared seam so this is on record.
| if tools.RelaxedLimits(ctx) { | ||
| limit = webFetchByteLimitRelaxed | ||
| } | ||
| raw, err := io.ReadAll(io.LimitReader(resp.Body, limit)) |
There was a problem hiding this comment.
Minor: no charset handling — string(raw) assumes UTF-8, so a page served as ISO-8859-1 or with a charset= Content-Type parameter comes back as mojibake. golang.org/x/net/html/charset (already reachable via x/net) detects and transcodes the reader. Minor since most modern pages are UTF-8, but older spec/RFC pages — squarely in this tool's wheelhouse — are often Latin-1.
…nscode charset (#307 review) - Medium (content): <pre>/<code> whitespace was collapsed, mangling code samples on the doc pages this tool is meant to read. Track a preformatted depth in the walker and emit verbatim text (newlines + indentation) inside pre/code. Test asserts indentation + newlines survive while non-pre whitespace still collapses. - Medium (security, defense-in-depth): web_fetch fell open to http.DefaultTransport when the context had no egress client, bypassing the allowlist / SSRF protections. Refuse instead (fail-closed) — it's a new default-on, LLM-URL-driven surface. http_request has the same seam; hardening both is tracked in #308. Test pins the no-egress-client refusal. - Minor (charset): non-UTF-8 pages (older spec/RFC pages are often ISO-8859-1 or declare charset=) came back as mojibake. Transcode via golang.org/x/net/html/charset before extraction. Test pins Latin-1. - Test gaps (finding 4): classifyContentType unit test (incl. empty-CT → HTML and binary rejections), JIT-credential-injector header stamping (static provider), and the 2 MiB byte-cap bound. - Docs: note the pre/charset/refuse behavior and that a non-2xx response returns the error page's content with its status. No new external dependency — charset is under golang.org/x/net (already direct).
|
Thanks — both Mediums fixed, the charset minor fixed, and the test gaps filled ( 1 (Medium — 2 (Medium — fail-open to 3 (Minor — charset) — fixed. Non-UTF-8 bodies are transcoded via 4 (test gaps) — filled. Nits: added a docs note that a non-2xx returns the error page's content with its Full |
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review of 4351bde — all findings resolved, #308 filed, CI green
Finding 1 (pre/code mangling) — fixed correctly. A preDepth counter increments on <pre>/<code> entry, decrements on exit, and text nodes inside emit verbatim instead of collapsed. Nesting is handled by the depth count, and — nice detail — since only <pre> is in blockTags (not <code>), inline <code> stays inline while preserving its content, and block <pre> keeps its newlines. TestExtractReadableText_PreservesPreformatted pins that indentation + newlines survive AND that non-pre whitespace still collapses — both directions.
Finding 2 (fail-open) — fixed fail-closed, shared seam tracked. An explicit transport == nil check refuses the fetch with a clear error instead of falling to DefaultTransport. The comment documents that http_request shares the seam and that hardening both is tracked in #308 (verified open, accurately titled). TestWebFetch_RefusesWithoutEgressClient pins the refusal. Exactly the fix-the-new-surface-now, follow-up-the-shared-debt split recommended.
Finding 3 (charset) — fixed with fail-safe fallback. charset.NewReader transcodes from the Content-Type charset / <meta charset> / BOM, applied to both HTML and text kinds, falling back to raw bytes on any error rather than failing the fetch. TestWebFetch_TranscodesCharset pins Latin-1. No new dependency.
Finding 4 (test gaps) — all four filled: TestClassifyContentType (incl. empty-CT→HTML + binary rejection), TestWebFetch_StampsInjectedHeaders, and TestWebFetch_ByteCapBoundsRawRead. Docs note the pre/charset/refuse behavior and the non-2xx-returns-error-page-content contract.
Verdict
All findings resolved — merge-ready (all 10 checks green). Thorough cycle: both Mediums fixed with correct implementations and pinning tests, the shared-seam hardening honestly split into #308 rather than papered over, and the minors + test gaps all closed in one pass.
Closes #266.
Gap
Agents had
web_search(returns results, not page contents) andhttp_request(returns the raw response), but no "read this URL" tool. Reading a linked spec / changelog / API doc meant fetching raw HTML viahttp_requestand hand-parsing it — expensive in tokens and unreliable. Claude Code exposesWebFetchfor exactly this.Change:
web_fetchbuiltinhttp_request— the context egress transport +SafeRedirectPolicy+ optional R9 JIT credential injector. No second network path; honors the egress allowlist / SSRF protections. Anything mutating stays onhttp_request.golang.org/x/net/html: dropsscript/style/nav/footer/aside/headchrome; keeps the<title>(as#), headings (#..######), list items (-), and links (text (href)). Non-HTML textual payloads (text/*, JSON, XML) pass through as-is.image/*,application/pdf,octet-stream, …) rather than streaming a blob into context.max_charscap (default 50 000) with a truncation marker.{url (final, post-redirect), content_type, status, content, truncated?}.Registered default-on in
builtins.All(), wired to the sameHTTPCredentialInjectorashttp_request, and added to the Skill Builder prompt (enforced by theListsEveryDefaultBuiltindrift-pin).Acceptance
web_fetchregistered as a builtin (default-on likehttp_request).On reusing
forge-pluginsmarkdownThe issue suggested reusing the
forge-pluginsmarkdown converter, but that package converts markdown → platform HTML (Telegram/Slack) — the wrong direction — andforge-coreimportingforge-pluginswould invert the dependency.golang.org/x/net/htmlwas already in the module graph, so extraction adds no new external dependency (x/netjust moves indirect → direct).Tests
TestWebFetch_ExtractsReadableText— chrome (script/style/nav/footer) stripped; title/heading/paragraph/list/link text kept.TestWebFetch_TruncatesAtMaxChars— cap + marker.TestWebFetch_PlainTextPassthrough— non-HTML textual returns as-is.TestWebFetch_RejectsBinaryContentType—image/pngrefused.TestWebFetch_RoutesThroughEgressTransport— a denying egress transport fails the fetch (proves no bypass).TestExtractReadableText_MalformedHTML— lenient parse still yields text.Bonus: fixes doc-sync debt
While wiring the Skill Builder prompt, I found
.claude/skills/forge-skill-builder.mdhad drifted several prompt rewrites behindskillBuilderPromptBase— #252/#270/#297 were never re-ported, so it was missing the entire "Built-in Tools" section. Re-ported the current prompt verbatim and addedTestForgeSkillBuilderMD_MirrorsPrompt, which pins the md body to the Go constant so it can never silently rot again.