Writing

The Overwrite Nobody Asked For

The Overwrite Nobody Asked For - abstract illustration

I spent a day auditing every tool in my MCP server that writes a file to local disk, expecting to find one or two rough edges. I found three tools that would silently destroy an existing file, with no parameter to refuse, and all three had the identical shape. None of it was a bug anyone had written on purpose. It was the default that every one of those code paths had inherited without anyone deciding on it.

The shape, three times

Storage_download pulls an object out of S3, SFTP, FTP, or WebDAV and writes it to a local path the caller names. Four separate transport implementations, four separate authors' worth of code, and every one of them unconditionally replaced whatever already sat at that path. EmailStore_copyAttachmentToFolder did the same thing with a mail attachment. A third path in the filesystem domain did it on copy.

The reason it looks fine in review is that the failure needs two things to line up, and in a test it never does. You write the download, you run it, the file appears, the test passes. The destructive case only shows up when something is already there, which in a fresh test directory is never.

In production it's not rare at all. A scheduled job that downloads to the same local path on every run clobbers its own prior output every single time. A path reused for an unrelated reason clobbers someone else's file. Neither one raises anything. The tool returns success, because from its point of view it did exactly what it was asked.

Why this is worse when an agent is the caller

I've written before about designing the degraded state before the happy path. This is a variant of that: the destructive path and the happy path are the same code, so there is no degraded state to design. The tool can't tell the difference between "put this file here" and "replace the file that's here."

A human caller has context the tool doesn't. They know whether that path is theirs. A model calling the same tool has a filename it constructed from a pattern, a plausible-looking destination directory, and no idea what's already in it. It will reuse a path far more readily than a person would, because reusing the path is the tidy-looking thing to do.

So the question isn't whether silent overwrite is defensible in general. It's whether it's defensible as the default on a surface that an autonomous caller reaches. It isn't. If the caller has to opt in to destruction, a wrong path costs you an error message. If destruction is the default, a wrong path costs you the file.

The fix that looks like the fix, and isn't

The obvious fix is a check before the transfer: if something's already at the destination, refuse. I wrote that first, and it's worth having, but on its own it's theater.

The collision window isn't the instant of the check. It spans the entire transfer, which for a large object can be minutes. Checking at the start and writing at the end leaves a gap long enough to drive a truck through. The pre-check's real value is narrower and worth naming honestly: it avoids spending minutes on a transfer that was already doomed to fail. It is not the guarantee.

There's a second subtlety in the check itself. FileManager.fileExists follows symlinks, so a dangling symlink at the destination reports as "nothing there" and gets walked straight past, after which you write through it to wherever it points. The check has to be lstat-based to treat the link itself as occupancy.

What actually closes it

The guarantee has to be at the landing, and it has to be atomic. Write the transfer to a temp file, then move it into place with an operation that fails if the destination exists. On macOS that's renamex_np with RENAME_EXCL. The kernel does the check and the move as one thing, so there's no window between them.

Two details cost me more time than the main mechanism:

Cross-volume moves. WebDAV's temp file comes out of URLSession.download, which puts it wherever it likes, and that may not share a volume with the destination. A rename across volumes returns EXDEV and you have to fall back to a copy-then-delete, which reintroduces the very window you just closed. You handle it, you write down that this path is weaker, and you don't pretend the fallback is equivalent.

Filesystems that don't support the flag. RENAME_EXCL isn't universal. On a volume that doesn't support it you need a degradation path, and the degradation has to be a refusal rather than a silent fallback to the unsafe behavior. I have one open item where an archive-extraction writer has this same exposure and hasn't been brought in line yet. It's filed rather than fixed, because knowing where the hole is beats pretending it's closed.

The part I couldn't make clean

Changing an existing tool's default from "overwrite" to "refuse" is a backward-incompatible change, even though the new overwrite parameter is optional. That's the sort of sentence that's easy to write past, so I'll be concrete: a recurring job that downloads to the same path every run worked yesterday and fails starting on its second run today, until someone goes and adds overwrite: true.

I audited the whole repo and found no internal caller relying on the old behavior. What I could not audit is an agent prompt saved somewhere, or a deployed MCP client config, written months ago against the old contract. Those exist outside the repository and there's no way to sweep them.

I shipped it anyway, because the alternative is keeping a data-destroying default forever on the grounds that someone might be depending on it. But it's worth being honest that "I checked all the callers" means "I checked all the callers I can see," and on a tool surface that other people's automations call, that's a smaller set than it sounds.

The error message is part of the contract

One small thing that turned out to matter more than expected: the refusal needed its own error case, not a reuse of the existing access-denied one.

"You may not write there" and "you may write there, but something's already there and you didn't opt in to replacing it" are different problems with different fixes. Collapsing them into one message means the caller, human or model, can't tell which one it hit. The message the tool returns now names the path and says explicitly to pass overwrite: true to replace it, which is the difference between a model retrying correctly and a model retrying at random.

What I'd check in your own tools

If you have a surface that an agent calls, this is worth an hour. Find every code path that writes to a caller-supplied destination and ask two questions: what happens when something is already there, and does the caller have any way to say "don't."

The answer will usually be "it replaces it" and "no," not because anyone chose that, but because it's what the standard library does when you don't think about it. Defaults you didn't choose are still defaults you shipped.

Building something like this?

This is the kind of work I do for clients. Tell me what you're building and I'll give you a straight read on the approach.

Book a 30-minute call