Security Model

Trust boundary

┌─────────────────────────────────────────────────────┐
│  Untrusted zone                                     │
│                                                     │
│  Python adapter  ─── POST /v1/tool ──►  Go runtime │ ◄─ Trusted zone
│  LangChain tool                                     │
│  Any HTTP client                         │          │
└─────────────────────────────────────────│──────────┘
                                          │
                              policy evaluation (OPA)
                                          │
                                    Allow │ Deny
                                          │
                              tool execution (allow only)

The runtime is the sole policy authority. Adapters submit tool call intents via POST /v1/tool. The runtime evaluates policy and either executes the tool (allow) or returns HTTP 403 (deny). No adapter code path can flip deny to allow.

Fail-closed: if the policy evaluator returns an error for any reason, the runtime defaults to Deny. An unavailable or corrupt policy bundle produces deny-all behavior, not allow-all.


Runtime API contract

POST /v1/tool
Content-Type: application/json

{"kind":"<tool-kind>","params":{...}}

HTTP status

Meaning

200

Decision = allow; tool executed; response contains output

403

Decision = deny; tool never ran; response contains reason

400

Allow decision but tool execution failed (invalid params, endpoint not allowlisted)

405

Non-POST request

The decision field is always present in the response body. The policy_ref field carries the version of the active bundle that made the decision.


Supply-chain verification order

autonomy verify applies four checks in sequence. All must pass; the command is fail-closed.

Step 1 — Signatures
  cosign verify image
  cosign verify <tag>-lock     (if --require-lock)
  cosign verify <tag>-policy   (if --require-policy)

Step 2 — OCI digest integrity
  Resolve live manifest digest
  Compare against agent_artifact.digest in attached lock file

Step 3 — Behavioral fingerprint
  Recompute BLAKE3 fingerprint of lock file
  Compare against behavioral_fingerprint field

Step 4 — Semver consistency
  Parse version tag from policy_bundle.ref
  Compare major.minor against bundle manifest.json version field

Each step assumes the previous step passed. Skipping --require-lock or --require-policy removes those cosign checks but does not skip the OCI digest and fingerprint checks for whichever artifacts are present.


Signing implementation

AutonomyOps uses cosign as an external CLI binary (Option A — subprocess) rather than the cosign Go SDK.

Why CLI and not the Go SDK?

The cosign Go SDK pulls in hundreds of transitive dependencies: sigstore, TUF, OIDC, Rekor, and their chains. Keeping the cosign binary as an external dependency gives the same functionality while keeping the Go binary lean and the dependency graph auditable.

Where the CLI is invoked:

Component

Invocation

Purpose

oci/sign/cosign.go runCosign()

subprocess

Shared cosign wrapper for sign + verify

oci/sign/sign.go

via runCosign

cosign sign with annotations

oci/sign/verify.go

via runCosign

cosign verify --output=json (4-step pipeline)

oci/verify.go VerifyBundle()

subprocess

Single-artifact signature check

policy/verifier.go Verify()

subprocess

Policy bundle signature pre-flight

No Go SDK import: github.com/sigstore/cosign/v2 is not imported anywhere.

Prerequisites: cosign must be in PATH for autonomy sign, autonomy verify, and autonomy policy fetch to function. Runtime policy evaluation does not require cosign.

Install cosign: https://github.com/sigstore/cosign/releases

Distinct error types for each failure mode:

Sentinel

Step

Cause

ErrNotSigned

1

No cosign signature in registry

ErrDigestMismatch

2

SHA-256 OCI digest ≠ lock record

ErrFingerprintMismatch

3

BLAKE3 behavioral fingerprint ≠ stored value

ErrSemverIncompat

4

policy_bundle_version major.minor mismatch

ErrTimestampMissing

1

autonomy.signed-at annotation absent

ErrTimestampExpired

1

autonomy.signed-at older than --max-age

ErrCosignNotFound

any

cosign binary not in PATH


Key management

Demo keys live in demo/keys/. They are not suitable for production.

Regenerate:

bash demo/keys/generate.sh

The cosign.key file is PKCS8 PEM, encrypted with COSIGN_PASSWORD. Set the environment variable before signing or verifying:

COSIGN_PASSWORD=<secret> autonomy sign \
  --image localhost:5000/agent:v1 \
  --key demo/keys/cosign.key \
  --lock --policy

Timestamp annotation

By default (AUTONOMY_TRUST_TIME=true), every signature carries an autonomy.signed-at annotation (RFC3339 UTC). autonomy verify rejects signatures older than --max-age (default: 8760h / 1 year).

Disable timestamp enforcement for air-gapped environments where clocks are unreliable:

AUTONOMY_TRUST_TIME=false autonomy verify \
  --image <ref> --pub <key>

Weakened: with AUTONOMY_TRUST_TIME=false, a compromised private key can produce signatures with no temporal bound. Use only with an out-of-band key rotation process.


Air-gapped operation

All runtime operations work without external network access.

  1. Build the binary on an internet-connected machine, copy to the air-gapped host.

  2. Push images and policy bundles to a private registry before the gap.

  3. autonomy policy fetch pulls a cached bundle from the private registry.

  4. autonomy verify requires the cosign public key on disk and a local registry; no Sigstore transparency log or certificate authority is contacted when using key-based signing.

The capability probe (autonomy oci probe) and attachment operations contact only the registry specified in --image. There are no callbacks to external services.


Policy evaluation

Runtime policy evaluation uses OPA/Rego from the loaded policy bundle. The query is data.autonomy.allow, with input containing action kind and params.

Decision behavior is fail-closed:

  • If OPA returns allow == true, runtime allows the action.

  • If evaluation errors, returns no result, or returns false, runtime denies.

This preserves the same trust boundary: adapters submit requests, runtime remains the sole policy authority.


Adapter trust level

Adapters — Python RuntimeClient, LangChain RuntimeTool, any HTTP client — are untrusted. They:

  • Can submit tool call requests.

  • Cannot override policy decisions.

  • Cannot execute tools directly; they receive only the runtime’s output.

  • Cannot suppress PolicyDeniedError; the exception propagates unconditionally.

The Python adapter enforces this at the type level: call_tool() returns ToolResult on allow and raises PolicyDeniedError on deny. There is no return path that converts a deny into a non-exceptional result.


Telemetry and audit

Every policy decision emits an autonomy.decision event to the WAL with:

  • tool — the action kind

  • outcomeallow or deny

  • reason — human-readable explanation from the evaluator

  • policy_ref — the bundle version that made the decision

The WAL is append-only and fsynced on each write. Events cannot be deleted from the WAL; telemetry drain deletes only from the SQLite priority buffer, not from the source WAL. telemetry drain reads from the consumer cursor (telemetry.pos) and advances that cursor only after successful delivery, providing at-least-once semantics. Receivers should deduplicate by stable event identity (event_id).

To forward events to an OTLP collector:

autonomy telemetry drain --endpoint http://collector:4318

Error events drain first (PriorityHigh), decisions and actions second (PriorityNormal), lifecycle events last (PriorityLow).