Layer 0 · agent

Giving a model web access is giving strangers a writing surface in its context.

Every page your agent reads was written by someone else. If that agent also holds your credentials and can run commands, the page is not data — it is input to a control loop. This layer is about what the repository does about that, and what it explicitly does not claim to solve.

Two problems arrive together when raw page text goes into an agent's context. One is boring: 50–100k tokens of prefill per search, which you pay for in time and money. The other is not: those tokens are attacker-controlled input to a model that holds your tools. The dual-LLM pattern addresses the second, imperfectly, and the repository says so in the same paragraph in which it recommends it.

PatternPrivileged model, quarantined model

The design is Simon Willison's dual-LLM pattern: a privileged model holds the tools but never reads untrusted content; a quarantined model reads untrusted content but cannot act. In this stack the quarantined model is whatever you configured in [extraction.llm] — deliberately small and fast — and the wiring is a single flag, "answer": true, in a 173-line MCP server.

The dual-LLM pattern: which model may act, and which may read A vertical boundary separates a privileged zone from a quarantined zone. The privileged agent holds shell, credentials, files and tools but receives only an answer and a list of URLs. The quarantined summariser holds no tools and cannot act, but reads raw page text arriving from the web. Only the answer crosses the boundary. THE BOUNDARY PRIVILEGED — MAY ACT, MUST NOT READ UNTRUSTED TEXT your agent shell credentials in env your files every other MCP tool QUARANTINED — MAY READ, CANNOT ACT summariser · [extraction.llm] no tools no way to act small, fast model reads raw page text 1 · the question, and only the question 3 · an answer, plus a list of source URLs — page bodies never make this crossing web pages attacker-controlled, by definition 2 · scraped by crw, handed to the quarantined model WHAT THIS BUYS a few hundred tokens instead of 50–100k, and no page markup in the context that holds your tools KNOWN WEAKNESS — the summary is still natural language derived from attacker-influenced input. An injection can in principle survive summarisation. Defence in depth. Not a guarantee.
Figure 8 — the dual-LLM split The boundary is not cryptographic and not enforced by the protocol. It is enforced by the quarantined model having nothing to act with: no tools, no shell, no MCP client. That property is what makes the arrangement worth anything, and it is why the summariser must not be your main coding model.

HonestyWhat the pattern does not fix

The README does not oversell this. It states the documented weakness directly, quoting it, and then says which control is actually load-bearing:

if the quarantined LLM returns natural language summaries, attackers might embed malicious instructions within those summaries

quoted in README.md — Honest limits of this pattern

A summariser is not a sanitiser. It reads text and writes text, and there is no theorem standing between an instruction in the input and a sentence in the output. What the pattern reliably removes is volume and fidelity: an injection has to survive being rewritten by a model that was asked to answer a different question, and it arrives without its markup, its links, or its formatting tricks. That is a meaningful reduction and not a boundary.

MapWhere untrusted bytes actually go

It is worth being precise about the fence, because there is one, and it does not cover what people assume. fastCRW wraps untrusted content in a nonce fence when it builds prompts — for its own LLM calls. That protects the synthesis path. It does nothing for the raw MCP path, because on that path crw is not building a prompt at all; it is handing you markdown and your agent is building the prompt.

Trust boundary map: the path untrusted bytes take through the stack Three regions: the open web, the hardened crw container, and your agent session, separated by a trust boundary. Inside crw, bytes are fetched, parsed, converted to markdown, and optionally wrapped in a nonce fence for the summariser. Two paths cross the boundary: raw markdown on the crw_search path, and a synthesised answer on the search_answer path. TRUST BOUNDARY THE OPEN WEB target pages HTML, JS, PDFs, redirects 100% attacker-controlled search results titles and snippets, also written by third parties Nothing here is a source you chose. It is a source a ranking algorithm chose, for a query a model wrote. CRW CONTAINER · read_only · cap_drop ALL 1 · fetch — the renderer ladder 2 · parse HTML — the risky part 3 · convert to markdown 4 · prompt built inside crw's nonce fence the fence wraps prompts crw builds for its OWN LLM — that is, this path only it is not applied to raw MCP responses bytes quarantined summariser reads the fenced page text holds no tools, cannot act max_concurrency 3 · max_html_bytes 100000 YOUR AGENT SESSION shell credentials every other MCP tool Whatever crosses this line is in the context of a process that can act — for the rest of the session. raw markdown crw_search · crw_scrape answer + URLs search_answer TWO PATHS CROSS THE BOUNDARY. YOU CHOOSE WHICH, PER TOOL CALL.
Figure 9 — where the bytes go The container hardening is not there to protect your agent from a prompt injection; it cannot. It is there because step 2 — parsing arbitrary HTML — is the step where a memory-safety bug in a parser would matter, and it is running against input chosen by a stranger.

