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.

Fetched content, sanitiser, taint, then blocked or allowed toolsA page fetched by webfetch or fetch_doc goes to the sanitiser, which redacts, datamarks and wraps it. A high-severity finding marks the session as tainted. A tainted session blocks write, task, most bash and mutating MCP tools, and keeps read-only tools. The next real user message releases the taint.OpenCode session1Fetchwebfetch toolfetch_doc MCP2Sanitiserredact, datamark,wrap as untrusted3Tainthigh finding marksthe session4Blockedwrite, task, bashuntil user speaks5Allowedread, grep, globalways
A page with a high-severity finding taints the session. Side-effecting tools stay blocked until the next real user message.

The taint rule

The OpenCode plugin webfetch-sanitize.js implements the rule in four steps.

  1. After a webfetch call, the plugin pipes the page through sanitize.py. If any finding has high severity, it records the session as tainted, with the source URL and the finding kinds.
  2. While the session is tainted, a tool.execute.before hook 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, a webfetch URL with a query or fragment (an exfiltration channel), and MCP tools whose names contain a mutating verb. Read-only tools keep working.
  3. The block message tells the agent not to retry or work around it, and to report what the page tried to do.
  4. The next real user message releases the taint, through the chat.message hook. 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 webfetch are 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-in webfetch tool 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.