case studies

When Someone Edits the Audit Log Behind an AI Agent

Picture the morning after. An agent had a grant to publish post 7, and on the way it tried post 8. The refusal sits in the log as entries 2 and 3. Someone with access to the server would rather that attempt wasn’t there. Maybe they set the agent up and the attempt looks bad. They open the database with sqlite3.

We ran that incident for real, on copies of the database from the publish session case study. Tokening 0.1.4, a stand-in CMS on the same machine, every command and its output saved in the case study folder of the code repo. The original log had 20 entries.

The table refuses updates and deletes through SQLite triggers, so the first thing any of these edits does is drop the trigger. That takes one line. The chain is what’s left to catch it.

Changing a refusal into an allowed call

First copy: entry 3’s outcome changed from refused to allowed.

$ sqlite3 tampered.db "DROP TRIGGER flight_no_update; UPDATE flight SET body = replace(body, '\"outcome\":\"refused\"', '\"outcome\":\"allowed\"') WHERE seq = 3"

$ tokening flight verify --db tampered.db
Broken at entry 3: entry 3 does not match its own hash: its contents were changed after it was written.
Entries 1 to 2 verify. Last good head: 2:496294b10a0b0e81d364b9298ce439ecffe7b60b2266b5d22a7b3c984291d5b8

Exit code 1. Replay on the same copy prints the edited line, now reading allowed (arg_mismatch), which makes no sense on its face, and then ends with a warning that the chain is broken at entry 3 and nothing from there on can be trusted.

Deleting the attempt

Second copy: entries 2 and 3 removed outright.

$ tokening flight verify --db deleted.db
Broken at entry 4: entries 2 to 3 are missing between entries 1 and 4.
Entries 1 to 1 verify. Last good head: 1:4052375b6fae7a73dacbc43d2b136a2411eba103b96305c3bc474d61407b2c5b

Third copy, for completeness: the last three entries cut off the end. Nothing is missing in the middle, so the links are fine. SQLite’s counter isn’t.

$ tokening flight verify --db truncated.db
Broken at entry 20: the database assigned entries up to 20 but the log ends at entry 17: entries were removed, or the chain was rebuilt shorter.
Entries 1 to 17 link to each other, but so would a rebuilt chain. Check them against a head saved earlier before trusting them.

The counter shows entries are gone. It can’t show where they went, so verify doesn’t guess.

A careful attacker rebuilds the chain

The edits above are what a hurried person does. A careful one deletes entries 2 and 3, renumbers everything after them, recomputes every hash and resets the counter. The hash format is public on purpose, so this is a few dozen lines of code. Before trying it, the case study script recomputed all 20 hashes of the real log from the written format, and all 20 matched.

$ tokening flight verify --db rebuilt.db
Intact: 18 entries, 1 to 18.
Head: 18:7e3e9fbcc20ea6bf12120459cbbfa59070d908ebca4f6e94b99194900690fbfc

The chain alone can’t catch this, and we’d rather say so than hide it. Replay on the rebuilt copy shows the agent publishing post 7 and then hitting the exhausted grant. The attempt on post 8 is gone.

This is what flight head is for. At the end of the session the head was 20:adff53dd... and it had been copied out of the database. Checked against the rebuilt copy:

$ tokening flight verify --db rebuilt.db --head 20:adff53dd54fbae58a0df9c57fa306b995135b864d228365fc9bd2ca2511fc276
Broken: the saved head is entry 20 but the log ends at entry 18: entries were removed, or the chain was rebuilt shorter.
The chain does not match the saved head.
Entries 1 to 18 link to each other, but so would a rebuilt chain. Entries after the last point that matches a saved head cannot be trusted; check an earlier saved head to find that point.

Caught, exit code 1. The real change was at entries 2 and 3, and the saved head can’t show that. It shows the log was rewritten, not where. When we first ran this on 0.1.3, verify said “entries were removed from the end” and then “Entries 1 to 18 verify”, which pointed the wrong way. 0.1.4 words it as above.

We also tried a rebuild that keeps the length. Entry 3 flipped to allowed, every hash from there recomputed, numbers and counter untouched. Verify on its own said “Intact: 20 entries”. With the saved head:

$ tokening flight verify --db rebuilt-edit.db --head 20:adff53dd54fbae58a0df9c57fa306b995135b864d228365fc9bd2ca2511fc276
Broken at entry 20: entry 20 has a different hash from the saved head: the log was rewritten from entry 20 or earlier.
The chain does not match the saved head.
Entries 1 to 19 link to each other, but so would a rebuilt chain. Entries after the last point that matches a saved head cannot be trusted; check an earlier saved head to find that point.

The rebuilt entries all link up, so they agree with each other and that’s all. Once a saved head fails, nothing after the last head you trust should be taken at face value. On 0.1.3 the second line here read “Entries 1 to 19 verify. Last good head: 19”, which made rewritten entries sound fine. 0.1.4 no longer says that.

The original database, against the same saved head, says “The saved head is in the chain.”

What to take from it

A changed or deleted entry is caught by the chain on its own, and so are entries cut off the end. Verify names where it broke. A full rebuild needs a head saved somewhere the database’s owner can’t rewrite: a ticket, a commit, a message to someone else. How often you save one sets how much history a rebuild could quietly change. The recipes post has a way to do it from a job you already run, and the flight recorder post covers the design.