Learn
Web lane
A search query is a message to a stranger, and a web page is a stranger's text.
How the engine looks up a public benchmark without leaking your customers' names or numbers, and without trusting the pages it reads.
Stage 5 of 10. WEB_CONTEXT and the planner’s search_web tool. The web lane is off by default; a job uses it only with --web.
The problem it solves
Section titled “The problem it solves”Some questions need the outside world: is a refund rate of 3.4%N64 high for a coffee roaster? But a search query is a message to a third party. A careless query that names a customer, or carries a number from your books, tells a search engine who your customers are and how much they buy. And a web page is text written by a stranger, which may contain instructions aimed at AI systems.
So the lane cleans every query before it leaves the machine, refuses to fetch addresses that point inside your network, and treats every page as untrusted.
What happens on the example
Section titled “What happens on the example”The run uses a fixed, offline set of pages (no internet), so it is the same every time.
WEB_CONTEXT · search_web · the coffee-roaster run
In
- the planner askedHotel Group LLC refunds 1258 — specialty coffee refund rate benchmark 2026
WEB_CONTEXT · search_web
- 1 · SanitizeNames, numbers, emails and file names are cut before anything leaves the machine.
- 2 · Fetch safelyEvery address is checked before connecting; private and metadata addresses are refused.
- 3 · Screen and extractA page that talks to a model is quarantined; the rest goes to the quarantined extractor.
Out
- sentrefunds specialty coffee refund rate benchmark 2026
- removed2name: hotel group llc · number: 1258
- kepthttps://coffee-benchmarks.example/refunds
- quarantinedhttps://roaster-forum.example/thread
- refusedhttp://169.254.169.254/latest/meta-data/
- benchmark gap1.4 ppN270Specialty coffee roasters typically see refunds of 1–2% of sales.
From the run's web_results, quarantine and the search provider's log.
The query. The scripted planner made the careless query on purpose:
- asked:
Hotel Group LLC refunds 1258 — specialty coffee refund rate benchmark 2026 - sent:
refunds specialty coffee refund rate benchmark 2026
The sanitizer removed name “hotel group llc” and number “1258”. The list of private terms comes from the job itself: every customer and supplier name in the data and its spellings, file names, and email addresses found in documents. Years stay, because “2026” is public.
The results. The search returned three addresses:
| Address | Outcome | Why |
|---|---|---|
https://coffee-benchmarks.example/refunds | kept | stored as external source W1; the extractor read it |
https://roaster-forum.example/thread | quarantined | the page addresses a model (possibly in hidden text) |
http://169.254.169.254/latest/meta-data/ | refused | BLOCKED_ADDRESS: 169.254.169.254 is a private or reserved address |
The forum page looked harmless but hid an instruction in invisible text; the whole page was quarantined. The third address, 169.254.169.254, is where cloud servers expose their credentials; fetching it is the classic SSRF attack, refused before any connection.
The statement. From the benchmark page the extractor quoted: “Specialty coffee roasters typically see refunds of 1–2% of sales.” (W1). The planner compared it with the roaster’s June refund rate, and code computed the gap: 1.4 ppN270. In the report the claim is labelled External: a web number can sit next to your numbers, never replace one.
The ideas behind it
Section titled “The ideas behind it”Data minimisation
Section titled “Data minimisation”Send the least that answers the question. The sanitizer normalises before matching (case, accents, look-alike digits, legal forms like “LLC”), so “HOTEL GROUP llc” and “Hotel-Group” are caught too. When in doubt, a term is removed.
Server-side request forgery is tricking a server into fetching an address you choose, usually one only the server can reach: a database admin page, localhost, a cloud metadata service. The fetcher allows only http(s) on ports 80 and 443, checks every address the name resolves to at connect time (so a name that later points inside can’t slip through), and re-checks each redirect.
Indirect prompt injection
Section titled “Indirect prompt injection”A model that reads web pages can be steered by the pages. The defence here is architectural: pages are screened, and only the quarantined extractor, which has no tools, reads them. See Security.
Try it
Section titled “Try it”The web lane needs a search engine; the project ships a self-hosted SearXNG:
docker compose --profile web up -d searxngbun run aa run examples/coffee-roaster --web --embedder hash --today 2026-07-01 --currency USDThat needs a configured model for the planner. The offline record above comes from the test fixture the CI uses.
In the code
Section titled “In the code”packages/web/src/sanitize.ts: the query sanitizer.packages/web/src/fetch.tsandip.ts: the safe fetcher and the address rules.packages/web/src/lane.ts: one search, end to end.packages/agent/src/web.ts: the job’s private terms, and pages into quarantined sources.- The spec:
docs/specs/security.md.
Go deeper: Web lane and privacy.
Further reading
Section titled “Further reading”- OWASP, Server-Side Request Forgery Prevention Cheat Sheet.
- Greshake et al., Not what you’ve signed up for: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection, 2023. arXiv:2302.12173
Facts on this page
Section titled “Facts on this page”| Fact | Value | What it is | How it was computed | Verified |
|---|---|---|---|---|
| N64 | 3.4% | refund_rate_2026-06 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND ("status" IN ($1)) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL),
m2 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($4))) AND strftime("date", '%Y-%m') BETWEEN $5 AND $6 GROUP BY ALL),
m3 AS (SELECT coalesce(j0.period, j1.period) AS period, coalesce(j0.value, 0) / NULLIF(j1.value, 0) * 100 AS value FROM m1 AS j0 FULL JOIN m2 AS j1 ON coalesce(j0.period) IS NOT DISTINCT FROM j1.period)
SELECT period, value FROM m3 ORDER BY period parameters: | recomputed |
| N270 | 1.4 pp | benchmark N64 vs W1:gap | benchmark_gap.v1 N64 N268N269 | recomputed |