AI Part 9: From Draft-Only to Whole-Site — the v2.4 Tool Surface and the Rails Behind It

Part 7 and Part 8 went outward — nmap, Metasploit, a 150-tool aggregator, other people’s idea of what an LLM should be allowed to touch. This post comes back home, to the small server that actually runs this blog. It just shipped v2.4, and it’s the biggest change to the tool surface since I first gave the model write access back in Part 1.

The one-line version: up to now the server was a publishing surface. It could write a draft, list drafts, read a draft, and deploy one. That’s it — a narrow slot cut into an otherwise off-limits site. v2.4 opens the whole thing up. The agent can now read any file under the site root, recursively list directories, grep across the entire content tree, write and overwrite source files, delete them, drop in binary image assets, pull an asset back off the live site, and trigger a rebuild. That’s a much bigger blast radius, and — exactly as in Part 4 — the code I’m actually proud of isn’t the tools. It’s the four or five boring things standing between those tools and a bad afternoon.

What actually changed

The old surface was deliberately tiny, and Part 6 ended with me arguing you should keep it that way. I still believe that. But six months of using it turned up a recurring friction: the model could write a post but couldn’t see the rest of the site to make the post fit. It couldn’t check whether a series-index page already linked the new part. It couldn’t find every post that referenced a tag I was renaming. It couldn’t add the diagram a post obviously needed. Every one of those turned into me doing a chore by hand and pasting the result back in.

So v2.4 adds, in rough order of how much I use them:

  • read / list / grep across the site. Read any file under the site root, list a directory tree with sizes, and run a case-insensitive regex across every text file. This is the half that removed the most friction and carries the least risk — it’s all read-only.
  • write and delete source files. The write half. This is the part that needed the rails, and most of the rest of this post is about it.
  • binary asset handling. Write an image into the site’s public assets and pull one back off the live site. This is what finally lets the agent illustrate its own posts instead of leaving me a <!-- TODO: diagram -->.
  • rebuild + tail-log. Kick off a site build, and read back the tail of the server’s own audit log — so the agent can confirm what it just did rather than assuming.

Read tools are cheap to trust. Everything that mutates something is where the interesting decisions live.

Why “trust the filename” fails

The asset-write tool takes a filename and a blob of base64 and drops an image into the site. The naive version of that tool is three lines and a security incident: decode the base64, join the filename onto the assets path, write the bytes. Two things are wrong with it.

The first is the filename. A filename is attacker-controlled input even when the “attacker” is a well-meaning model that misread its own instructions. ../../something is not under the assets directory once you resolve it. So the same rule from Part 4 applies here and applies first: resolve the path, then check the resolved path is still inside the one directory this tool is allowed to write to. Asset writes are confined to the site’s public/ tree and nowhere else. Not “should be” — the tool refuses anything that resolves outside it.

The second is the contents. An extension allowlist (.png, .jpg, .svg, .webp, and friends) is necessary but nowhere near sufficient, because the extension is just more of the same attacker-controlled string. evil.png can contain anything at all. So the tool also sniffs the magic bytes of the decoded payload and confirms they match an actual image format before a single byte hits disk. A PNG has to start with the real PNG signature; a JPEG has to start with FF D8 FF. Name it .png all you like — if the leading bytes say it’s something else, it’s rejected. Filename and content have to agree, and the content is the one that gets the final say.

That combination — resolve-and-confine the path, allowlist the extension, then verify the bytes independently — is the whole trick. Any one of the three on its own has a hole. Together they mean the worst a confused agent can do is write a valid image to a web-root directory, which is precisely the thing the tool is for.

The payoff is the workflow I wanted since Part 1: I ask for a diagram, the model generates and resizes the image locally, ships it over as base64, the server validates it and commits it, and it appears in the post. The agent illustrating its own writing, end to end, with me reviewing the result rather than doing the plumbing.

The fetcher that has to assume the worst

The other new asset tool pulls an image back — give it a URL, it fetches the bytes. If you’ve done any network work you already felt your neck prickle, because “server fetches a URL a client handed it” is the exact shape of an SSRF, and a box that can reach its own management plane or a cloud metadata endpoint is a box you do not want making arbitrary outbound requests on command.

So the fetcher is the most paranoid tool in the set, and deliberately so:

  • HTTPS only. No http://, no file://, no gopher://, none of the schemes that make SSRF write-ups fun. One scheme, and it’s the encrypted one.
  • No redirects. A URL that passes every check and then 302s to http://169.254.169.254/ is the oldest trick in the book. Redirects are refused outright rather than followed and re-validated, because the second request is the one that bites you.
  • Resolve DNS first, then check the address. This is the part people miss. You cannot validate a hostnamemyimages.example can resolve to a private address, and can resolve to a different address the second time you look (DNS rebinding). So the tool resolves the name itself, checks the resulting IP against the private, loopback, and link-local ranges, and refuses anything that isn’t a real public address. The check is on the number you’re about to connect to, not the name you were handed.

