Show HN: Policy gateway for AI agent tool calls, with a tamper-evident log A new open-source policy enforcement gateway intercepts every AI agent tool call and validates it against a Pydantic schema, an OPA policy, a SPIFFE/SPIRE cryptographic workload identity, and an ImmuDB tamper-evident ledger before the call executes, denying the call if any link in the chain fails. The project's author states the gateway does not prevent prompt injection but structurally constrains it, and that three of the four demo tools are "observed," meaning a bypassed call produces no record at all. A walkthrough at docs/walkthrough/README.md lets users verify one real record and a refused prompt injection locally with Python and one dependency, with no Docker, credentials, or network required. A policy enforcement gateway for AI agent tool calls, with a tamper-evident audit ledger. Deterministic, fail-closed, and honest about its limits. 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. 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. | 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. php 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 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. 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. 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 | — | — | 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. 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 state whose ImmuDB signature has been checked, and only forwards; tests/test trust anchor.py drives both call sites and both seeding paths. D23's motivation is untouched by this and the correction should not be read as wider than it is: external anchoring rests on the local anchor being inside the operator's control, which held either way. What changed is which callers could move it. What this proves, and what it does not. The chain establishes that a returned entry was committed and has not been altered, deleted, or served from a forked or rolled-back store, and an auditor can reproduce the result offline with immuclient against the same signed state. It does not prove the correctness of the policy that approved the entry; that is the OPA layer's concern. Tamper-evidence and policy-correctness are separate guarantees. Coverage is enforced by integration tests run against a live ImmuDB on every CI build: proof parity between verifier and server, corruption of the persisted anchor caught as a consistency-proof failure ErrCorruptedData , cross-process verification through /audit , and a write-read round trip. A fifth test demonstrates that a mismatched verifying key is caught as a signature failure BadSignatureError ; as written it substitutes the key on a client object the test itself constructs, so it proves key-mismatch detection, not resistance to an attacker substituting the key on a running verifier - see TODO.md for the attacker-reachable version of this test. Any failure fails the build. Of the five tests, one the persisted-anchor corruption test exercises a tamper vector an attacker with access to the verifier's state volume could actually reach; the rest are correctness and detection checks, valuable on their own but not tamper simulations. The guarantee above was, until Phase 3a, only checkable from inside this system. Confirming one record meant being given a running stack, network reach to it, and credentials for it - a much larger grant than the question deserves, and impossible for anything archival or air-gapped. An evidence bundle is one JSON file for one ledger record: the record as stored, the raw proof material ImmuDB returned, the fingerprints of the keys it expects, and - since Phase 3b - a statement of whether the ledger state it is proven against was published outside this deployment §3.4.2 . GET /audit/bundle?key=