Skip to content

Security — Supply Chain, Runtime, Attestation Chain

TL;DR

Agents that commit, build, and deploy collapse three historically-separate threat boundaries (author, builder, operator) into one. The security contract: every step from intent to production produces a cryptographically verifiable artifact, any missing artifact rejects admission, every artifact is bound to a traceable identity. Wired via Sigstore (keyless signing) + SLSA v1.0 L3 provenance + Kyverno admission + Cilium L7 egress + CaMeL trust separation.

For runtime safety (guards, stuck detection, hallucination mitigation), see safety.md.

The threat model in one diagram

graph TB
    classDef threat fill:#ffcccc,color:#000
    classDef defense fill:#c8e6c9,color:#000

    T1[Stolen agent OIDC token]:::threat
    T2[Prompt injection via issue]:::threat
    T3[Malicious dependency]:::threat
    T4[Fabricated image]:::threat
    T5[Hallucinated package]:::threat
    T6[Agent compromise]:::threat
    T7[Human credential leak]:::threat

    D1[Ephemeral Fulcio certs]:::defense
    D2[CaMeL separation + egress ACL]:::defense
    D3[Socket.dev + SBOM + allowlist mirror]:::defense
    D4[Cosign + Kyverno verify]:::defense
    D5[Ephemeral install sandbox]:::defense
    D6[Two-attestor T3 policy]:::defense
    D7[Workload identity + short TTL]:::defense

    T1 -.->|mitigated by| D1
    T2 -.->|mitigated by| D2
    T3 -.->|mitigated by| D3
    T4 -.->|mitigated by| D4
    T5 -.->|mitigated by| D5
    T6 -.->|contained by| D6
    T7 -.->|mitigated by| D7

Each defense is cumulative. None alone is sufficient; together they raise the attacker's cost meaningfully.

The attestation chain

Every change produces an attestation at each step, indexed by either artifact digest or git SHA, signed by the identity responsible for that step, logged in a transparency log (Rekor). Kyverno rejects anything reaching production that breaks the chain.

Step Attestation Signed by Registry
1. Issue triage agent-plan (issue ID, model, prompt hash, TaskSpec) Agent OIDC via Fulcio Rekor
2. Commit gitsign signature Agent OIDC via Fulcio Rekor (via git tag)
3. PR review review-attestation (reviewer identity, verdict, commit SHA) Human OIDC or Review-E Rekor
4. Build SLSA v1.0 Provenance GitHub Actions isolated builder Rekor + GHCR
5. Image cosign signature on digest GitHub Actions builder identity GHCR
6. Deploy human-cosign (for T3) Human OIDC via Fulcio Rekor
7. Admission Kyverno verdict log Cluster audit K8s events

Kyverno rejects at step 7 if any of steps 1-6 is missing, expired, or identity-mismatched.

Why this chain is novel

No production system publicly demonstrates the full chain for AI-authored code as of early 2026. Individual pieces exist (Sigstore, in-toto, SLSA, Kyverno); wiring them to agent identity as a unified policy is the contribution.

Sigstore: keyless signing as the foundation

Sigstore is the 2026 answer to "how do we sign without managing long-lived private keys?" Four components:

  • Cosign — image and blob signing
  • Fulcio — issues short-lived (10-minute) x509 certs bound to OIDC identity
  • Rekor — append-only transparency log for signatures
  • Gitsign — git commit signing using Fulcio + Rekor

Image signing at build

Every GitHub Actions build job adds one step:

- name: Sign image
  run: cosign sign --yes ghcr.io/dashecorp/${{ github.repository }}@${{ steps.build.outputs.digest }}
  env:
    COSIGN_EXPERIMENTAL: 1

The Fulcio cert binds the signature to the GitHub Actions identity (repo:dashecorp/foo:ref:refs/heads/main). No KMS to manage, no secrets to rotate.

SLSA v1.0 Provenance via slsa-github-generator

Target: SLSA Build L3 — isolated builder where user-controlled steps can't touch signing keys. The reusable workflow from slsa-framework/slsa-github-generator produces L3 because the signing job runs in a separate isolated workflow the build code can't influence.

- uses: actions/attest-build-provenance@v1
  with:
    subject-name: ghcr.io/${{ github.repository }}
    subject-digest: ${{ steps.build.outputs.digest }}
    push-to-registry: true