None of that is novel — it’s the standard SSRF checklist. But writing it out for a networking audience is worth it precisely because the standard checklist is what people skip when they’re building “just a little image fetcher for my blog.” The little image fetcher is exactly the thing that reaches somewhere it shouldn’t.

write-file is RCE-by-design, and git is the undo

Here’s the honest framing I kept coming back to while building this: a tool that lets a remote model write arbitrary source files into the directory a web framework builds from is, on a personal box, functionally remote code execution by design. That’s not a bug I’m confessing; it’s the feature. The entire point is that the agent can change how the site is built. Pretending otherwise would be the dangerous move.

You don’t neutralise that with cleverness. You neutralise it with a blast radius you control, and the control is version history. Every mutating tool call auto-commits. Write a file, delete a file, drop an asset — each one lands as its own commit with a machine-generated message, immediately, before the tool returns. The audit log records the same event a second time in its own append-only file. So the state of the site is never something the agent changed and I have to reconstruct; it’s a linear history I can read, and every line of it is exactly one revert away from being undone.

You can watch it work. Two probe operations from the v2.4 shakeout — a write and the delete that cleaned it up — show up as exactly what you’d want:

$ git log --oneline -3
e67904d mcp: delete_site_file public/.mcp-v24-probe.txt
4f9e3cd mcp: write_site_file public/.mcp-v24-probe.txt
3c82651 baseline: pre-v2.4 site state
| git_commit | write_site_file public/.mcp-v24-probe.txt
| git_commit | delete_site_file public/.mcp-v24-probe.txt

Git log and audit log agree, which is the whole point — two independent records of the same mutation, neither of which the agent can rewrite. This is Part 4’s rollback rail scaled up: back then the irreversible thing was a deploy and the undo was a repo revert. Now every file operation is a committed, revertible step, and the undo is the same muscle memory. The thing I deliberately did not build is an in-tool “undo” the agent can call. Reverting is still my job, out of band, the same as it was in Part 4 — because an undo the agent controls is an undo that can loop without me noticing.

One more line I want to be explicit about, since it’s a load-bearing omission: the server’s own code is not reachable through any of these tools. The allowlist is scoped to the site content tree; the process that enforces the allowlist lives outside it. The agent can rewrite the blog. It cannot rewrite the thing that decides what it’s allowed to rewrite. That asymmetry is intentional and it’s the reason I sleep fine with write access on.

Two gotchas that cost me an afternoon

The 100 KB wall. The first real image I tried to ship over the asset tool failed with a 413 Payload Too Large, and I lost a while to it because the tool was correct — the ceiling was one layer down. The JSON body parser sitting in front of the handler has a default request-size limit of 100 KB, and it enforces it silently, before your code ever runs. A base64-encoded image is trivially bigger than that. The fix is a one-liner — raise the parser’s limit to something sane for image payloads — but the lesson is the one that keeps recurring in this series: when a tool fails at the edge of what it handles, suspect the framework default before you suspect your own logic. The defaults are tuned for tiny JSON bodies, and nobody tells you when you’ve outgrown them.

The future-date guard. I schedule posts by dating them ahead and letting the pipeline pick them up on the day. That’s a lovely footgun to hand an eager agent: “deploy the post” on a piece dated three weeks out shouldn’t shove next month’s content live today. So deploy_post now refuses to publish anything dated in the future — unless you pass an explicit force. The guard is the default; the override exists but you have to ask for it, in as many words. That’s the shape I want every irreversible tool to have: the safe thing is free and automatic, the risky thing is possible but never accidental.

The catalogue cache, one more time

If Part 6 had a greatest hit, it was the tool-catalogue cache, and v2.4 played it again on cue. I deployed the new server, restarted cleanly, watched it log the new version — and the conversation I was in kept insisting there were only the old tools. Nothing broke; the client had simply pinned the tool list it started the conversation with, and no amount of restarting the server refreshes a snapshot the client took. The new tools showed up the moment I opened a fresh conversation.

I wrote in Part 6 that I now treat catalogue refresh as an explicit deploy step, and v2.4 was a good reminder that “I know about this one” and “I remembered to check for it” are different sentences. The verification isn’t done when the server logs the new version. It’s done when a client you’ve re-opened actually lists — and successfully calls — the new tool.

What it adds up to

v2.4 roughly triples the number of things the agent can do to this site, and the surface still fits Part 6’s advice better than it looks: the new tools are boring on purpose. Read. Grep. Write. Delete. Fetch. None of them are clever, and the cleverness I did add lives in the rails around them — the confined write paths, the magic-byte check, the SSRF-hardened fetcher, the commit-everything blast radius — not in the tools themselves.

That’s the same conclusion I reached at the end of the original six parts, just with more tools hanging off it: if you’re building one of these and something has to give, cut the tools, keep the rails. A server that can rewrite your whole site is only a good idea for exactly as long as every change it makes is one revert away from never having happened.