Threat modelThe control that is actually load-bearing

The README is explicit that summarisation is not the defence it relies on:

The load-bearing control is not handing one session the lethal trifecta: private data + untrusted content + a way to send data out. If your agent can read the web and fetch your credentials and run shell, no amount of summarising saves you.

README.md — Honest limits of this pattern
The lethal trifecta, mapped onto this stack Three overlapping circles: private data, untrusted content, and a way to send data out. Each is annotated with what supplies it in an agent session that has web access. The centre, where all three overlap, is labelled as the dangerous configuration. PRIVATE DATA API keys in the environment your repository, your notes anything the session has read UNTRUSTED CONTENT every page crw returns every snippet SearXNG merges chosen by a ranking algorithm, not by you A WAY TO SEND DATA OUT shell · curl · git push · any tool that makes a request all three, one session no summariser fixes this SIMON WILLISON'S FRAMING — THE ONE THIS REPO'S DESIGN FOLLOWS localbrowser supplies the right-hand circle. It cannot decide the other two for you.
Figure 10 — the trifecta Adding web search to an agent adds exactly one of these three. That is the honest way to read this repository: it hands you a capability, and the safety question is which of the other two circles your session already occupies.

ContainmentWhat the containers are for

Container hardening solves a different problem from prompt injection, and it is worth keeping the two apart. Injection is a semantic attack on a model. The container is defence against a technical attack on the process that parses bytes: a malformed PDF, a decompression bomb, a parser bug.

From docker-compose.yml. The crw service, which is the one that touches untrusted input, carries the strictest set.
servicehardeningreasoning as written in the file
crw read_only, tmpfs /tmp, cap_drop ALL, no-new-privileges, mem 2g, memswap 2g, pids 512 "Defence in depth for untrusted page content" — this is where arbitrary HTML is parsed
searxng cap_drop ALL, cap_add [CHOWN, SETGID, SETUID] Drops everything, then adds back only the three the image needs to drop privileges at start-up
searxng logging: max-size 1m, max-file 1 A search proxy under agent traffic will otherwise fill a disk with query logs you did not want kept
camofox profiles: [stealth], shm_size 2gb Does not exist unless requested; a real browser needs real shared memory

ExposureWhy everything binds to loopback

This is the part most likely to be undone by a well-meaning change. crw fetches arbitrary URLs on request and has no authentication. Anything that can reach port 3002 can ask it to fetch http://192.168.1.1/admin, or your cloud metadata endpoint, and read the response back. That is a textbook SSRF primitive, and the compose file's opening comment says so before it says anything else.

Do not change CRW_BIND_ADDRESS

Reaching the stack from another machine is a solved problem that does not require exposing it: ssh -L 3002:127.0.0.1:3002 user@host. The variable exists in .env.example with a comment telling you not to use it for a LAN or a tailnet. "Tailnet" is called out specifically because a private overlay network still means every device on it, and every device that ever compromises one of them.

PracticeHow to use this without lying to yourself

The pattern is only worth what the surrounding configuration is worth. Concretely, in decreasing order of how much it matters:

  1. Do not give one session the whole trifecta. A research session with web access and no credentials, and a separate build session with credentials and no web access, is worth more than any amount of filtering.
  2. Prefer search_answer. It is cheaper and it keeps page bodies out of your context. Reach for crw_search when you need exact syntax, and know that you are choosing to do so.
  3. Point [extraction.llm] at a small local model. Not your coding model — that is a GPU contention argument, but it is also a scope argument: the quarantined model should be a component, not a colleague.
  4. Leave the binds alone. Everything on loopback, tunnels for remote access.