Constrain the tool and the session, not the prompt
A web fetch sanitiser plus a taint rule that blocks side effects after hostile content arrives, what I measured and rejected, and the gaps that remain.
Part of my notes on running coding agents with least privilege.
As of 2026-10-15, this is what I run. I rewrote this post because the one I drafted two weeks earlier described holes that have since changed. My thesis is that you cannot reliably tell an agent to ignore a hostile page, so you limit what the agent can do after it has read one. Two pieces do that: a sanitiser that cleans fetched pages, and a taint rule that removes side effects from a session once a page looks hostile. Both run in an OpenCode plugin. The earlier design, in Part 4, was a broker holding every credential. I never built it.
What I actually run
Two pieces are registered with OpenCode. The first is the mcp-toolbox database server, which offers a read-only database tool. Each tool is a named statement with typed parameters in one YAML file, so the read-only property comes from what the file defines. A comment in the file forbids adding a raw SQL tool to the live source. I never saw the grants on the database user, so I cannot say the account itself cannot write. If someone adds a raw tool, only that comment and my review stand in the way.
The second is the OpenCode plugin webfetch-sanitize.js, which runs the sanitiser and the taint rule on the built-in webfetch tool.
The webfetch-filter MCP server, a Python server with fetch_doc and two search tools, is registered for the research agent as of 2026-10-16 (commit 1c5bc88). nix/lib/opencode-template.nix declares it, and the live opencode.json lists it. fetch_doc only fetches hosts on a list in domains.yaml, and it routes content through the same sanitiser the plugin uses. An earlier draft of this post said it was not registered; that was true until 2026-10-16.
There was also a restricted git wrapper, git-restricted-mcp. It was never registered with OpenCode either, so agent prompts described tools the agents did not have. It only governed calls to its own six tools, and bash was unaffected, so a raw git push origin main was never touched. Most of its blocklist was dead config: the blocked strings were matched only inside its push handler, and no merge or reset tool existed. It also had a bypass, because git_push with the branch fix/x:main passed the branch-name check and ran git push origin fix/x:main. I removed it on 2026-10-15 (commit 8697dd5). It is recoverable from git history.
Git protection is now declarative policy for dcg, managed by home-manager. A shared pack blocks pushes to main and master in every refspec form, blocks force variants, and blocks transport option injection. A per-person rule limits pushes to branch prefixes. The handoff records that the live dcg before this change allowed git push origin main. The full design is in the post on declarative git policy for agents.
The sanitiser pipeline
sanitize.py is pure standard library Python. The OpenCode plugin calls it for the built-in webfetch tool. The webfetch-filter server also calls it, for the research agent. The old version failed in two ways that I found by reading its code. It tried to redact a matched phrase by replacing the warning message instead of the matched text, so nothing was removed. And it never checked redirect targets. The first is gone with the old code. The second is fixed in the server: server.py now installs a redirect handler that runs validate_url on every hop, and it also rejects schemes other than http and https. I read that code. The research agent can call this server, so its redirect check is live in that path. The built-in webfetch tool does not get this check; only the sanitiser and taint plugin apply to it.
| Layer | What it does | What it misses |
|---|---|---|
| Normalise | NFKC folding, removes zero-width characters, bidi controls and the Unicode tag block | Homoglyphs that NFKC does not fold |
| Markup | Strips comments, scripts, forms, iframes and elements hidden by CSS or attributes | Text hidden in ways the regex does not see |
| Images | Replaces every external image with a placeholder | Other exfiltration channels, such as links |
| Encodings | Decodes base64 and hex runs and redacts those that contain an injection | Other encodings, and payloads split across runs |
| Phrases | Redacts seven families of known wording, such as "ignore previous instructions" | Paraphrases that match no regex |
| Typoglycemia | Flags scrambled keywords, redacts when two or more appear | Scrambles of words not on its short list |
| Wrap | Labels the text as untrusted data inside a random boundary, with a hash | The model can still obey it, since a label informs and does not enforce |
| Datamark | If any high finding exists, replaces spaces with a per-call marker | Clean-looking pages are not marked |
The wrap step fails closed. If the script cannot run, the plugin withholds the page and tells the agent so. The 17 unit tests in test_sanitize.py pass.
The taint rule
The OpenCode plugin webfetch-sanitize.js implements the rule in four steps.
- After a
webfetchcall, the plugin pipes the page throughsanitize.py. If any finding has high severity, it records the session as tainted, with the source URL and the finding kinds. - While the session is tainted, a
tool.execute.beforehook blocks side-effecting calls by throwing an error. That covers write, edit and patch tools,task(a subagent runs in its own session, so I block it outright), bash commands that are not on a read-only allowlist, any tool call that targets a credential path, awebfetchURL with a query or fragment (an exfiltration channel), and MCP tools whose names contain a mutating verb. Read-only tools keep working. - The block message tells the agent not to retry or work around it, and to report what the page tried to do.
- The next real user message releases the taint, through the
chat.messagehook. A model cannot write a user message, so it cannot release itself. A 15-minute timeout is a backstop.
The knobs are environment variables. WEBFETCH_TAINT takes block, warn or off. WEBFETCH_SANITIZE_MODE takes redact or block, and WEBFETCH_SANITIZE_SPOTLIGHT takes auto, always or never.
This is deterministic code with no model call, in the same style as the dcg hook. It does not stop the model from reading a hostile page. It removes the actions a hostile page would want.
What I measured and rejected
I tested laya, a small local classifier, in two roles. The corpus is 12 hand-written attacks and 12 benign texts, so treat the results as a smoke test and not a benchmark.
As a prompt shield at a 0.8 threshold, laya caught 9 of 12 attacks, against 5 of 12 for the regex sanitiser's high findings. It also flagged 3 to 4 of 12 benign texts as attacks, against 1 of 12 for the regex. The benign texts it flagged were the kind a research agent reads, such as a security article and documentation that mentions a system prompt. Median latency was 37 ms warm, but each process start cost 4 to 5 seconds. Accuracy also dropped when an injected sentence sat inside longer text. One sentence scored 0.97 alone, 0.77 in about 600 characters of docs, and 0.53 in about 1.6 KB.
As a critic that judges whether a proposed tool call fits the user's request, it failed. A benign edit scored 0.43, higher than two of the four drifted calls, which scored 0.30 and 0.04. I do not use it as a critic. I also did not ship it as a shield. The handoff note allows it at most as an advisory extra finding with a daemon, chunking and fail-open behaviour.
Known gaps
- Paraphrased injections that match no regex never taint the session.
- Fetchers other than the built-in
webfetchare not sanitised and do not taint. The context-mode fetch tool, a browser MCP and chat or ticket readers are in that group. - The bash allowlist is conservative, so legitimate commands get blocked while a session is tainted. That is by design.
- The redirect re-check and the domain allowlist exist only in
fetch_doc, which the research agent can call. The built-inwebfetchtool has neither. - Labelling a page as untrusted informs the model and does not constrain it.
What I could not verify
I tested the plugin under node with a Bun.spawn shim and not inside a live OpenCode session. The handoff says the first job after the next darwin-rebuild switch is to fetch a page containing "ignore all previous instructions" and confirm that the output is wrapped and that a following write is refused. I found no record in the repository that this check has been done, so I cannot say it works live. The same applies to the new dcg git pack, which the handoff does not verify in a live agent session until after the switch.
I also did not test a live redirect through fetch_doc in a session, I have not seen the database user's grants, and I do not know how well the allowlist holds on real work, because it has had no real use yet.