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.
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.
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
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.
| service | hardening | reasoning 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.
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.
- The Camoufox sidecar is re-bound on purpose.
camofox-browser's ownmake uppublishes on0.0.0.0; this compose file publishes127.0.0.1:9377:9377instead. A remote-controllable browser on your LAN is a worse exposure than the search engine. - SearXNG's limiter is off, and that is a conditional decision. It requires Valkey or Redis, and a private instance behind loopback does not need rate limiting. Expose the instance and it does — the settings file and the README both say so.
- Set a real
SEARXNG_SECRET_KEY.setup.shgenerates one withopenssl rand -hex 32on first run. The compose file will refuse to start without it:${SEARXNG_SECRET_KEY:?set SEARXNG_SECRET_KEY in .env}. - The
.envfile is gitignored, alongsidevendor/,*.logand__pycache__/. If you inline an API key intosettings.ymlas the comments suggest, that file is not gitignored in this repository — treat your deployment copy accordingly.
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:
- 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.
- Prefer
search_answer. It is cheaper and it keeps page bodies out of your context. Reach forcrw_searchwhen you need exact syntax, and know that you are choosing to do so. - 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. - Leave the binds alone. Everything on loopback, tunnels for remote access.