Your Agent Can Byte-Wreck a Live SQLite Database — Now Hermes Refuses the Write

It is a Wednesday afternoon and you ask Hermes to tweak a small config script in one of your projects. The same directory happens to hold a kanban.db, or your app’s app.db. It edits the script in a few seconds, you move on. The next morning the application will not start: database disk image is malformed. You never deleted the database. You never edited it. You never even opened it. What wrecked it was the assistant you hired — its write_file treated the database’s log file as an ordinary text file.
That is not hypothetical. It happened inside Hermes’ own repository: one team’s kanban.db went from 550 KB to a field of U+FFFD replacement characters, and SQLite declared the image malformed and unrecoverable. PR #114220, merged on 17 September 2026, closes that hole. This post walks through how the damage happened, what the fix actually guards, and whether the build you are running right now has it.
How it happened: a text tool wrote into a binary file
write_file and patch are tools for text. They decode a file into a string when reading and encode it back when writing. For plain text that round-trip is lossless. For binary content it is catastrophic: bytes that are not valid UTF-8 become U+FFFD (“�”) on the way in, and those bytes are gone forever on the way out.
What makes this genuinely unsettling is that Hermes never decided to write a database. It meant to write some neighbouring file, or it overwrote what it believed was a text file while tidying a directory. The question that matters is how a tool decides “this is binary, do not touch it”:
tools/binary_extensions.py
The old check looked at the final path suffix only:
app.db -> .db -> on the binary list -> refused OK
state.db-wal -> .db-wal -> not on the list -> treated as text
.db itself was already covered by the overwrite guard. The sidecar files were not. So what are they?
x.db-wal— the write-ahead log, the scratch pad of WAL mode. New writes land here first and are merged back into the main file later. Corrupt it and you have torn up the ledger that has not been posted yet.x.db-shm— the shared-memory index for the WAL, which processes use to agree on read/write positions.x.db-journal— the rollback journal used in non-WAL mode to undo an unfinished transaction.
In plain terms: the main database is a bound ledger, and the WAL is the stack of unposted slips on the desk. Paint over the slips and the whole ledger stops balancing the next morning. That is why the affected database was not “missing a few rows” — it was declared malformed outright.
What the fix does: one verdict, shared by every read and write path
The approach in PR #114220 is clean: do not patch the symptom, make “this is a SQLite sidecar” a single shared verdict so reads and writes use the same ruler.
Concretely, the change is:
- Sidecar recognition moved into
tools/binary_extensions.py— a small_lower_suffixhelper plus anis_sqlite_sidecarcheck — andread_file’s guard,file_tools.pyand the five sandbox read sites intools/file_operations.pyall consult it. The read guard, the write guard and the sandbox reads can no longer disagree. - A sidecar path is refused even when the file does not exist yet. After a checkpoint a database may have no sidecars on disk at all, but the very next write creates one. A guard that only checks for an existing file would slip through exactly that window.
- Overwriting an existing binary file is still refused, while creating a brand-new
.dbfile is allowed again — the repo has text fixtures literally named*.db, and a blanket ban on creation would break them. (This deliberately differs from the original contributor’s policy; upstream review relaxed it.) read_fileno longer hands back lossy text for a sidecar; it is treated as binary.
Upstream measured the behaviour against a real database:
| Action | Before | After |
|---|---|---|
write_file into state.db-wal (10 bytes) |
write succeeded, then SQLite reported disk I/O error |
refused, bytes unchanged, integrity_check passes |
write_file a new notes.db |
allowed | still allowed |
read_file a sidecar |
lossy text returned | handled as binary |
Twenty-one test cases cover the four combinations (existing sidecar / absent sidecar / the .db itself / the patch tool) and all pass. There is a useful piece of process here too: this is a salvage of community PR #114091. The original author opened the PR, upstream review chose not to merge it as-is, so the commits were cherry-picked with authorship preserved and widened. That pattern is everywhere in the Hermes repo, and it is a big part of how dozens of fixes land in a single day.
Does your version have it?
Honestly: no. The commit merged into main on 17 September 2026, and the newest tagged release is v0.21.3 (tag v2026.9.14, 14 September) — this fix lands after it. Check what you are running:
hermes --version
hermes update
Until the guard reaches your build, three practical habits:
- Keep live databases out of the agent’s working directory. Move database files, backup directories and
*-walsidecars out of the project tree — or at least out of the paths you let the agent write to freely. - Change a database through the
sqlite3CLI, not through text tools. Safe operations like.backup(hot backup) andVACUUM INTO 'copy.db'(a compact copy) are the database managing its own bytes; there is no text round-trip to lose information in. - Back up on a routine. A database damaged this way is essentially unrecoverable — the 550 KB
kanban.dbin the original report was a total loss, because the damage happened in the read-replace-write step: what was destroyed was the data itself, not an index.
The general lesson behind the guard
Step back and the rule holds for any agent toolchain: a text tool cannot round-trip binary content, so accepting the target at all is the defect — not something that goes wrong during the write. The right repair is not a cleverer conversion at write time; it is refusing the target at the entrance. Hermes already sniffed binaries on four read paths; the write paths simply never got the same treatment. Holes like this live in that kind of seam, where one path was left unaligned.
One known follow-up did not make this PR: magic-byte sniffing for extensionless databases (a SQLite file starts with SQLite format 3\0). A file named backup with no extension is still treated as text today. If that matches your naming habits, habit 1 above — keep it out of the working directory — is your only insurance.
It also belongs to a family of problems we have covered before: concurrent writers into the same database, WAL handles held by another process, and hermes doctor --fix acting on a database it should have left alone. They all point at the same conclusion: the more capable the agent, the more the things it must not touch need machine-readable protection, rather than relying on you to remember to warn it. A refusal in the tool layer beats a line in a prompt asking nicely.
Summary
- The defect:
binary_extensions.pycompared only the final suffix, so.db-walread as ordinary text — decoded into mojibake and written back, destroying live databases. - The fix (PR #114220, merged 17 September 2026): a shared
is_sqlite_sidecarverdict used by reads, writes and sandbox paths; sidecars refused whether or not they exist; binary overwrites refused while creation stays allowed. - Status: on
main, not in any tagged release yet (latest is v0.21.3). - What you can do today: move live databases out, edit them with the
sqlite3CLI, back them up.
Related reading: the site’s v0.21.3 release notes cover the same family of database-write safety work; if your database is already damaged and you need to rescue it, see the state.db corruption recovery guide; for when hermes doctor’s advice about a WAL flips from helpful to harmful, read the honest WAL warning; and the container-side version of the same topic is Docker and cross-VM state.db WAL.