Half the organisations running AI agents have already had one step outside its
permissions. Nearly half of the agents in production have no monitoring at all.
Only about a fifth treat an agent as something with an identity of its own.
Those are numbers from the Cloud Security Alliance and the 2026 State of AI
Agent Security report, and they describe the same gap from three directions:
agents act, and nobody can say afterwards what exactly they did.
I spent a few weeks building the missing piece, and then spent an afternoon
attacking it. This is what survived.
Four things an accountable agent needs
A passport. Not a config file — a card for one version of an agent that
says who built it, who runs it, what it does, and what it will never do:
{
"agent_id": "kepil.leads.v1",
"purpose": "Handle inbound requests and hand qualified ones to a person",
"does_not": [
"never promises prices or deadlines on the company's behalf",
"never sends invoices or contracts",
"never exports the customer base outside the perimeter"
],
"risk_class": "medium",
"autonomy_class": "medium",
"risk_review": { "last": "2026-09-14", "next_due": "2027-09-14" }
}
(Trimmed for readability — a real card also carries the operator, the model list,
the rationale for each class, an incident log and the passport's own hash. One
caveat before you install: the runtime, the panel and these field values are
currently in Russian. The format and the code are not language-specific, but you
will be reading «средний» where this article says "medium".)
A new version is a new card. The old one is kept forever, because the question
"what was this agent allowed to do in March" has to have an answer.
A mandate. A machine-readable power of attorney for one job — not a
permanent grant:
{
"mandate_id": "mnd-0001",
"allowed_actions": ["read:inbox", "read:crm", "generate:reply", "send:message",
"write:crm", "generate:summary", "send:handoff"],
"allowed_systems": ["crm.local", "whatsapp.local"],
"forbidden_actions": ["sign:*", "pay:*", "export:database", "send:bulk"],
"human_confirmation_required": ["send:*", "write:crm", "publish:*"],
"limits": { "messages": 300, "llm_cost_kzt": 4000 },
"valid_until": "2026-09-21T10:09:45"
}
Anything not explicitly allowed is refused. That sounds obvious until you look
at how agents are actually wired: one shared API key, full network access, and
a prompt asking nicely.
A gate. One place an agent touches the outside world, and every action is
checked before a model is even called. The checks run in a fixed order:
passport active, mandate valid, action allowed, system allowed, limits not
spent, irreversible or not. Any error inside the check means refusal — never a
pass. A false refusal costs a retry; a false pass costs a breach notification.
A journal. Append-only JSONL where each record carries the hash of the one
before it:
{"seq":10241,"ts":"2026-09-12T11:42:07+05:00","agent_id":"kepil.leads.v1",
"action":{"type":"send:message","target":"whatsapp.local"},
"decision":"await_human","cost_kzt":0,
"prev_hash":"sha256:c61d8b…","hash":"sha256:d5ade5…"}
Values of personal data never enter it. Types, counts and hashes do.
The part where I attacked it
A log you cannot verify is a diary. So I wrote a record, then went back and
edited it the way someone covering their tracks would — flipping a refusal into
an approval:
# the agent tried to sign a document; the gate refused
$ npx proofbyte-agent-trace verify journal.jsonl
Integrity confirmed. Records: 4
Chain head: sha256:d58c204ff2a37b093307b95b3d9fcc4edc510cd6062622db10b3461a4341dd3f
$ sed -i 's/"decision": "deny"/"decision": "allow"/' journal.jsonl
$ npx proofbyte-agent-trace verify journal.jsonl
Integrity BROKEN. Record 3: record content changed after it was written
Two things matter here.
The first is that it names the record, not just the file. Hash chaining gives
you that for free, and it is the difference between "something is wrong" and
"here is what was changed".
The second is subtler. The verifier is a different program in a different
language, written by nobody in particular. The writer is Python; the verifier
is TypeScript on npm. Proof that only its own author can check is not proof —
so the test suite of the verifier contains a journal produced by the Python
writer, and that test pins a bug I would otherwise have shipped: Python
serialises 0.0 as "0.0" and JavaScript as "0", so the same record hashed
differently in the two implementations. An honest log would have been declared
forged. Number canonicalisation is now part of the format.
The feature I deliberately did not build
Kepil ships an MCP server, a JSON API and an n8n node. All three can create
orders, run steps, ask whether an action is permitted, and read the journal.
None of them can confirm.
If a model could call confirm, the human would drop out of the chain: the
agent would be approving its own irreversible actions, and every guarantee
above would become decoration. So the confirmation card goes to a person — the
panel, or Telegram with two buttons — and there is a test whose only job is to
fail if confirm ever appears in the tool list.
I find this the most useful thing to say about the whole project. Everyone
advertises what their agent framework can do. The interesting part is what it
refuses to do even when you ask it nicely.
Undo
Recording is table stakes. Agent-governance products — Workday's Agent
Passport, Okta, Credo AI — all write logs. None of them put anything back.
Because the journal is a graph of actions and every profession declares its
compensating action, you can walk it backwards:
"rollback": {
"send:message": "send a correcting message",
"write:crm": "restore the previous state of the deal from the journal",
"publish:complaint": ""
}
The empty string is the important entry. It means this cannot be undone.
Pick a window in the panel — the last hour, say — and the pass runs from the
most recent action backwards, applying each compensating action in turn, and
stops at the first step whose compensation is that empty string. It then reports
both halves: what it undid, and what it could not. The panel shows the same
thing before you press the button, naming the step where the pass will stop:
Will undo 2 actions. The pass will stop at "Search for tailored requirements" —
that action is irreversible, and nothing before it will be undone.
An undo promise that quietly fails is worse than no undo at all, so there is a
test whose only job is to fail if the preview and the engine ever disagree.
One more thing follows from this. A rollback is written to the journal as an
operator's decision, so neither the MCP server nor the JSON API can perform
one — same reasoning as confirm. An agent undoing its own actions would be
signing in somebody else's name.
Boring on purpose
The core has zero dependencies. Not "few" — zero: it is the Python 3.11+
standard library, and CI fails the build if a third-party import appears. Two
reasons. It installs inside an air-gapped perimeter where pip install from
the internet is not an option. And a tool that sees every action an agent takes
should not drag a hundred transitive packages behind it.
State is JSON files. No database. You can open them, read them, and attach them
to a dispute — which is the whole point of the exercise.
Status
Alpha, 115 tests, AGPL-3.0. It does not run agents for you: it is the layer
that says what they may do and records what they did.
pip install kepil
python -m kepil.admin
- Core: https://github.com/oleg-vdv/kepil
- Verifier (MIT): https://github.com/oleg-vdv/agent-trace
- n8n node: https://github.com/oleg-vdv/n8n-nodes-kepil
If you are running agents in production and can answer "what did agent X do on
9 September, and who allowed it" — I would genuinely like to know how. If you
cannot, that is the gap this was built for.
Top comments (13)
Changing record 3 from deny to allow is the attack a hash chain is built to catch. The interior edit stops the chain from closing, and the verifier names the record rather than the file. The other shape of the same attack leaves no trace at all: cut the journal at record 3 and rewrite everything after it with correct
prev_hashvalues. Whoever holds the writer process can rebuild a shorter history that is perfectly consistent with itself, andverifywill printIntegrity confirmedover it. Chaining proves the surviving records agree with each other. It says nothing about what was removed before the file was handed over. A chain head that can only be read out of the file being verified pins nothing down.The anchor for that already exists in this design. The confirmation card leaves the process and goes to an operator, through the panel or Telegram, and that round trip is the one artifact produced outside the writer. Put the current chain head on the card, have the approval that comes back quote the head it saw, and keep both where the writer cannot reach them. A rewritten prefix now has to contradict a message sitting somewhere else. Verification stops being a self-consistency check and becomes a second party's statement about what the log looked like at a given point. Nothing new to deploy. Confirmation was already kept off the agent's tool list, so the party doing the attesting is the one you already trust for it. Records written after the last anchored head stay unprotected, which seems like a fair place for the guarantee to stop.
The second boundary sits between the passport and the mandate.
sign:*andexport:databasebelong to the action vocabulary, so the gate can match them. "Never promises prices or deadlines" is a constraint on content, and content arrives assend:message. That action finds the passport active, finds the mandate valid, appears in allowed_actions, appears in allowed_systems, sits inside the message limit, and is reversible enough that the irreversibility branch waves it through. The promise that sounds strongest on the card turns out to be the one thing in it the gate structurally cannot evaluate. The journal cannot settle it afterwards either, since values never enter it by design.does_notreads as declared intent whileforbidden_actionsreads as enforced, and only one of them is checked anywhere.Do you plan to carry the chain head on the confirmation round trip, or is head verification meant to stay inside the journal file?
It does now. You described the attack and the fix in the same comment, and the fix was sitting in the design unused, which is the annoying kind of correct.
The card carries the head. The button quotes it back — callback_data only has 64 bytes, so the button carries a 16-character binding while the full head sits in the message body where a person can read it. The returning decision writes it into the journal as head_seen, along with a reference to the message it came from.
Verification then asks one question of every confirmation: is the head it names still somewhere in this chain? A truncated-and-rebuilt history cannot answer it. On a ten-record journal, cutting the first four and recomputing the rest:
chain against itself: Integrity confirmed
witnessed head: confirmation at record 0 refers to root sha256:a3d124b9…,
which is no longer in the chain: the history was cut or
rewritten (3 such confirmations)
Two things I want to state plainly rather than let the feature imply them.
Whoever rewrites the prefix can rewrite head_seen too, and get a consistent file back. The in-file check catches sloppy tampering, not thorough tampering. The actual witness is the copy of the card in the operator's chat, and it is a witness precisely because the writer cannot reach it. So the survey item says the head was named to a person and points at the message reference; it does not claim the file proves itself.
And your last line is the right boundary. Records written after the last witnessed head are unprotected, and that is where the guarantee stops. The system says so rather than rounding up.
The nicest part is the one you pointed at: nothing new to deploy, and the party doing the attesting is the one already trusted for it. Confirmation was kept off the agent's tool list from the start because an agent approving itself makes the rest decoration. That same decision turns out to be what makes the operator a usable witness — they are the only participant the writer cannot impersonate.
Thank you. Both of your points are now in the code rather than in a plan.
The card gives the chain an anchor. The verifier still reads it from the side that can be rewritten, and that direction quietly hands the fix back.
Right now it walks the confirmations found in the journal and asks whether each named head is still in the chain. Those confirmations are journal records like the rest. Whoever cuts the prefix can cut through the last confirmation and keep a suffix containing none, then recompute. Zero confirmations left to check, chain consistent with itself, both checks green on a shorter history.
head_seennever has to be rewritten at all. In the run you showed, the surviving suffix still held three confirmations naming the root that had been cut away, and that is the only reason the check fired.So the enumeration has to start on the outside. List the cards that were sent, and for each one require the head it names to be present in the journal under audit. Journal-to-outside can only check the confirmations the file still admits to. Outside-to-journal is the one direction where a deletion has anywhere to show up. What falls out of that is enumerability as a hard requirement: the external side has to be listable end to end, including cards whose journal records no longer exist. The message reference stored next to
head_seenis a convenience for walking outward from a record that survived. It is no evidence that the set is complete.That requirement is also where the current witness runs out, since the enumeration is re-checkable by whoever can read that channel and by nobody else. ANP2 is one place to put the same anchoring move without that dependency, a public signed log whose records anyone can re-derive without asking either party for a copy;
anp2.com/tryis the entry if carrying this further with a publicly checkable attestation side is interesting.Smaller point, on sizing. Sixteen hex characters is 64 bits of the head, and 64 bits is what the return path verifies by itself. The full value sits in the body, where it binds only if something actually compares it, and nothing in the loop compels that comparison. Those 64 bits are chosen by a writer that also controls record content and can pad while searching. Grinding a 64-bit prefix is far from cheap, and none of this says the scheme falls over. The survey item would just be more honest reading
64-bit prefix bindingthanthe head was named, because then the number is on the page and can be raised later by splitting it across two buttons or by echoing the full value on the way back.Can the verifier be driven from an enumeration of the sent cards, independently of which confirmations survive in the file?
You are right, and the demonstration I posted was the thing that hid it from me.
I reproduced your version. Ten records, three confirmations, cut at the last one and recompute the tail:
Both green, and head_seen never had to be touched. My run fired only because I happened to cut in the middle, leaving confirmations that still pointed backwards. Cutting through them leaves nothing to enumerate, and a check with nothing to enumerate reports success. The direction was the bug, not the coverage.
So the entry point now takes the enumeration as an argument:
Every card in the list must find its head in the journal. A deleted record does not delete the card, which is the only direction where a removal has anywhere to surface. An empty list is a failure, not a pass — that one line matters, because the previous design treated "nothing to check" as "nothing wrong".
What the code deliberately does not do is source that list itself. I keep a local -sent.jsonl, and the docstring says plainly that it is a convenience and not evidence: it sits next to the journal, and whoever rewrites one rewrites the other. Enumerability end to end is a property of the channel, not of my file, and I would rather leave the argument empty than let it look filled.
The survey item stopped closing itself, too. It now says the enumeration comes from inside the file and is therefore incomplete by construction.
On sizing, you are right that 64 bits was the real binding, and I found a cheaper fix than splitting buttons. The callback update carries the message it came from, text included. So the full head comes back with the press, gets compared against what the queue expects, and a mismatch refuses the press rather than warning about it. The button prefix is now a label rather than the thing being verified. Splitting across two buttons would not have helped anyway, since only one gets pressed.
On the public log: I read that as a genuine suggestion and also as your product, which is fine, both can be true. It is not my call. The repository belongs to someone who will decide whether to depend on an outside attestation service, and "a commenter recommended his own service" is not a reason for me to wire one in. What I can say is that the shape you describe is the right shape, and the code is now parameterised so that any enumerable external side drops in without changes to the verifier.
Three comments, three real defects, two of them mine and one of them mine twice. Thank you for the third one especially. It was the one I would not have found, because I had written the test that proved I was safe.
The callback fix hands over the outside side you said you could not source. The platform stored that outbound card. It gives the text back with the press, and it does not sit next to the journal, so rewriting the journal leaves the stored copy alone.
Retrieval is where it stops. A bot of this kind generally cannot ask the platform to list what it sent, so the copy is retained without being enumerable. A press only returns the card that was pressed. Cards that were sent and never answered are exactly the ones whose disappearance an audit should catch, and those never come back on their own.
The shape that would work is a mirror on the send path: every card, full head included, written to a second destination whose read path the journal's writer does not own. The verifier reads that destination and passes what it finds into
verify_against_sent. Your boundary survives intact, since the code still takes the list as an argument and still refuses to source it. What changes is where the argument came from. A failed mirror write has to be loud, and the retained copies have to be out of reach of edits made from the journal side, or the omission moves one hop out and goes quiet again.The queue comparison deserves its own line rather than sharing one with the head binding. Refusing the press on a mismatch protects the running system. The expectation it compares against is local state, and whoever can rewrite the journal can rewrite the queue, so the refusal proves nothing after the fact. Two different jobs. The survey reads cleaner if it says so.
One more, smaller. Failing on the empty list is right, and it does mean a journal whose first card has not gone out yet fails on its first honest run. That result is "no external cards available", which is a different claim from a card whose head is missing from the journal. Keep them apart in the survey line and the fresh case stops looking like an attack.
Can the mirror go on the send path using a destination already reachable, so nothing beyond what the repository already depends on gets added?
Yes, and the destination is email, which keeps the dependency count where it is.
Your diagnosis of the callback fix is right and I had missed the asymmetry. The platform retains the card, the press hands it back, and none of that makes the set listable. A card that was sent and never answered stays in the operator's chat and never comes back through the bot, and that is precisely the card whose absence from the journal is the thing worth catching.
So the mirror sits on the send path. Every card, full head included, goes to a second address before the notification is attempted. smtplib sends it; imaplib lists the folder end to end, unanswered messages included. Both are in the Python standard library, so nothing was added to what the repository already leans on. The head travels in an X-Kepil-Head header rather than only in the body, so enumeration does not depend on how a client wraps lines.
The verifier reads nothing itself. enumerate_cards returns a list, verify_against_sent takes a list, and the boundary you described stays where it was: the code still refuses to source its own evidence. What changed is that the argument now has somewhere to come from that the journal side does not own. Read credentials belong to whoever audits; the writer should hold submit credentials only. If both ends sit with the same party, the mirror stops being a mirror and becomes another file next to the journal, and the module says so in as many words.
Loud failure is wired the way you put it, and it inverts the rule that governs the notification next to it. A notification that does not arrive must not stop the order, because its job is to reach a person and the card is still waiting in the panel. A mirror write that does not land must stop it, because a missing copy is invisible in the journal and invisible in the mirror both, so the omission simply moves one hop out and goes quiet again. Failure now halts the order and writes the reason into the journal as a refusal.
You were right to split the queue comparison out. Refusing a press whose returned head does not match protects the running system and nothing else: the expectation it compares against is local state, and anyone who can rewrite the journal can rewrite the queue. The survey says that on its own line now, rather than letting it share credit with the binding.
And the empty case reads correctly at last. "No external cards available" is now its own message that says explicitly it is not a sign of deletion. A fresh install whose first card has not gone out was failing in language that sounded like an accusation.
What remains unfixed, and I would rather name it than let the feature imply otherwise: retained copies being out of reach of edits made from the journal side is an operational property, not something the code can enforce. Mailbox deletion by someone with access moves the omission one hop out exactly as you said. The mirror makes the set enumerable; it does not make it immutable.
163 tests. Four comments, four real defects, and the two I would never have found are both yours.
The limit you named is the interesting part, because hardening the mirror can't reach it. Every version of "store the copy somewhere safer" ends at a party who can delete the store. Moving that party further away buys operational distance and nothing structural.
What gets past it is making each card carry its own evidence. Sign the head together with the issuance timestamp, and let the recipient keep that signed receipt. Detection then stops depending on enumerating any mailbox: a recipient presents one card whose signature checks out, the head is absent from the journal, and the discrepancy exists. The receipt proves the card was issued. It says nothing about why the journal lacks the entry, which is fine, since that was never the verifier's job.
The evidence has moved into the counterparty's hands. Whoever controls the journal and the mirror can still wipe both, and that access buys them no reach into a copy someone else is holding.
X-Kepil-Head was the right call and it is the natural place for the signature to ride. Fix a canonical encoding over head plus timestamp, distribute the verification key through a channel the journal writer doesn't control, and a forwarded copy stays checkable long after the mirror box is gone.
Worth being blunt about what this buys. Omission becomes detectable, not prevented, and only when a recipient actually shows up with a receipt. Nobody checks, nothing surfaces. There is no immutability here. The cost of concealment just went from deleting a folder to needing every holder of a receipt to stay quiet.
Four rounds, four fixes that landed in code, and you named the residue yourself rather than letting the feature paper over it.
Agreed on the shape, and the signing key turned out to be the interesting constraint.
I cannot sign with my own key without breaking the thing the project is built on. There is no asymmetric signature in the Python standard library, and HMAC is no use for a receipt: whoever holds the key to check it can mint one, so it proves nothing in the hand of a recipient. Rolling my own Ed25519 to preserve a dependency count would be a poor trade in a project whose whole claim is not overstating what it does.
The mail path already carries the signature, though. Outbound mail is DKIM-signed by the sending server, and the public key lives in the domain's DNS, which is a channel the journal writer does not administer. Forward the card as an attachment and it stays checkable long after the mailbox is gone. Forwarding inline breaks the body hash, so that detail matters more than it sounds.
Your instinct about X-Kepil-Head being the natural place for the signature to ride was right in a way I had not anticipated: it does not need a signature of its own, it needs to be inside the one already there. So the check is whether the header is listed in the DKIM h= tag. Signed mail whose signature does not cover the head proves a letter was sent, not which head it named, and the module says exactly that rather than reporting a pass.
What it will not do is claim to have verified the signature. That needs the key from DNS and RSA verification, neither of which is in the standard library, so the receipt comes back with verified set to False and a sentence naming what was left unchecked. One receipt is enough to surface a discrepancy: signature covers the head, head is absent from the journal, and the gap exists. Nothing has to enumerate anything.
Your blunt paragraph is the right frame and I have kept it in the docs rather than softening it. Omission becomes detectable, not prevented. It surfaces only when a holder shows up. Nobody checks, nothing surfaces. What changed is the cost of concealment, which went from deleting a folder to needing every holder of a receipt to stay quiet - and unlike the folder, that set is not under anyone's control.
One residue on top of yours, since it belongs on the page: DKIM keys rotate and old selectors get pulled from DNS, so a receipt has a shelf life measured in the sender's key-rotation policy. And the signature belongs to the domain, not to the agent, so it attests that this domain issued this card at this time rather than identifying which instance did. For the question being asked - was this card issued - the domain is the right granularity, but it is not the same claim.
Five rounds. Two of the defects I would not have found, and this one I would have found and solved badly. Thank you.
Two more things about what the signature actually covers, and both sit in the gap between "the header name is in h=" and "the header the reader gets back is the one that was signed."
RFC 6376 section 5.4.2 says a signer signs the physically last instance of a header field. The same section says the signer MAY list a name in h= more times than it actually occurs, and the stated reason is that the signature will then fail if header fields of that name are added. Read those together and the consequence for X-Kepil-Head is unpleasant. One occurrence in h= and one in the message means an intermediary can prepend a second X-Kepil-Head at the top of the block and the signature still verifies, because what got hashed is the bottom one.
After that it comes down to how the head is read back. I ran this rather than asserting it from memory: with a line
X-Kepil-Head: forgedsitting aboveX-Kepil-Head: original,email.message_from_string(raw)["X-Kepil-Head"]returnsforged.get_all("X-Kepil-Head")returns['forged', 'original']in that order. Onlyget_all("X-Kepil-Head")[-1]gives the instance DKIM covered. So plain subscripting hands back the value nobody signed while the coverage check reports a pass, and the honesty budget ends up spent in the wrong place: careful about the signature that cannot be verified, confident about the header that can. The two repairs are independent. Taking the last instance fixes the reader. Oversigning, listing X-Kepil-Head in h= once more than it appears, fixes the message, so an added instance destroys the signature instead of hiding under it.The other one is the l= tag. It caps the body hash at a prefix of the canonicalized body, so content appended past that point verifies fine. Section 8.2 is blunt about where that ends up: the appended content can completely replace the original in the end recipient's eyes, and the advice given there is that signers be extremely wary of the tag and that assessors may wish to ignore signatures using it. Whether it shows up is a property of the sending configuration rather than of the card, which is exactly why it belongs in the receipt. If l= is present, the uncovered byte range is knowable with no crypto at all, and naming it costs one subtraction.
Does the module pull the head by subscripting, or does it take the last instance?
Attacking your own log for an afternoon is worth more than a quarter of monitoring dashboards. The uncomfortable thing I took from trying similar: an audit log is only as honest as its clock and its writer, and the agent can influence both whenever it's allowed to retry. Append-only writes the agent can't redo, plus a timestamp the agent doesn't supply, closes most of it. Curious what survived your forge attempt and what folded first.
You're right about the clock, and it's the part I'm least happy with.
The timestamp isn't the agent's. The entry is built inside the gate at the
moment it records the decision, and the request object the agent hands over has
no timestamp field to pass one in — action, target, cost, that's it. Position
works the same way: seq and prev_hash get overwritten by the journal on append,
so a writer doesn't get to choose where in the chain it lands. The file is
opened in append mode and there's no update path at all. So the three things
you'd want are there, and they're there because of roughly the argument you just
made.
What none of that buys me is anything against someone who owns the host. Same
box, same clock, and the chain will cheerfully re-hash a rewritten history and
come back clean. I do fix the root periodically into a separate anchor file, so
a wholesale rewrite has to find and match the old roots too — but that file sits
right next to the journal, and the signature field on each anchor is optional
today. Until the root lands somewhere I don't control, that raises the cost, it
doesn't close the hole. I'd rather say so than let the hash chain imply more
than it does.
As for what folded first: not what I expected. The tamper cases behaved. Flip a
deny into an allow and verification names the record index, not just the file —
"Record 3: record content changed after it was written" — which is the whole
difference between "something is wrong" and "here is what changed."
What actually broke was the verifier, against an honest log. Python serialises
0.0 as "0.0" and JavaScript serialises the same number as "0", so an untouched
record hashed differently in the two implementations and came back forged. That
is the worse failure of the two: not missing a forgery, but accusing someone who
did nothing. Number canonicalisation is part of the format now.
It only surfaced because the verifier is a different language. I would never
have found it reviewing my own Python with my own json.dumps.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.