banji-007/compliance-ail
Compliance gateway that verifies OPA policy and a proof-checked, tamper-evident audit trail on autonomous AI agent tool calls before they execute. Isolated SDK-based verifier; framework-agnostic en...
2 stars · 1 forks
README
Agentic Integrity Ledger (AIL) A policy enforcement gateway for AI agent tool calls, with a tamper-evident audit ledger. Deterministic, fail-closed, and honest about its limits.
What this is, and what it does not claim Every tool call an autonomous agent makes is intercepted, validated against a schema, decided by policy, and written to a tamper-evident ledger before it executes. A failure anywhere in that chain denies the call. That is the whole product; the rest of this file is how each link is built and what each one is worth. What is enforced. A call reaches its tool only after a cryptographic workload identity is presented (SPIFFE/SPIRE, mutual TLS), its arguments satisfy a Pydantic schema, an OPA policy evaluates to allow, and the decision is committed to ImmuDB. The four are ordered and none is skippable. What is fail-closed. Policy engine unreachable, ledger unreachable, identity unavailable, an unregistered tool, an empty credential: every one of these denies. There is exactly one subsystem in this project that fails open, external transparency-log anchoring, and it is bounded by fail-closed-on-the- claim: a bundle covering a state no log has seen says so in a field rather than staying silent. What the ledger's account of itself guarantees. The audit page is ordered by commit position, allocated under a compare-and-set the ledger enforces in the same transaction as the record it indexes. A write reports whether it committed as a fact read back from the ledger, or reports that it does not know; it never reports a committed record as never written, and it never treats a not-found read taken in the window right after a commit as evidence of absence. A proof that fails after a record has committed produces a durable, separately signed fault record rather than a repairable silence. What is not claimed, at the same resolution:
Prompt injection is not prevented. It is structurally constrained. The model can be talked into anything; the gateway refuses the call anyway, and section 4.4's Test 2 is that happening. What is claimed is that the denial is a policy decision on a well-formed payload, not a text filter. Tamper-evidence is not forgery-resistance. The proofs protect a record already written. Anything holding the verifier's network position and a valid credential can write a record this system treats as authentic. A bundle proves a record, not its truth. It proves the record was committed and has not changed. It does not prove the policy that produced it was correct. A writer signature names a key, not a service. Every service currently mounts every writer key. Three of the four demo tools are observed, meaning the agent retains their real authority and a bypassed call produces no record at all. One tool is mediated, and the difference is stated per tool rather than averaged into a deployment-wide claim. The Helm chart does not deploy. Section 4.7.
Section 5's Residual Limits is the long form of this list, and nothing in this section is stronger than what is there. Start here if you want to check a claim rather than read one: docs/walkthrough/README.md verifies one real record, a prompt injection being refused, on your own machine with Python and one dependency. No Docker, no credentials, no network, and nothing from this project running anywhere.
1. The Problem: LLM System Prompts Are Not a Security Boundary Agents built on LangGraph, AutoGen, CrewAI or bespoke orchestration commonly rely on LLM system prompts to enforce compliance rules. A typical implementation looks like this: SYSTEM: You are a helpful cloud provisioning assistant. You must never provision instances larger than t3.large. You must always set encryption_at_rest to true. You must never use regions outside of eu-central-1.
This approach is not a security control. It is a polite suggestion written in natural language, enforced by a probabilistic next-token predictor. The fundamental vulnerabilities are:
Threat Vector Why System Prompts Fail
Prompt Injection A malicious payload in user input or tool output can override system instructions. LLMs have no cryptographic way to distinguish a system prompt from injected text at inference time.
Jailbreaking Adversarial inputs can cause the model to ignore or rationalize away safety instructions.
Model Drift A model update from your LLM provider can silently alter how system instructions are interpreted, breaking compliance guarantees you have never re-tested.
Hallucination Even a well-intentioned model can produce a tool call payload that violates a constraint it was instructed to follow, especially under complex multi-step reasoning chains.
Non-Determinism The same prompt does not produce the same output. A system that passes compliance testing today may fail in production tomorrow under identical conditions.
2. Architecture The AIL gateway implements a four-stage enforcement pipeline. Each stage is independently fail-closed: a failure at any stage results in a denial, never a silent pass.
flowchart TD A([Untrusted AI Agent\nLangGraph / LangChain]) -->|Tool Call Attempt| B
subgraph IDENTITY ["Stage 1 - Cryptographic Identity"] B[Envoy Proxy\nmTLS Termination] B1[SPIRE Agent\nSPIFFE SVID Issuance] B1 -->|X.509 SVID\nEphemeral Cert| B end
B -->|Authenticated Request\nretargeted, Phase 2| DS
subgraph DECISION ["Stage 2 - Decision Service (Phase 2, D12)"] DS[POST /decide\nSchema + OPA + Ledger + Vault] end
DS -->|Policy query| C
subgraph POLICY ["Stage 3 - Policy Enforcement"] C[Open Policy Agent\nRego Evaluation] C1[AIL Control Plane\nFastAPI Bundle Server] C2[OPA Bundle\nper Tenant] C1 -->|/bundles/tenant_id\nGDPR + SOC2 + FinOps + HIPAA| C2 C2 -->|Loaded into OPA\non poll cycle| C end
C -->|APPROVED / DENIED| DS DS -->|logged via verifier| V
subgraph LEDGER ["Stage 4 - Verified Immutable Audit"] V[AIL Verifier\nisolated immudb-py SDK] D[ImmuDB\nMerkle-Tree Ledger] V -->|verifiedSet\ninclusion + consistency proof| D D -.->|ECDSA-signed state\nverified vs public key| V end
V -->|verified entries + state_id\nvia verifiedGet| E
subgraph OBSERVE ["Stage 5 - Observability & Control"] E[CISO Control Plane\nNext.js Dashboard] F[Prometheus + Grafana\nReal-Time Metrics] end
DS -->|DENIED| G([Execution Blocked\nAgent receives structured error]) DS -->|APPROVED\nobserved tools| H([Agent Executes\nThree Python-function tools]) DS -->|APPROVED\nread_vault_secret only| I([Decision Service Executes\nAgent never holds the credential])
style IDENTITY fill:#1e3a5f,color:#fff,stroke:#4a90d9 style DECISION fill:#4a1e5f,color:#fff,stroke:#a94ad9 style POLICY fill:#1e3f1e,color:#fff,stroke:#4aaa4a style LEDGER fill:#5f1e1e,color:#fff,stroke:#d94a4a style OBSERVE fill:#3f2e1e,color:#fff,stroke:#d9944a style G fill:#8b0000,color:#fff,stroke:#ff0000 style H fill:#004d00,color:#fff,stroke:#00aa00 style I fill:#00394d,color:#fff,stroke:#00aacc
Loading
Stage boxes are numbered for readability; they are not a claim that every call visits every stage in strict physical order (the decision service's own OPA query and ledger write are itself two of the calls the diagram groups as one "Decision Service" node - see decision_service/main.py). Fail-closed guarantees:
Decision service unreachable from the agent → DENY (Phase 2; no ledger record, the agent's own client leg never reached it) OPA unreachable → DENY ImmuDB unreachable → DENY Verifier unreachable or entry unverified → DENY SPIRE socket absent → DENY, with its own dedicated guard independent of decision-service readiness (P2-5) Schema validation failure → DENY (before OPA is even queried)
There is no code path in which an infrastructure failure results in a silent approval.
3. Core Capabilities 3.1 Workload Identity: Ephemeral Certificates, Not Static Keys AIL uses SPIFFE/SPIRE (the CNCF standard for workload identity) to issue ephemeral X.509 SVIDs (SPIFFE Verifiable Identity Documents) to each AI agent at runtime.
Every agent is assigned a unique SPIFFE ID: spiffe://ail.internal/workload/agent Certificates are short-lived and automatically rotated by the SPIRE agent On the full docker-compose.yml stack, the langgraph-demo agent's traffic transits an Envoy proxy enforcing strict mutual TLS (DECISION_SERVICE_URL=https://envoy:8443/decide) - retargeted in Phase 2 from OPA directly to the decision service, since the agent no longer talks to OPA at all. This is not a universal gate: docker-compose.test.yml, which the integration suite and CI actually run against, has no Envoy service at all - SPIRE_DISABLED=true there means the interceptor calls the decision service directly, unauthenticated at the transport layer. Even on the full stack, docker-compose.yml's edge/backend network split (Phase 2, docs/adr/0008-decision-service-boundary.md) is what actually keeps the agent from reaching OPA's, the verifier's, or the control plane's ports at all - Envoy is the authenticated path onto backend, not a packet filter sitting in front of an otherwise-reachable one. Certificates are loaded in-memory only on Linux (os.memfd_create), never written to disk If the SPIRE workload socket is absent at boot, the agent process exits immediately, via its own dedicated guard (P2-5) independent of decision-service reachability
This means exfiltrating a static API key buys an attacker nothing on this data plane - identity is bound to the workload's cryptographic attestation, not a secret that can be copied out and replayed elsewhere. It does not mean a compromised container has nothing actionable in general: code running inside the agent's own container holds that workload's real SPIFFE identity for as long as it runs, and can use it to reach whatever that identity is authorized to reach - which, since Phase 2, is the decision service's /decide route and nothing else (see Residual Limits, §5, for what that identity still lets a compromised agent do to the three observed tools). What SPIFFE/SPIRE removes is the static-secret-theft attack; it does not remove the I am now running inside the trusted workload attack, which is a different threat entirely. 3.2 Multi-Schema Pre-flight Inspection The decision service maintains a Pydantic v2 schema and tool registry (decision_service/schemas.py::TOOL_REGISTRY) that maps tool names to a validator, an authority holder, a mechanism, and a conformance profile. Schema validation runs before the OPA call - this moved out of the agent process in Phase 2 along with everything else intercept_tool_call used to do in-process (docs/adr/0008-decision-service-boundary.md). This catches hallucinated or malformed payloads - missing required fields, wrong types, values outside expected ranges - and blocks them with a structured error before they consume a policy evaluation cycle.
Tool Schema Enforces Profile Exclusivity
provision_cloud_server Instance type, region format, required tag fields (cost_center, environment, encryption_at_rest) observed n/a
query_database Table name, query string, required processing_purpose declaration observed n/a
deploy_to_production Repository name, environment target, required approval metadata observed n/a
read_vault_secret Secret name, restricted to an allowlist enforced in Rego mediated demonstrated, checked at decision-service startup - never taken from config alone (D13)
Unregistered tool Blocked at registry lookup - fail-closed before OPA is queried — —
3.3 Multi-Tenant Policy Isolation Each tenant receives a dynamically generated OPA bundle served by the control plane, so two tenants' policies are different Rego, not one policy branching on a tenant field. Section 4.5 drives that difference: the same request is approved under tenant_default and denied under tenant_finance, with the denial naming that tenant's own allowlist. The bundle contains:
The tenant's enabled compliance framework Rego policies (toggleable: GDPR, SOC2, FinOps, HIPAA) A data.json document injecting the tenant's specific configuration: allowed_cost_centers, approved_regions, approved_purposes
OPA polls the bundle endpoint (/bundles/{tenant_id}) on a configurable interval. When a CISO changes a policy setting in the dashboard and saves, the control plane generates a new bundle with a new SHA-256 ETag. OPA detects the ETag change on its next poll and hot-reloads the bundle - no restart required. tenant_default → allowed_cost_centers: [engineering, marketing, finance, operations] tenant_finance → allowed_cost_centers: [finance, executive]
Each OPA process resolves exactly one bundle resource, from its own AIL_TENANT_ID environment variable, once at startup - it polls and evaluates against that single tenant's bundle for the lifetime of the process. Isolation between tenants comes from running a dedicated OPA process per tenant, not from one process serving several: in the Kubernetes/Helm deployment this is a separate OPA sidecar container per agent pod, each pinned to its tenant. The docker-compose demo runs a single OPA container, so at any given moment it is serving exactly one tenant; switching which tenant it serves means recreating that container against a different AIL_TENANT_ID (section 4.5 below). The control plane persists tenant config in SQLite, which is sufficient for the demo and single-instance deployments but is a single-writer store. Horizontal scale-out of the control plane requires moving to a networked database (Postgres). The tenancy model and bundle generation are storage-agnostic; only the persistence layer is the constraint. 3.4 Cryptographic Auditability Every policy decision is written to ImmuDB through an isolated verifier service wrapping the official immudb-py gRPC SDK. The verifier runs in its own process so its Protobuf dependency never reaches the interceptor, preserving the SPIFFE mTLS posture (see ADR-0001). The record, not a message. The ledger entry itself is a structured outcome record, not a free-text string: outcome_type (one of policy_allow, policy_deny, schema_deny, fault), fault_class when outcome_type is fault, the policy_revision that produced the decision, and the deny reasons. This is set at one point in the decision service (decision_service/main.py::query_opa_policy, moved here from the interceptor in Phase 2) and never reconstructed downstream by inspecting message text — a policy denial, a schema rejection, and an infrastructure fault are distinguishable everywhere: the ledger, /audit, the dashboard, and Prometheus. Every record also carries profile, per-tool since Phase 2 (D13); a mediated record additionally carries exclusivity. /audit also computes execution_state ("completed" | "unknown" | "n/a") for every entry - the read-time signal for whether a mediated call's write-ahead intent record has a matching completion record (D16, Phase 2 completion pass). See docs/adr/0005-outcome-taxonomy.md, docs/adr/0008-decision-service-boundary.md, and docs/adr/0009-write-ahead-intent-and-per-tool-verification.md. The hash, not the payload. The entry carries input_sha256, a hash over the canonically serialized tool arguments, not the arguments themselves. The full arguments are stored separately, in the control plane's own database, keyed by call_id (minted at intercept, independent of ImmuDB's own transaction numbering) — erasable independently of the immutable ledger, so a GDPR Article 17 request can delete the arguments without touching the proof of what was decided or that the input hashed to that value. The content write happens before the ledger write; the ledger entry then records content_state (present or unavailable), and a content-store failure denies the call as a fault rather than recording a decision it cannot describe. Writes use verifiedSet and reads use verifiedGet. On each write the SDK checks the inclusion proof binding the (key, value) leaf to the transaction's entries hash, and the consistency proof from the verifier's persisted state to the new transaction, before the entry is treated as durable. A write the SDK cannot verify makes the interceptor fail closed and return DENY; no tool call executes against an unverifiable audit record. Whether a ledger entry exists in that case depends on where the failure happened, and since D35 (Phase 3c-3c) the write response says which. Both routes commit before their proof runs, so a proof that fails cannot prevent the write: if the verifier could not be reached, or the write did not commit, there is no entry; if the write committed and its proof did not check out, the record is in the ledger at a real transaction and position, indexed, with the counter advanced, and a ledger_fault: record qualifies it. The call denies either way. Both states are still reported as fault_class: verifier_unreachable, which is one closed-set class covering two materially different outcomes; that collapse is stated in Residual Limits (§5) and is not resolved here. See docs/adr/0005-outcome-taxonomy.md's Documented Boundary and docs/adr/0014-ordered-audit-view-index.md's D35. Every decision write also takes a commit position, atomically (D32, Phase 3c-3b). One ExecAll commits the record, an advanced counter and the view-index entry in a single transaction, gated by a compare-and-set precondition on the counter, so a record cannot exist without the position that orders it and a writer that read a stale counter is refused outright. immudb-py 1.5.0 has no verified ExecAll, so the inclusion and consistency proofs that verifiedSet used to run inside the write call are issued immediately after it as a verifiedGet on the record key - the same SDK code over the same proofs, raising on the same conditions, so an unverifiable write still denies the call. Erasure tombstones keep the plain POST /write route and take no position, because a tombstone is never a row on the ordered page. See docs/adr/0014-ordered-audit-view-index.md. Verification is a read, not a record. A ledger entry cannot assert its own verification status. /audit computes one of five states per entry, at request time: verified (a proof check ran and passed), failed (a proof or signature was rejected — the tamper signal, with error_class distinguishing a consistency failure from a signature failure), unverifiable (a check was attempted and could not complete), asserted (no check was attempted for this entry in producing this response), or not_found (a check was attempted and the underlying gRPC call returned NOT_FOUND — no entry was ever written for this key; not a tamper signal, since no proof was ever rejected). See docs/adr/0006-verification-states.md. When ImmuDB runs with a signing key, each state it returns is ECDSA-signed, and the verifier rejects any state whose signature does not verify against the configured public key before accepting a proof result. The persisted signed state is the trust anchor; it sits on a volume separate from the ledger-writing identity, so the process that records entries cannot rewrite the anchor by writing to that volume. What that sentence does not say, stated because the inference is natural and was false (corrected 2026-09-03, Phase 3c-3f, D47/P3c3f-11). Volume separation is about who can write the file directly. It is not a claim that reaching the verifier leaves the anchor where it was, and until D47 it did not: POST /verify reported the ledger head with the SDK's client.currentState(), whose handler persists what it reports, so a caller holding only the read credential advanced the anchor every later proof is measured against. Driven: four writes made straight to ImmuDB moved the head from 11 to 15, the anchor stayed at 11 because nothing had asked the verifier anything, and one POST /verify moved it to 15. The anchor is now written and seeded only from a stat