Skip to content

Export signed evidence

View as Markdown

This guide gives your deployment a key to sign exports with, exports signed evidence, and checks an export.

A deployment can sign three exports, for an auditor or for your own records:

  • an approval's audit bundle: who decided a human review, when, why, and the evidence it was shown;
  • a run's provenance: the graph of what ran, with each model call's usage;
  • compliance evidence: the audit events a filter matches, by run, agent, flow, kind or time.

Each is one JSON file. It holds the exact bytes that were signed, base64, as bundle. It also holds a signature over them, the algorithm it was made with, and the public key that signed. Anyone can check it, with no access to your deployment.

  • A runtime, such as kindgi dev, with Kindgi 0.1's CLI.
  • KINDGI_API_URL and KINDGI_API_TOKEN set in your shell, for the HTTP requests.
  • openssl, to make a key to keep. kindgi dev signs with a throwaway key without one.
  • A decided approval, or the run of an agent turn that completed, to export.

The runtime signs with one key, its export signing key. Set exactly one of:

Setting What it holds
KINDGI_EXPORT_SIGNING_KEY_PATH The path of a private key file: Ed25519 or EC P-256 (PKCS#8 PEM, mode 0600).
KINDGI_EXPORT_SIGNING_KEY The same PEM file, base64: for a platform that gives secrets as environment variables, such as Cloud Run with Secret Manager.
KINDGI_EXPORT_SIGNING_KMS_KEY A KMS key version, so the private key never leaves the KMS: a Cloud KMS key version (EC_SIGN_ED25519 or EC_SIGN_P256_SHA256), or an Azure Key Vault key version (https://<vault>.vault.azure.net/keys/<name>/<version>, an EC P-256 key). Optional.

Make a key for this alone:

Terminal window
openssl genpkey -algorithm ed25519 -out export-signing.pem && chmod 600 export-signing.pem

It prints nothing. The key is in export-signing.pem, readable only by you.

The key's type sets the export's algorithm. An Ed25519 key signs ed25519, the default. An EC P-256 key signs ecdsa-p256-sha256, for a key store that has no Ed25519:

Terminal window
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out export-signing.pem && chmod 600 export-signing.pem

It prints nothing either.

The verifiers in Check an export check either. They refuse an algorithm they don't know, as An algorithm the verifier doesn't know shows.

  • kindgi dev signs with a key the runtime makes at startup, so exports work with nothing set. That key is a throwaway, though. To keep one across restarts, set KINDGI_EXPORT_SIGNING_KEY_PATH to the absolute path of a key file on your machine, in the shell or the pack's .env. kindgi dev mounts that file into the runtime's container, read-only.

  • On Cloud Run, set export_signing = "secret" in the Terraform module, then add the key's version. $N is the module's name_prefix:

    Terminal window
    openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-export-signing-key --data-file=-

    gcloud prints the version it created. For a P-256 key, put openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 in place of the first command. The rest is the same.

    Or set export_signing = "kms" and export_signing_kms_key to a key version: the module grants the server's service account roles/cloudkms.signerVerifier and roles/cloudkms.publicKeyViewer on its key.

  • On Azure, a Key Vault key: set KINDGI_EXPORT_SIGNING_KMS_KEY to the key version's URL, https://<vault>.vault.azure.net/keys/<name>/<version>. The version is required, so exports keep the key id of the version that signs them. The key must be EC P-256 (EC or EC-HSM) and allowed to sign, so exports are ecdsa-p256-sha256. Key Vault has no Ed25519. <vault> is your Key Vault's name, and <name> the key's:

    Terminal window
    az keyvault key create --vault-name <vault> -n <name> --kty EC --curve P-256 --ops sign verify

    az prints the new key as JSON, with its version in key.kid.

    The runtime signs as its managed identity: KINDGI_AZURE_CLIENT_ID names a user-assigned one, else it's the system-assigned one. Give that identity Key Vault Crypto User on the key. The private key never leaves Key Vault.

  • Without a key, outside development, the exports answer 404 signing-not-configured.

The runtime publishes its key's public half. Pin it, so you can tell your deployment's exports from anyone else's:

Terminal window
curl "$KINDGI_API_URL/v1/export-signing-keys" -H "Authorization: Bearer $KINDGI_API_TOKEN"

It answers with the keys as JSON, the active one first. Each key has an id derived from it, ex_…, so the same key keeps its id across restarts. Its fingerprint, sha256:…, is there to compare by eye, and publicKeyPem is its public half.

Export an approval by its id, or a run's provenance by the run's id. Each prints the signed export, and > saves it:

Terminal window
pnpm exec kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json
pnpm exec kindgi provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json
  • An approval must be decided: approved, rejected, escalated, expired or withdrawn. An open one answers 409 approval-not-decided. --include-messages adds the conversation of the approval's run.

  • Compliance evidence is exported over HTTP. With no body, it covers every kind the classifier marks exportable. Here filter keeps one run's events:

    Terminal window
    curl -X POST "$KINDGI_API_URL/v1/compliance/evidence/export" \
    -H "Authorization: Bearer $KINDGI_API_TOKEN" \
    -H 'content-type: application/json' \
    -d '{ "filter": { "runId": "11a79b0a-ebdd-4d21-9e66-e0cacaea50e8" } }' > evidence.json

Every export is recorded as an export-signed audit event: who exported what, with which key, and the SHA-256 of the signed bytes. That ties a file someone hands you to the moment it was made. If the event can't be recorded, the export isn't handed out.

To check one from your code, ask your coding agent: "I want to check that an export was signed by my deployment." For reference, the command and the code look like this:

Terminal window
pnpm exec kindgi exports verify approval.json --from-runtime

In a Python project that's uv run kindgi …, and in Java or Scala ./kindgiw ….

--from-runtime trusts the keys the runtime lists. An auditor without access checks against the key you gave them, with --trust=export-signing.pub.pem. Repeat it for several keys. It prints the check as JSON: valid, and signingKeyId, the id of the key that signed. It exits 0 when the export checks out and 1 when it doesn't, saying why.

An export whose algorithm the verifier doesn't know is refused, never checked another way. Here, an export whose algorithm was edited to ecdsa-p384-sha384:

$ kindgi exports verify approval.json --trust=export-signing.pub.pem
{
"valid": false,
"kind": "audit-bundle",
"signingKeyId": "ex_sD1K1gmwO-S3BJxR",
"checkedAgainst": "trusted-keys",
"issues": [
"algorithm \"ecdsa-p384-sha384\" isn't one this verifier knows (ed25519, ecdsa-p256-sha256): a newer verifier may check it"
]
}

It exits 1. The TypeScript and Python verifiers give the same line in issues. A file signed with a newer algorithm needs a newer verifier. A file someone edited is refused either way.

Checked without trusted keys, a valid export only proves its bytes weren't changed: anyone could have signed it with a key of their own. Checking against your deployment's keys is what tells you it's yours.

An audit bundle made with @kindgi/api 0.1.4

Section titled “An audit bundle made with @kindgi/api 0.1.4”

The 0.1.4 runtime didn't sign exports: its three export routes answered 404 signing-not-configured. But an app that embedded @kindgi/api 0.1.4 with its own key could export audit bundles. Those verify. That version stamped a bundle's envelope exportedAt separately from the signed one, so the two can be a millisecond apart. The envelope's copy isn't signed, so the verifiers report the signed time in notes and don't compare the two:

$ kindgi exports verify approval.json --trust=export-signing.pub.pem
{
"valid": true,
"signingKeyId": "acme-export-key-1",
"checkedAgainst": "trusted-keys",
"notes": [
"made by Kindgi 0.1.4, which stamped the envelope's exportedAt separately: the signed export time is 2026-10-08T13:12:21.532Z (the envelope says 2026-10-08T13:12:21.533Z)"
]
}

It exits 0. The TypeScript and Python verifiers give the same line in notes. Only 0.1.4's audit bundle format, with an envelope bundleSchemaVersion of 1, is checked this way. In every later export, an envelope exportedAt that isn't the signed one fails the check.

Replace the key and restart: the file, the secret's version, or the KMS key version. New exports carry the new key's id.

An export signed before still checks out against the old public key wherever you pinned it. For --from-runtime to keep trusting it, list the old key as retired. Save its public half before you replace it:

Terminal window
curl -s "$KINDGI_API_URL/v1/export-signing-keys" -H "Authorization: Bearer $KINDGI_API_TOKEN" \
| jq -r '.data[] | select(.active) | .publicKeyPem' >> export-signing-retired.pem

Then give the runtime the file, in one of these:

Setting What it holds
KINDGI_EXPORT_SIGNING_RETIRED_PUBLIC_KEYS_PATH The path of a file of retired keys' public halves: PEM (-----BEGIN PUBLIC KEY-----), Ed25519 or EC P-256, concatenated.
KINDGI_EXPORT_SIGNING_RETIRED_PUBLIC_KEYS The same file, base64 (base64 < export-signing-retired.pem): for a platform that gives settings as environment variables.
  • What it lists: GET /v1/export-signing-keys lists the active key first and the retired ones after it, with active: false. So kindgi exports verify --from-runtime trusts what they signed.
  • They never sign: a request that asks for a retired key's id is 404 signing-key-not-found. A private key in the file stops the runtime at boot, and so does a block it can't read.
  • Any signing key: it works whatever signs now: a file, a secret or a KMS key version.

Approvals, Provenance, Compliance and Export signing keys in the HTTP API have every operation.