DEV Community

Cover image for A Per-Run Identity and Audit Log for AI Agents: A Minimal Pattern After India's CERT-In Advisory
chinmay garg
chinmay garg

Posted on

A Per-Run Identity and Audit Log for AI Agents: A Minimal Pattern After India's CERT-In Advisory

On October 15, 2026, India's CERT-In issued advisory CIAD-2026-0049. AI agents run by model providers during testing reached the live systems of Indian organisations and read personal data and business records. The way in was ordinary: credentials left in config files and public repositories, and cloud roles with too many permissions. None of the affected organisations detected it. The providers reported it themselves.

The advisory has a short list of agent-specific controls. Two of them are the subject of this post:

  • Give each agent run its own short-lived, least-privilege identity.
  • Keep a tamper-resistant log of agent actions, and keep API logs for at least 180 days.

Below is a small pattern that does both, with code I ran before writing this. It is a teaching sketch, not a product. I will say where it stops being enough.

What problem are we solving?

Most agent logs today look like this:

2026-10-19 02:14:07  service-account-12  GET /customers/88  200
Enter fullscreen mode Exit fullscreen mode

That line has an event and no actor. You cannot tell which agent made the call, who deployed it, what task it was on, or whether the task was allowed to touch customer data. If personal data is involved, those are the first questions a customer, an auditor or a regulator will ask.

What we want instead, for every call an agent makes:

  1. A credential that belongs to one run of one agent, and expires quickly.
  2. Claims inside it: which agent, which accountable owner, which purpose, which scopes.
  3. A check at the gateway that verifies the credential and the scope before the call goes through.
  4. A log entry for every decision, allowed or denied, that cannot be quietly edited later.

The pattern

1. Issue a credential per run

import base64
import hashlib
import hmac
import json
import time
import uuid

# Demo only. In production use asymmetric keys held in a KMS or HSM.
SECRET = b"demo-only-secret"


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64(text: str) -> bytes:
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def sign(body: str) -> str:
    return b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())


def issue_run_credential(agent_id, owner, purpose, scopes, ttl_seconds=900):
    """One credential per agent run: short-lived, narrow, and named."""
    claims = {
        "run_id": str(uuid.uuid4()),
        "agent_id": agent_id,
        "owner": owner,
        "purpose": purpose,
        "scopes": sorted(scopes),
        "exp": int(time.time()) + ttl_seconds,
    }
    body = b64(json.dumps(claims, sort_keys=True).encode())
    return f"{body}.{sign(body)}"
Enter fullscreen mode Exit fullscreen mode

Each run gets a new run_id. The owner field is the accountable human or legal entity. The purpose field says why the run exists. scopes lists the exact actions allowed. The default lifetime is 15 minutes.

2. Verify at the gateway

def verify(token):
    body, sig = token.split(".")
    if not hmac.compare_digest(sig, sign(body)):
        raise PermissionError("bad signature")
    claims = json.loads(unb64(body))
    if claims["exp"] < time.time():
        raise PermissionError("expired")
    return claims
Enter fullscreen mode Exit fullscreen mode

3. Log every decision in a hash chain

Each entry carries the hash of the entry before it. Change an old entry and every later hash stops matching.

class AuditLog:
    """Append-only log where each entry carries the hash of the one before it."""

    def __init__(self):
        self.entries = []
        self.last_hash = "0" * 64

    def append(self, claims, action, resource, allowed):
        record = {
            "ts": round(time.time(), 3),
            "run_id": claims.get("run_id", "unverified"),
            "agent_id": claims.get("agent_id", "unverified"),
            "owner": claims.get("owner", "unverified"),
            "purpose": claims.get("purpose", "unverified"),
            "action": action,
            "resource": resource,
            "allowed": allowed,
            "prev": self.last_hash,
        }
        record["hash"] = hashlib.sha256(
            json.dumps(record, sort_keys=True).encode()
        ).hexdigest()
        self.entries.append(record)
        self.last_hash = record["hash"]

    def chain_is_intact(self):
        prev = "0" * 64
        for entry in self.entries:
            body = {k: v for k, v in entry.items() if k != "hash"}
            digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
            if entry["prev"] != prev or entry["hash"] != digest:
                return False
            prev = entry["hash"]
        return True
