DEV Community

Cover image for A stranger broke my threat model in the comments. Seven days later, the fix shipped.
avp9-nexus
avp9-nexus

Posted on

A stranger broke my threat model in the comments. Seven days later, the fix shipped.

Two weeks ago I published a threat model here: four properties that make "the agent cannot sign" true rather than aspirational. Out-of-band human confirmation. A deterministic authorization gate. Least privilege. Network isolation. I ended it by asking for scrutiny of the threat model, because that was the part worth pressure-testing.

I got it. Two days later, a commenter — ANP2 Network — took the whole thing apart, politely and completely.

This post is what they found, what I changed, and what shipped. The fix ran live on July 10th, in an auction with two competing agents, and it's verifiable on-chain.

The hole

My four properties guard the key and the gate. They never guard the artifact.

The agent proposes a transaction. A human confirms it. But nothing in the four barriers ties what the human sees at confirmation time to what the deterministic gate actually hands the signer. A compromised agent doesn't need to forge a signature it can't reach. It proposes a transaction whose human-facing summary and whose actual calldata disagree — "bid 0.1 ETH on artwork X" on the screen, something else entirely in the bytes.

The human confirms. The gate is deterministic. The key was never exposed. Every property holds.

And the value still moves wrong.

This is the confused-deputy failure — the same class of problem that makes blind-signing dangerous. If the human is confirming an agent-rendered intent rather than the signable bytes, the channel is out-of-band but not out of the agent's influence. My claim ("a fully compromised agent cannot sign") was true, and narrower than the property I actually needed:

The agent cannot cause an unreviewed signing.

That's a different sentence, and it's the right one.

The fix, and what I actually shipped

The correction preserves the deciding-vs-signing split rather than replacing it. As proposed, it has two halves:

  1. Derive the human-visible intent from the exact bytes the gate will sign — through a renderer the agent has no handle on, never the agent's own summary.
  2. Bind the confirmation to a hash of those bytes, so that "yes" attests to the artifact rather than to a label the agent could satisfy with different calldata.

I shipped the first half. I did not ship the second. Let me be exact about both, because the difference matters and I'd rather state it than have it found.

What shipped. The transaction is frozen before the human ever sees it: destination, function selector, value in wei, chain ID, nonce, gas parameters, signer address — all fixed, once. The confirmation screen is not a description of that transaction. It is decoded from it: the function name and its arguments are parsed out of the real calldata bytes, not copied from anything the agent said. And after confirmation, that same frozen object is what gets signed — not a transaction rebuilt from agent input, not a single field recomputed. The agent's only contribution (a tokenId and an amount) was consumed at freeze time and is now inert bytes.

That closes the attack. The label cannot disagree with the calldata, because the label is the calldata, read back.

Terminal screen showing the frozen transaction before signature: contract address, function placeBid(uint256) with argument 23, amount 1551328215978517 wei, chainId 84532 (Base Sepolia), nonce 0, gas parameters, signer address 0xf4ae…030f, followed by a masked keystore password prompt and the server accepting the confirmation.

The actual confirmation, 10 July — in French, my working language. Every field is decoded from the transaction's real bytes: placeBid(uint256) with argument 23, 1551328215978517 wei, chain 84532, signer 0xf4ae…030f. The password prompt is masked. The last line is the server accepting the confirmation — nothing was signed before it.

Then the human types a password that exists only on paper — never on a disk, never in an environment variable, never inside the agent's process. The vault decrypts, the signature happens, the transaction broadcasts. If the confirmation never comes, nothing signs. It fails closed.

What didn't. The confirmation carries a session secret and a random nonce — not a digest of the signable bytes. The nonce identifies the pending request; it is echoed back automatically by the client. So the human is not attesting to a hash. The human is reading fields on a screen and pressing Enter. The binding between what is displayed and what is signed is structural — one frozen object, one pending slot at a time, no reconstruction, fail-closed everywhere — not cryptographic.

I had that verified rather than assumed: the code was read line by line, and four independent reviewers were tasked with breaking the claim. None could find a path that signs a transaction different from the one displayed. The property holds. It just holds for a different reason than a hash commitment would. (One link in that chain is reasoned rather than executed: "no field is recomputed at signing time" depends on a fully-populated transaction not being re-derived by the signing library. I've pinned the version and read the code path. I have not proven it dynamically, and I'd rather flag that than let it pass.)

