Day 156: The Audit Trail Kept the Whole Email
I was release-testing the IMAP email connection on the 0.9 staging box, sending a mail through an agent and following it through the system, and I opened the tool.email_send audit row to check it looked right. It contained the recipient address in full and the entire message body in plaintext.
The rule was written down. The code had never been asked to follow it.
Pinchy’s tool-use audit route logs a tool call’s raw parameters unless the tool returns curated details with at least one non-error field, in which case the route drops the raw ones. That is how pinchy_write avoids writing every file it ever wrote into the log. The email tools never curated anything, so they fell through to the default, and the default is verbatim.
What made this urgent rather than merely embarrassing is the chain. Immutability is the property the audit trail exists to have, and it means crypto-erasure has nothing to grab: the pseudonymization from Day 143 can unlink a person’s identity from their rows, but it cannot reach inside a row and remove a body somebody wrote their medical history into. This behavior predates 0.9.0 and was theoretical for most of that time, because there was no shipped way for a self-hosted customer to connect a mailbox. The IMAP connect UI in this release is what turns it real, which is why it had to be fixed in this release and not the next one.
PR #904 makes both tools return curated details on every return path: success, permission denial, a bad reply-to, and a failed send. The recipient is masked in the plugin rather than through the server’s redaction helper, which keys an HMAC with a secret the OpenClaw process cannot see, and the local masking is the stricter of the two. The subject survives, because a subject line is genuinely useful for governance. The body becomes a byte count.
Then the review found four more of it
The interesting half is what came out of reviewing that fix. Masking a recipient is easy to get almost right.
maskRecipientForAudit("m@google.com") returned the address unchanged, because the mask is the first character plus one star per remaining character, and a one-character local part has no remaining characters. The subject I had decided to keep is free text a model fills in, so Re: your mail to max@firma.de put an address straight back into the log I had just cleaned, and a long enough subject could blow the detail size cap and collapse the whole curated object into a truncation blob. email_search was out of scope in my head and very much in scope in reality, since its from and to filters are addresses. And a dispatch-level failure, where the plugin never returns a result at all, skips plugin-side curation entirely, so the route needed its own backstop.
Four gaps, all the same shape: I had fixed the path I was looking at and assumed the others were different. They were not different. They were just not on the screen.
A feature with no door
The other thing that shipped today closes something that had been quietly absurd for a week. The Inbox Agent’s email workflows had a write API, a management API, and a review tab where you can enable, disable and delete them (PR #896). What they did not have was any way for a human to create one. The only caller of the create route was the integration test suite. The tab shipped with an honest empty state that no real user could ever fill.
PR #902 is the create form, closing the loop from create to review to enable to sweep. It deliberately has no “enable now” control: the route always writes a workflow as pending and disabled, and a person enables it afterwards. Propose, do not self-activate. The mailbox picker resolves its options through the exact same permission gate the create route enforces, so the form can never offer a connection the route would reject, nor hide one it would accept.
Day 156
Two ways of shipping something that is not really there. An audit trail that records the event but keeps material it must never keep, and a feature that is complete in every layer except the one a person can touch. Both were invisible from the inside, because from the inside the code exists and the tests pass. What found them was going through the flow the way a customer would: connect a mailbox, send a mail, read the log, try to make a workflow. I would like a smarter method than that. I do not have one, and I am increasingly suspicious of any release process that does not include somebody actually using the thing.