A real attestation is an in-toto v1.0 Statement (predicateType https://slsa.dev/provenance/v1) containing builder.id, buildType, invocation.configSource, materials (source commit SHA), signed by Fulcio, logged in Rekor.

Reproducibility (L4-equivalent) is a separate, much harder property and not required for any SLSA v1.0 level. We don't pursue it.

Gitsign for agent commits

Agents sign commits via gitsign instead of gpg:

export GITSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com"
git config gpg.x509.program gitsign
git config gpg.format x509
git commit -S -m "feat: add event type X"

Each commit carries a signature bound to the agent's OIDC identity, with the Rekor entry as non-repudiable record.

Gitsign vs GitHub 'Verified' badge

GitHub does not display gitsign commits as Verified — GitHub's trust root doesn't include Sigstore and the certs expire in minutes. Branch protection rules requiring Verified commits will reject gitsign-signed commits.

Workaround: don't use GitHub's Verified badge as a trust signal. A post-merge CI check runs gitsign verify and posts a status; branch protection requires the CI status, not the UI badge. The rig's verification is authoritative; the UI is cosmetic.

Admission: Kyverno over OPA Gatekeeper

Kyverno's advantages for a small team:

  • YAML CRDs (no Rego language learning curve)
  • Native cosign/notary verification (first-class, not an external tool)
  • Operational simplicity — one controller, one CRD family, policies version-controlled in git
  • OpenReports output — policies emit standardized reports consumable by dashboards

Gatekeeper wins only if you already operate OPA for other reasons. For us, we don't.

The T3 two-attestor policy

The centerpiece:

apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: t3-two-attestor
spec:
  validationActions: [Deny]
  matchConstraints:
    namespaceSelector:
      matchLabels: { blast-radius: t3 }
    resourceRules:
    - apiGroups: [""]
      apiVersions: [v1]
      operations: [CREATE, UPDATE]
      resources: [pods]
  attestors:
  - name: agent-identity
    cosign:
      keyless:
        identities:
        - subject: "https://github.com/dashecorp/.+/.github/workflows/release\\.ya?ml@.+"
          issuer: "https://token.actions.githubusercontent.com"
        ctlog: { url: "https://rekor.sigstore.dev" }
  - name: human-approval
    cosign:
      keyless:
        identities:
        - subject: "repo:dashecorp/prod-approvals:environment:t3-approve"
          issuer: "https://token.actions.githubusercontent.com"
        ctlog: { url: "https://rekor.sigstore.dev" }
  attestations:
  - name: slsa-provenance
    intoto:
      type: "https://slsa.dev/provenance/v1"
  validations:
  - expression: |
      images.containers.map(i,
        verifyImageSignatures(i, [attestors.'agent-identity']) &&
        verifyImageSignatures(i, [attestors.'human-approval'])
      ).all(e, e)
  - expression: |
      images.containers.map(i,
        verifyAttestationSignatures(i, attestations.'slsa-provenance', [attestors.'agent-identity'])
      ).all(e, e > 0)

Translation: manifests targeting namespace=prod-auth, prod-payments, prod-db-migrations (any namespace labeled blast-radius=t3) must carry both an agent Sigstore signature and a human co-sign, plus valid SLSA provenance. The human co-sign comes from a dedicated prod-approvals repo whose workflow environment: t3-approve is gated by GitHub's required-reviewer protection.

Other Kyverno policies

  • require-signed-images-all-ns — global: no unsigned image admits
  • deny-privileged-pods — container security baseline
  • require-network-policy — every pod must be in a namespace with a NetworkPolicy
  • forbid-default-sa-in-prod — default service accounts are denied in production namespaces
  • require-resource-limits — DoS mitigation
  • forbid-latest-tag-in-prod — reproducibility
  • require-slsa-provenance-prod — all prod images carry SLSA L3 attestation
  • t1-canary-required — T1 deploys must be Canary resources, not raw Deployments
  • t2-interface-approval-required — T2 manifests must carry an interface-approval attestation

Catalog maintained in dashecorp/rig-gitops/policies/kyverno/.

What Kyverno cannot enforce

  • Rate limits — e.g., "max 3 agent deploys per hour." Stateless per-admission policy. Handled by a dedicated Flux webhook that reads deploy events from a counter.
  • Semantic correctness — policy can say "manifest carries an SLSA attestation" but not "the diff is semantically correct." Semantic correctness is Review-E + tests + canary.
  • Cross-repo policy — e.g., "this PR touches service A, so service B's error budget must also be checked." Handled by Conductor-E's projection logic, not admission.

Cilium L7 egress — the single biggest ROI against prompt injection

Standard Kubernetes NetworkPolicy is L3/L4 (IP + port). Cilium's CiliumNetworkPolicy adds L7 filtering (FQDN matching and HTTP rules) via Envoy. This is the defense that closes "malicious README tells agent to POST secrets to attacker.com."

Per-agent egress allowlist

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: dev-e-egress
  namespace: dev-e
spec:
  endpointSelector:
    matchLabels: { agent: dev-e }
  egress:
  - toFQDNs:
    # Primary LLM provider (Anthropic default). Add api.openai.com,
    # generativelanguage.googleapis.com, or openrouter.ai when the
    # agent uses alternative providers — see provider-portability.md.
    - matchName: api.anthropic.com
    - matchName: api.github.com
    - matchPattern: "*.githubusercontent.com"
    - matchName: pkg.dashecorp.com
    toPorts:
    - ports: [{ port: "443", protocol: TCP }]
      rules:
        http:
        - method: "GET"
          path: "/.*"
        - method: "POST"
          path: "/v1/messages"
        - method: "POST"
          path: "/repos/.*/issues/.*/comments"
        - method: "POST"
          path: "/repos/.*/pulls"
  - toEndpoints:
    - matchLabels: { app: conductor-e }
    toPorts:
    - ports: [{ port: "8080", protocol: TCP }]
  - toServices:
    - k8sService: { serviceName: kube-dns, namespace: kube-system }

Default-deny: anything not explicitly allowed is blocked. The policy is per-namespace per-agent; Review-E's policy is stricter (no write paths on GitHub API).

What this blocks

  • Exfiltration to attacker-controlled domains: DNS doesn't even resolve.
  • Unintended HTTP methods or paths: DELETE /repos/* from Dev-E is denied even against github.com.
  • Lateral movement within the cluster: Dev-E cannot reach Review-E's pod, Conductor-E's Postgres, or the host network.

What this does not block

  • DNS exfiltration via DNS-over-HTTPS (agent embeds data in DoH queries to allowed domains). Mitigation: deny DNS egress to anything except cluster DNS; force all DNS through CoreDNS which we control.
  • Exfiltration to allowed domains (e.g., opening a GitHub Gist to dump secrets). Mitigation: tool scoping — Dev-E has no tool for creating gists; the API surface exposed to the agent is minimized.

Secrets: SOPS + age in git, everything runtime-fetched is deferred

Corrected — three rounds of retraction

This section has been rewritten three times. Original whitepaper promised Vault; first retraction dropped Vault in favor of SealedSecrets+ESO; second retraction flipped SOPS to primary "replacing" SealedSecrets. Third retraction (2026-04-17): grep-verified the repo — zero SealedSecrets references, zero bitnami-labs image pulls. SOPS + age was always the deployed pick; there was never any SealedSecrets to replace or migrate from. Full three-round log in tool-choices.md.

Actual deployed state (verified 2026-04-17)

Verified against apps/ in the rig-gitops repo:

  • .sops.yaml at repo root with creation_rules covering every apps/*/*.sops.yaml
  • Cluster-scoped age key in flux-system/sops-age Secret
  • Every app Kustomization sets decryption.provider: sops + secretRef.name: sops-age
  • Encrypted manifests live per-app: apps/dev-e/dev-e-secrets.sops.yaml, apps/review-e/review-e-secrets.sops.yaml, apps/conductor-e/conductor-e-secrets.sops.yaml, apps/cloudflared/tunnel-token.sops.yaml
  • Zero SealedSecret objects anywhere
  • Flux decrypts inline — no separate secrets controller pod

Target state (same as deployed state, plus deferred additions)

  • Primary encryption for secrets in git: SOPS + age, decrypted inline by Flux's kustomize-controller --decryption-provider=sops. This is the current live pattern — see above.
  • GitHub App tokens — each agent pod mints an installation token on startup (1 h TTL) via the GitHub App API. No Vault required — GitHub itself does the short-lived-credential minting.
  • Postgres (Conductor-E) credentials — static service account with narrow grants + NetworkPolicy + yearly rotation. At 3 agent classes on one Postgres, a static narrow-grant account is enough; the security delta from dynamic per-agent users is marginal vs. the operational cost of running Vault.
  • LLM API keys — long-lived (neither Anthropic nor OpenAI nor Google issues OIDC-ephemeral keys today). Stored as SOPS-encrypted secrets in git. Per-agent virtual keys in the LiteLLM proxy provide isolation at the application layer regardless of underlying provider — see provider-portability.md.
  • Other static secrets (Cloudflare tokens, Discord webhooks) — SOPS + age in git.
  • Certificatescert-manager + trust-manager. Non-controversial.
  • Deferred: External Secrets Operator + GCP Secret Manager if/when we outgrow git-at-rest (multi-cluster, compliance audit, runtime-fetched secrets). See tool-choices.md for the full evaluation.

Why this stack, not Vault

Use case Pick Why
Git-at-rest encrypted static secrets SOPS + age MPL-2.0 / CNCF governance forever. Flux decrypts inline — no controller pod. Age keys are simpler than GPG.
Backend-agnostic runtime secret fetch (deferred) External Secrets Operator The reversibility insurance for if we move away from git-at-rest. Backend-agnostic CRDs.
Cloud KMS-backed managed secrets (deferred) GCP Secret Manager (free tier) We're already on GCP. Free tier covers us. Pick this when git-at-rest stops scaling.
Short-lived GitHub credentials GitHub App installation tokens Native 1-h TTL. Minted by SDK. No extra infrastructure.
Certificate lifecycle cert-manager + trust-manager CNCF-graduated. ~86% of prod clusters use it.

The whole stack runs without Vault. See tool-choices.md for the full evaluation and comparison to SOPS, Infisical, 1Password Connect, CSI Secrets Store, and OpenBao.

Operational reference

For how-to mechanics (bootstrapping an encrypted secret, rotation procedure, key management), see docs/sops.md.

When to re-evaluate (adopt OpenBao or ESO)

A concrete trigger — not vibes:

  • Second K8s cluster or second Postgres instance deployed
  • Compliance requirement that mandates audit log on secret access
  • Actual credential compromise where rotation scope pain became real
  • Team grows past ~5 operators, making human secret-handling the bottleneck
  • Need for short-lived AWS/GCP cloud credentials per agent
  • getsops.io project stalls or is archived — fork or migrate

When runtime-fetched secrets become necessary, adopt ESO + GCP Secret Manager first. When dynamic secrets become necessary, adopt OpenBao (LF-maintained Vault fork, MPL 2.0, API-compatible) — not Vault itself, because the BSL license and IBM pricing playbooks make the fork the right pick.

Dependency integrity

Defenses, layered

  • GitHub Dependabot malware detection (March 2026): detects npm malware against the GitHub Advisory Database's malware feed, separate from CVE alerts. Enable for all repos.
  • Socket.dev score on every dependency addition: PR check fails if Socket score < threshold.
  • Package-age policy (Datadog's pattern): block deps < 14 days old without explicit approval. Catches typosquatting account-takeovers (Axios March 2026 compromise, shai-hulud September 2025 — 200+ package supply chain attack).
  • Lockfile hash pinning: npm ci --ignore-scripts in agent sandboxes.
  • Ephemeral install sandbox: npm install runs in a throwaway pod with no network beyond the package registry. If the install executes postinstall scripts, they execute only in the sandbox, which is discarded.
  • SBOM generation and scanning: Syft generates SBOM per build, Grype scans, results uploaded with the build attestation. Dependency CVEs surface in the admission chain.

Known limits

  • All defenses operate on metadata (age, score, lockfile hash). A deeply-embedded malicious package with a 30-day history and a high Socket score passes through. Defense-in-depth via the L7 egress allowlist — even a compromised dep cannot exfiltrate.

Prompt injection in the security layer

Full treatment is in safety.md; from the security-layer perspective:

  • CaMeL-style separation is the only defense with a formal guarantee.
  • L7 egress allowlist (this document) closes the exfiltration path.
  • Tool scoping per agent role minimizes capability given a compromised agent.
  • Non-bypassable dangerous-command guard catches shell-level escape attempts.
  • CVE-specific mitigations — see the CVE mitigation table in safety.md for specific CVE-to-defense mappings.

Mandatory vs over-engineered

For a non-hosted, non-customer-facing small-team rig:

Mandatory (the floor)

  1. Keyless image signing via cosign at every build (5 lines of YAML per workflow).
  2. Kyverno admission that rejects anything without a valid Sigstore signature bound to our org's GHA workflows.
  3. SLSA L3 build provenance via slsa-github-generator.
  4. Gitsign on agent commits + out-of-band CI verification.
  5. Cilium L7 egress allowlist per agent namespace.
  6. Short-lived credentials where native: GitHub App installation tokens (1 h TTL), no long-lived ghp_* in cluster. Postgres: narrow-grant static service account (dynamic via OpenBao only if triggers from above fire).
  7. Two-attestor policy for T3 — human OIDC co-sign required.
  8. CaMeL-style architectural separation on untrusted input (formal prompt-injection guarantee).

Over-engineered at our scale

  • Reproducible builds — valuable for an OS distribution, marginal for our threat model. SLSA L3 provenance is enough.
  • SLSA L4 — doesn't exist in v1.0; L3 is the spec ceiling.
  • OPA Gatekeeper — Kyverno covers 95% with less operational cost.
  • Full zero-trust service mesh (Istio) — Cilium L7 gets 80% of the value at 10% of the cost; skip Istio unless we need mTLS to external services.
  • Air-gapped build — not meaningful when agents talk to the primary LLM provider (api.anthropic.com by default; same reasoning for OpenAI, Gemini, or OpenRouter when configured) by design. Local Ollama is the only fully air-gapped option and only covers tasks small enough for a local model — see provider-portability.md.
  • HSM-backed signing keys — keyless Sigstore is better for our threat model, not worse. HSMs solve key-custody; we solve key-custody by not having long-lived keys.
  • Full SAST + DAST + IAST suite — Dependabot + Socket + Kyverno + property tests catch the failure modes relevant to our scale.

The security dashboard

Metrics the dashboard surfaces (observability.md):

  • admission_reject_total{policy="..."} — Kyverno rejections per policy
  • signature_verify_fail_total — cosign/rekor verification failures
  • slsa_provenance_missing_total — builds that would fail L3
  • egress_deny_total{agent="...", destination="..."} — Cilium L7 denials
  • vault_credential_issued_total{agent="...", type="db|github"} — short-lived credential churn
  • dependency_reject_total{reason="..."} — Socket / age / malware rejections
  • t3_cosign_latency_seconds — human approval turnaround

High-signal alert set: spikes in admission_reject_total or egress_deny_total indicate compromised agent or misconfigured policy; high t3_cosign_latency_seconds indicates humans are the bottleneck on T3 throughput.

The 2026 attack landscape

Partial catalog of real incidents informing these choices:

Incident Lesson
CVE-2025-54794/95 (InversePrompt against Claude) Prompting alone is not a prompt-injection defense
CVE-2025-59536 / CVE-2026-21852 (Claude Code project-file RCE) Don't clone untrusted repos into agent workspaces; strip hook/MCP configs on clone
CVE-2025-68143-45 (Anthropic Git MCP server RCE) MCP servers need their own network-policy scoping
Oasis "Claudy Day" (Claude.ai file exfil) Files API access requires egress policy scoping
Axios March 2026 (account-takeover typosquat) Package age policy catches most of these
shai-hulud September 2025 (200+ package supply chain attack) SBOM + Socket + age policy + ephemeral install sandbox
Cloudflare Dec 5 2025 outage (emergency fast path bypassed canary) No emergency fast path — all mutable surfaces flow through the same gated pipeline

See OWASP Top 10 for Agentic Applications (2026) for the canonical list of agent-specific attack classes.

Evolving the policies

Changes to Kyverno policies are themselves a meta-T3 operation (T3 change to the enforcement of T3). They go through:

  1. PR to dashecorp/rig-gitops/policies/kyverno/
  2. Review-E review with policy-specific criteria
  3. Human co-sign required
  4. Flux rolls the policy into the cluster
  5. Audit: after deployment, a projection checks that no previously-admitted resource would now be rejected (grandfathering check); existing rejected resources are reported as alerts

See also