DEV Community

Cover image for Why 'monitoring' isn't enough for AI agents — and how I made delegation cryptographically verifiable
kironovlaziz-del
kironovlaziz-del

Posted on

Why 'monitoring' isn't enough for AI agents — and how I made delegation cryptographically verifiable

The problem nobody talks about with AI agents

We're rushing to give AI agents autonomy. An orchestrator agent calls a research agent, which calls a writer agent, which calls a tool. Each hop, one agent hands some of its authority to another.
Every "AI governance" tool I looked at solves this the same way: it logs everything. You get a dashboard, a timeline, an audit trail. Which sounds great — until you ask one uncomfortable question:
When an auditor asks "who authorized this agent to spend money / delete data / call that API?", is a log you control actually proof?
It isn't. A log is a claim. If the server writes the log, the server can write anything. Monitoring tells you what a system says happened. It doesn't let anyone prove it independently.
As agents get more autonomous — and as regulation like the EU AI Act starts demanding "verifiable accountability" — I think this gap becomes a real problem. So I tried to close it.

The idea: sign the delegation, not just log it

Instead of recording that Agent A delegated to Agent B, what if the delegation itself were cryptographically signed by A? Then:
Anyone can verify the signature against A's public key
The server holds only public keys — it can verify a delegation, but it can never forge one
An auditor can check the proof on their own machine, without trusting my server at all
That last point is the whole game. "Trust me, here's my log" becomes "here's the math, check it yourself."
I built this into an open-source platform (AI Control Tower), but the technique is general. Let me show the core of it.

Why Ed25519

For signing delegations you want:
Small keys and signatures (32-byte public keys, 64-byte signatures) — these get stored and passed around a lot
Fast verification — you may verify a whole chain of hops
Deterministic signatures — no per-signature randomness to get wrong
Available everywhere — including natively in the browser via WebCrypto
Ed25519 checks every box. It's modern, boring in the good way, and — crucially for the "verify in your browser" goal — supported by the WebCrypto API.

The tricky part: canonical bytes

Here's the bug that will silently break everything if you're not careful.
To verify a signature, the verifier must hash exactly the same bytes the signer signed. If your backend signs a JSON object and your frontend re-serializes it even slightly differently — different key order, extra whitespace, different number formatting — the bytes differ, and every verification fails, even though nothing was tampered with.
The fix is a canonical serialization both sides agree on. In Python (signing side):

import json

def canonical_bytes(payload: dict) -> bytes:
    # sort_keys + no whitespace = deterministic output
    return json.dumps(
        payload,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")

Enter fullscreen mode Exit fullscreen mode

And the matching thing in JavaScript (verifying side) has to produce byte-for-byte the same output. JSON.stringify with manually sorted keys and no spaces gets you there for simple payloads — but test it against real data, because nested objects and unicode will bite you.
Lesson learned: write a test that signs on the backend and verifies with the exact frontend serializer, using awkward payloads (unicode, nested objects, numbers). That one test caught more bugs than anything else.

Signing (backend, Python)

Using the cryptography library (no exotic deps):

from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey, Ed25519PublicKey,
)

def generate_keypair():
    private_key = Ed25519PrivateKey.generate()
    public_key = private_key.public_key()
    return private_key, public_key

def sign_payload(private_key: Ed25519PrivateKey, payload: dict) -> bytes:
    return private_key.sign(canonical_bytes(payload))
Enter fullscreen mode Exit fullscreen mode

When Agent A delegates, you build a payload describing the delegation (who, to whom, what capabilities, when), sign it with A's private key, and store the payload + signature + A's public key.

Verifying — in the browser, offline

This is the part that makes it verifiable rather than trust-me. Using WebCrypto in the browser:

async function verifyDelegation(publicKeyRaw, signature, canonicalPayloadBytes) {
  // import the raw 32-byte Ed25519 public key
  const key = await crypto.subtle.importKey(
    "raw",
    publicKeyRaw,
    { name: "Ed25519" },
    false,
    ["verify"],
  );

  return crypto.subtle.verify(
    { name: "Ed25519" },
    key,
    signature,
    canonicalPayloadBytes,
  );
}
Enter fullscreen mode Exit fullscreen mode