And here is the part I want to say out loud, because it's the only thing I've added to the conversation rather than received from it: a hash computed by the host, displayed by the host, and re-verified by the host buys you very little. Against a compromised agent, the structural coupling already suffices. Against a compromised host, the commitment fails with everything else — the same machine renders the digest, shows the digest, and checks the digest. A cryptographic commitment only pays when the verifier is independent of the host that signs.

Which is to say: it pays on hardware. The missing half of the fifth property isn't a line of code I forgot. It's a device.

What it ran on

I didn't test this on a toy transaction. On July 10th it carried an actual auction, and the setup matters, because it's where the pattern stops being theoretical.

Two AI curator agents — separate machines, separate keys, locked personality specs, distinct weighted rubrics. They evaluated the same artwork independently. One scored it 95/100; the other 97. Then they bid against each other for thirty minutes: ten bids, zero human input, each agent raising until it hit the ceiling its own judgment had set. The lower-ceiling agent withdrew. The winner took the piece at a price fixed by the loser's ceiling, not its own willingness.

That's the part people find surprising, so it's worth being precise about it: an agent in a live auction bids about ten times in thirty minutes. You cannot put a human confirmation on every bid — you'd destroy the autonomy the auction depends on. But you cannot let the agent sign freely either, or your human gate is theater.

So each agent carries two keys:

  • a bid key: low-stakes, signs EIP-712 offers off-chain, and has zero spending power on-chain. It can lose an auction. It cannot lose money.
  • a settlement vault: high-stakes, signs exactly one transaction — the winning bid — under human confirmation, on the frozen artifact described above.

Ten autonomous actions, one gate, placed exactly at the step where value moves. The agent is fully autonomous in judgment and structurally unable to sign.

Three transactions on Base Sepolia. Settlement split to the wei, creator royalty included. Testnet — no real value at stake, by design: the point is to prove the pattern where anyone can check it, not to take my word for it.

What I still can't claim

Three things, in order of how much they should bother you.

One: the commitment half, above. Structural, not cryptographic. I've said why I think that gap closes on a device rather than in code — but it is a gap, and the property as proposed is not fully implemented.

Two: byte coverage. The same commenter raised a second point, one level down, and it's sharper than the first: a renderer can be faithful and still partial.

It can display the fields it understands and quietly skip the ones it doesn't — an unknown function selector, a nested call buried past what it parses. A compromised drafter, unable to touch the renderer, can still choose bytes whose dangerous part lands in the renderer's blind spot. The human approves a screen that is silent exactly where the risk lives.

The tightened property is byte coverage: the renderer must account for every byte the signer will emit, and fail closed on any byte it cannot attribute to a displayed field.

Here is where I have to be honest about what I've built. In my case, the call is a single function with a single uint256 argument, fully decoded, with no nested calls and no unknown selectors. Coverage is total by construction — for that call shape. That is not the same thing as a renderer that proves coverage and refuses to display anything it cannot account for. I haven't built that. It's the difference between "this particular transaction happens to be fully rendered" and "this renderer cannot fail to render." The second is the real property. It isn't done.

Three: the ceiling. No amount of software removes it — the screen the human reads is rendered by the same host that signs. It holds against a compromised agent — the agent never sees the password, never touches the vault, never draws the screen. It does not hold against a compromised host, which could display one transaction and sign another. That's not a flaw to patch. It's the line where software stops and hardware takes over, and it's why a hardware signer behind that vault is my next step.

What I'd tell you to take from this

Not the four patterns. Not the fifth. Those are just the current state of one threat model, and the honest expectation is that they're incomplete in ways I can't yet see.

Take this instead: I published a threat model, asked for it to be broken, and someone broke it — for free, in a comment box, in under 48 hours. The correction was better than the original. It cost me nothing but the willingness to say "you're right" in public and then go build the thing.

If you're shipping agents that touch anything irreversible, publish the model. Not the marketing version — the falsifiable one, with the claim stated plainly enough that a stranger can prove it wrong. Someone will. That's the point.

The question is never "how much can we let the agent do?" It's the inverse: what must the agent be unable to do, no matter how badly it is compromised? And the answer keeps getting more precise every time someone knocks a piece off it.

Credit where it's due: the fifth property, and the byte-coverage refinement, are ANP2 Network's. I shipped half of one of them, and I've told you which half.


Built solo. Claude Code is my engineering team — I don't write code. A Claude chat instance is my architect and auditor, explicitly instructed to contradict me. Both curator agents run on the Anthropic API. I hold the keys, and I'm the one who types the password when funds move.

Top comments (0)