Enter fullscreen mode Exit fullscreen mode

4. Authorize, log, and support a kill switch

REVOKED_RUNS = set()


def authorize(token, action, resource, log):
    try:
        claims = verify(token)
    except PermissionError:
        log.append({}, action, resource, allowed=False)
        return False
    if claims["run_id"] in REVOKED_RUNS:
        log.append(claims, action, resource, allowed=False)
        return False
    allowed = action in claims["scopes"]
    log.append(claims, action, resource, allowed)
    return allowed
Enter fullscreen mode Exit fullscreen mode

Note that denied calls are logged too, including calls with a forged token. Those are the entries you most want during an investigation. An unverified caller is recorded as unverified, never silently dropped.

Running it

if __name__ == "__main__":
    log = AuditLog()
    token = issue_run_credential(
        agent_id="invoice-agent-7",
        owner="finance-ops@example.com",
        purpose="reconcile vendor invoices",
        scopes=["read:invoices"],
    )
    print(authorize(token, "read:invoices", "invoice/1042", log))    # True
    print(authorize(token, "read:customers", "customer/88", log))    # False, out of scope
    print(authorize("forged.token", "read:invoices", "invoice/1", log))  # False, unverified
    print("chain intact:", log.chain_is_intact())
    log.entries[0]["allowed"] = False  # tamper with history
    print("chain intact after tampering:", log.chain_is_intact())
Enter fullscreen mode Exit fullscreen mode

Output:

True
False
False
chain intact: True
chain intact after tampering: False
Enter fullscreen mode Exit fullscreen mode

The first call is in scope. The second asks for read:customers, which the credential never had. The third is a forged token. The last two lines show that editing history is detectable.

Where this stops being enough

This is deliberately small. Before you use anything like it, know the limits:

  • Shared secret. The demo signs with HMAC and one secret. Anyone who holds the secret can mint credentials. In production use asymmetric keys held in a KMS or HSM, so the gateway can verify without being able to issue.
  • Log storage. A hash chain shows tampering only if you keep the latest hash somewhere the attacker cannot rewrite, such as a write-once store or an external anchor. Otherwise they can rebuild the whole chain.
  • Retention. CERT-In asks for API logs for at least 180 days. Build retention and legal-hold rules around the log, not just the code.
  • Egress. CERT-In also asks for outbound traffic to be blocked by default and enforced at the network level, not through model instructions. A credential check at the gateway does nothing if the agent can call out directly.
  • Credential theft. The advisory's root cause was exposed credentials. Short lifetimes and narrow scopes shrink the damage. They do not replace secret scanning in code, config and CI.
  • Self-declared names. agent_id here is assigned by whoever issues the credential. It is not whatever the agent says about itself. This week, researchers reported an agent that claimed to be Claude when it likely was not. A name in a request is a claim. A signed credential you issued is evidence.

Where DPDP fits

Under India's DPDP Act, a Data Fiduciary is responsible for personal data in its control, including processing done on its behalf. Consent Manager registration opens on November 13, 2026, and the main duties, including breach reporting, apply from May 13, 2027.

The credential above is the natural place to carry two more claims: the consent or lawful basis the run relies on, and a reference to the Data Fiduciary that answers for it. I left them out of the code because there is no agreed standard for them yet. NPCI is building a registry of AI agents for UPI, Amex has released an Intent ID and Proof of Intent Token for agent purchases, and DPDP will have Consent Managers. I could not find a public description that ties an agent's identity to any of the three consent records.

That is the part I think is worth building in the open. This is not legal advice.

Questions for you

  • Do you issue credentials per agent run, or per agent, or per team?
  • What do you use as the trust anchor so the log cannot be rebuilt?
  • For on-device agents, where should the log live?

I would like to hear what has held up in production.

Sources: CERT-In advisory CIAD-2026-0049 as reported by The Hacker News and TechCrunch (Oct 16, 2026); TechCrunch on the Amex Intent Intelligence Framework (Oct 13, 2026); TechNode on NPCI's AI-agent registry (Sep 12, 2026).

Top comments (0)