The browser fetches the delegation's payload, signature, and the signer's public key, rebuilds the canonical bytes, and verifies — locally. The server never gets a chance to lie, because the proof is checked on the client. If the math checks out, you see a green "verified" badge; if anything was altered by a single byte, it fails.
(Note: browser Ed25519 support via WebCrypto is now widespread, but if you need to support older browsers, keep a graceful fallback that verifies server-side and clearly labels it as such — don't pretend a server-side check is the same guarantee.)

The other half: capabilities can only shrink

Verifiable signatures answer "did A really authorize this?". But there's a second rule that matters for agent safety:
An agent can never delegate more authority than it holds.
If A can call read and search, it must not be able to hand B write or delete. So every delegation runs a subset check: the delegated capabilities must be a subset of the delegator's own effective capabilities. If B tries to escalate, the delegation is rejected and an incident is raised. Combine that with the signatures, and you get a chain where every hop is both authorized (subset) and provable (signed).

Why this matters more every month

Single-agent systems were easy to reason about. Multi-agent systems — where agents spawn and delegate to other agents — are not. As they spread into companies, "show me the log" stops being good enough. People will start asking "prove it." Verifiable delegation is one way to have an answer.

Try it / steal the idea

The full implementation — signing service, capability validator, a live delegation graph where you click any edge and verify the signature in your browser — is open-source (Apache-2.0), self-hosted, and runs with one Docker command:

git clone https://github.com/kironovlaziz-del/AI-tower.git

GitHub: https://github.com/kironovlaziz-del/AI-tower
I'm a solo developer and this is an early, honest MVP — I'd genuinely love feedback, especially on the canonicalization approach and the capability model. If you're working on agent infrastructure, I'd like to hear how you're thinking about the accountability problem.
Have you hit the "monitoring isn't proof" wall with agents yet? How are you handling it? Let me know in the comments.

Top comments (4)

Collapse
 
reidmarlow profile image
Reid Marlow

Attenuating capabilities at each hop solves the token side, but the operational headache we run into is lifetime mismatch. A parent agent hands a sub-agent a scoped credential for a 3-minute subtask, but if the sub-agent spawns an async background job or retries against a queue, that signature either expires mid-flight or has to be scoped so wide in duration that revocation becomes manual again. Enforcing hard monotonic TTL shrinkage alongside capability subsetting is usually where delegation graphs break in production.

Collapse
 
kironovlazizdel profile image
kironovlaziz-del

This is a sharp point and honestly a gap in what I described — I focused on the capability dimension and mostly hand-waved the temporal one. You're right that TTL is where it gets nasty: capability subsetting is clean because it's monotonic by nature (you can only ever hold less), but time doesn't behave that way once you introduce async jobs and retries.

The monotonic TTL shrinkage idea is interesting — forcing each hop's credential to expire no later than its parent's is the temporal equivalent of the subset check. But the async/queue case breaks the neat model, because the work outlives the delegation that authorized it. A few directions I've been turning over, none fully satisfying:

  • Treat a queued/async job as a new delegation request at execution time rather than reusing the original signature — the credential authorizes enqueueing, not the eventual run. Pushes the problem to "who signs the re-delegation," though.
  • Short TTL + a refresh path that re-checks the capability subset on each renewal, so revocation stays effective but long-running work can continue as long as the chain is still valid.

Both add latency and moving parts. How are you handling it in practice — do you re-delegate at execution time, or lean on short TTLs with renewal? This is exactly the kind of thing I'd rather get right before it bites someone in production.

Collapse
 
pushpendraagrawal profile image
Pushpendra Agrawal

the subset check on capabilities is the part most people skip. we run a managed gateway for agent calls at GTWY and the escalation attempts almost always come from a delegated sub agent trying to reuse a token scope it was never handed, not from the top level agent itself. signing the delegation is a good fix but logging every rejected escalation attempt separately is what actually catches it in practice, since the attacker or bug does not know which check failed.

Collapse
 
kironovlazizdel profile image
kironovlaziz-del

This is a great point, and it matches what I see too — the escalation almost never comes from the top-level agent, it's a sub-agent reusing a scope it was never granted. That's exactly why the subset check runs at every hop, not just the first.

And you're right that logging every rejected escalation separately is what actually catches it. In my case each rejected delegation raises an incident with the specific capability that was over-requested, so you get a signal like "sub-agent X tried to use write when its chain only held read" — which, as you say, is valuable precisely because the caller doesn't know which check tripped.

Curious how you handle it at GTWY: do you treat repeated rejected escalations from the same sub-agent as a kill-switch trigger, or just surface them? I've been going back and forth on auto-revocation vs. human-in-the-loop for that.