Agentic AI Atlasby a5c.ai
OverviewWikiGraphFor AgentsEdgesSearchWorkspace
/
GitHubDocsDiscord
iiRecord
Agentic AI Atlas · Proof-Based Policy Enforcement
page:docs-policy-enforcementa5c.ai
Search record views/
Record · tabs

Available views

II.Record viewspp. 1 - 1
overviewarticlejsongraph
II.
Page JSON

page:docs-policy-enforcement

Structured · live

Proof-Based Policy Enforcement json

Inspect the normalized record payload exactly as the atlas UI reads it.

File · wiki/docs/policy-enforcement.mdCluster · wiki
Record JSON
{
  "id": "page:docs-policy-enforcement",
  "_kind": "Page",
  "_file": "wiki/docs/policy-enforcement.md",
  "_cluster": "wiki",
  "attributes": {
    "nodeKind": "Page",
    "sourcePath": "docs/policy-enforcement.md",
    "sourceKind": "repo-docs",
    "title": "Proof-Based Policy Enforcement",
    "displayName": "Proof-Based Policy Enforcement",
    "slug": "docs/policy-enforcement",
    "articlePath": "wiki/docs/policy-enforcement.md",
    "article": "\n# Proof-Based Policy Enforcement\n\nA cryptographic policy-enforcement layer for the babysitter monorepo. A specific\ncommand, run with a specific tool and specific credentials, executes **only** when a\ndeclarative policy's required *trust chain of signed evidence* is satisfied. When a\npolicy is satisfied, a short-lived **`CommandAuthorization`** envelope is minted and the\ntool layer verifies it at the point of execution, **failing closed** for policy-covered\nactions.\n\nThis document describes the **shipped** system (Milestones A–E). Every schema, config\nkey, environment variable, and behavior below is verified against the source, not the\ndesign doc alone. Where the design and code differ, this documents the **code**.\n\n- **Authoritative design spec:** [`docs/design/proof-based-policy-enforcement.md`](design/proof-based-policy-enforcement.md)\n  (acceptance criteria `AC-n` are referenced throughout for traceability).\n- **Package:** [`@a5c-ai/policy-adapter`](../packages/adapters/policy) (`packages/adapters/policy`).\n- **Trust primitives:** [`@a5c-ai/genty-core/trust`](../packages/genty/core/src/trust).\n- **Worked example configs:** [`packages/adapters/policy/examples/`](../packages/adapters/policy/examples).\n\n> **Overriding rule (repo policy: \"fallbacks are evil\").** Every verification, parse, and\n> config step **fails closed**: any error, missing field, or thrown exception is a\n> **denial**, never a pass-through. There is no fallback-allow anywhere in the enforcement\n> path.\n\n---\n\n## 1. Concepts\n\n### 1.1 The unified proof envelope\n\nEvery producer and consumer speaks one proof format: genty's `SignedEnvelope<T>`\n([`packages/genty/core/src/trust/types.ts`](../packages/genty/core/src/trust/types.ts)):\n\n```ts\ninterface SignedEnvelope<T> {\n  payload: T;\n  signature: string;               // base64 Ed25519 signature\n  publicKeyFingerprint: string;    // sha256 of the SPKI/DER public key\n  signedAt: string;                // ISO\n  signedFields: string[];          // exactly which payload fields the signature covers\n  algorithm: 'Ed25519';\n}\n```\n\nCanonicalization is genty's `canonicalize`\n([`signing.ts`](../packages/genty/core/src/trust/signing.ts)):\n`JSON.stringify({ _meta: deepSortKeys(meta), _payload: deepSortKeys(extractFields(payload, signedFields)) })`.\nNo new envelope type or canonicalization routine is introduced — the policy adapter\nimports `signPayload` / `verifySignature` from `@a5c-ai/genty-core`.\n\n**Two hardening rules the raw primitives do not provide (added in the policy adapter):**\n\n- **`signedFields` completeness (AC-2).** A field present on `payload` but absent from\n  `signedFields` is **not** covered by the signature. The verifier asserts a per-kind\n  required-field set at the trust boundary and denies on any missing field. See\n  `REQUIRED_SIGNED_FIELDS` in\n  [`verify-envelope-trusted.ts`](../packages/adapters/policy/src/verify-envelope-trusted.ts).\n- **Domain separation via `payloadType` (AC-51).** Every payload carries a constant\n  `payloadType` discriminant that MUST be in `signedFields`. The verifier binds the\n  expected constant per kind, so a signature over one payload kind is **not transferable**\n  to a structurally compatible payload of another kind (`EXPECTED_PAYLOAD_TYPE`).\n\n### 1.2 Evidence taxonomy\n\nThree evidence kinds, each anchored to a distinct trust-root **kind**:\n\n| Evidence | `payloadType` | Payload | Producer | Trust-root kind |\n| --- | --- | --- | --- | --- |\n| **human-approval** | `human-approval` | `PermissionEvidencePayload` (`{action, scope, approvedBy, approvedAt, expiresAt?}`) | signed breakpoint answer / proven bridge | `human` |\n| **model-decision** | `model-decision` | `ModelDecisionPayload` (§1.3) | transport proxy (authoritative) **or** in-process genty | `engine` |\n| **delegation** | `delegation` | `DelegationChainLink` (`{delegatorFingerprint, delegatorSignature, delegatedAt}`) | agent identity | `agent` |\n\nThe policy adapter exposes an `Evidence` discriminated union\n(`{ kind, envelope }`). The `kind` is a *claim*, not a trust decision: it selects which\nrequired trust-root kind the verifier binds against, and the verifier rejects if the\nresolved trust root's kind disagrees. A caller cannot relabel an engine-signed envelope\nas `human-approval`.\n\n### 1.3 Model-decision attestation and tool-call binding (AC-34)\n\n`ModelResponsePayload` has no field naming the tool call it authorized, so a valid\nattestation would be replayable to a *different* tool call in the same model turn.\n`ModelDecisionPayload`\n([`model-decision.ts`](../packages/genty/core/src/trust/model-decision.ts)) extends it —\nthe single, deliberate exception to the no-new-schema rule:\n\n```ts\ninterface SignedToolCall {\n  toolCallId: string;   // the provider/harness tool-call id\n  name: string;         // tool name the model chose\n  argsHash: string;     // sha256 of the canonical JSON of that call's arguments\n}\ninterface ModelDecisionPayload extends ModelResponsePayload {\n  payloadType: 'model-decision';\n  toolCalls: SignedToolCall[];   // EVERY tool call the model emitted this turn, each bound\n}\n```\n\nThe signed-field set is fixed to `MODEL_DECISION_SIGNED_FIELDS`\n(`payloadType, modelId, provider, inputMessagesHash, outputContent, timestamp, toolCalls`).\nA model-decision step is satisfied for a given executing tool call **iff** the attestation\ncontains a `SignedToolCall` whose `toolCallId` **and** `argsHash` both match the call about\nto run (AC-34a) — so the turn's attestation cannot be replayed onto a sibling call.\n\n### 1.4 `CommandAuthorization` (AC-8)\n\nOn a satisfied policy the issuer mints a short-lived\n`SignedEnvelope<CommandAuthorizationPayload>` binding the exact execution context:\n\n```ts\ninterface CommandAuthorizationPayload {\n  payloadType: 'command-authorization';\n  policyId: string;\n  policyDocHash: string;       // sha256 of the integrity-verified policy doc that granted this\n  configEpoch: number;         // the config epoch under which it was issued (AC-46)\n  matchedChainId: string;\n  toolName: string;\n  toolCallId: string;          // the exact tool-call id this authorization is bound to\n  commandHash: string;         // sha256 of the canonicalized argv (empty-string sentinel if N/A)\n  argsHash: string;            // sha256 of the canonical JSON of the tool args\n  credentialScope: string;\n  evidenceFingerprints: string[];\n  evidenceEnvelopeHashes: string[];   // content hash of each satisfying evidence envelope\n  evidenceStepBindings: { stepIndex; requiredKind; envelopeHash }[]; // one per required step\n  runId?: string;\n  sessionId?: string;\n  issuedAt: string;\n  expiresAt: string;           // short-lived; default 120s, per-policy override\n}\n```\n\nAll fields above are in `signedFields` (enforced at runtime by AC-2). The default TTL is\n`120` seconds. Every gate **re-verifies** the authorization against the exact\ntool/args/command/scope about to run, recomputing `argsHash` and `commandHash` at the gate\n(TOCTOU binding).\n\n### 1.5 The shared canonicalizer (AC-52)\n\n`argsHash` / `commandHash` are computed in the proxy producer and at each gate. Byte\ndivergence would silently break the binding, so **one** canonicalizer is exported from the\nadapter ([`canonicalize-args.ts`](../packages/adapters/policy/src/canonicalize-args.ts))\nand imported by every producer and gate:\n\n- `canonicalizeArgs(value): string` — **total** (throws only the explicit deny-path on\n  non-finite numbers / non-representable types, never a silent coercion), **loss-preserving**\n  (deep-sorts object keys but preserves array order, string bytes, value types; no\n  lowercase/trim/unicode-fold).\n- `canonicalizeArgv(command): string[]` — POSIX-ish tokenizer honoring single/double quotes\n  and backslash escapes; throws on an unterminated quote (not statically tokenizable → the\n  caller must deny). Backticks and `$( )` are **not** treated as quotes — they stay in the\n  token so the argv matcher can detect and deny them.\n- `argsHash(v) = sha256(canonicalizeArgs(v))`, `commandHash(cmd) = sha256(canonicalizeArgv(cmd).join('|'))`.\n\nThe in-process genty attestation producer re-implements the byte-identical serialization\n([`in-process-attestation.ts`](../packages/genty/core/src/trust/in-process-attestation.ts))\nbecause genty-core is a *dependency* of the policy adapter (importing back would be a\ncycle); a conformance test pins byte-identity.\n\n---\n\n## 2. Trust and threat model\n\n**Adversary in scope:** a compromised, **workspace-writable** agent. It can read and edit\nany file on the workspace — including `.policy/` config, trust-roots, and policy documents\n— and can emit envelopes signed with any key it holds.\n\n**What is protected:**\n\n- **Config integrity + anti-rollback (AC-36/AC-45/AC-46/AC-47).** Trust-roots and policy\n  files are honored **only** when covered by a single signed manifest with a monotonic\n  `configEpoch`, verified against an **off-workspace pinned** config root and epoch floor\n  (§4). The agent can edit files but cannot produce a valid signature or a higher epoch, so\n  tampered or rolled-back config is rejected (deny-all).\n- **Key resolution (AC-35).** Signatures are verified only against key material resolved\n  from the trusted store, with fingerprint binding and cross-kind rejection — never against\n  a caller-supplied key.\n- **Human-approval non-spoofing (AC-28/AC-48).** An `approved` bit is honored only if it was\n  actually inside the human-signed field set, signed by a `human` trust root.\n- **Model-decision binding (AC-30/AC-34).** The authoritative model attestation is signed by\n  a key held **outside** the agent (the proxy); the agent cannot forge which model answered\n  nor which call it authorized. In-process attestation is correlation-grade only (§3.1).\n- **TOCTOU (AC-32).** The authorization is verified at the execution gate against the exact\n  hashes about to run, recomputed at the gate.\n- **Credential scoping (AC-40).** The credential→scope mapping comes from a trusted\n  out-of-agent source keyed by a collision-resistant credential identity; the agent cannot\n  relabel a broad credential as a narrow scope.\n\n**Explicit non-goals (do not build under this design):** journal hash-chaining; a general\nsecrets/vault backend; replacing the proxy's bearer-token transport auth; a network\nPKI/CA/OCSP service; a policy-authoring UI; rewriting proven's canonical form immediately;\nsigning the passthrough-proxy path; and **gating substrate-delivered credentials** (IMDS /\nIRSA / workload-identity, image-baked creds, pre-existing mounts) — see §6.4.\n\n---\n\n## 3. Evidence producers\n\n### 3.1 Model attestation — two producers, one evidence type\n\nBoth producers emit the same `ModelDecisionPayload` and both register as `engine` trust\nroots with **distinct** fingerprints, distinguished by a `producer` marker on the trust\nroot (`'proxy'` vs `'in-process'`):\n\n- **Authoritative — transport proxy** (`@a5c-ai/transport-adapter`,\n  [`attestation.ts`](../packages/adapters/transport/src/attestation.ts)). Signs at the wire\n  seam with a key held by the proxy process, outside the agent. Marker\n  `PROXY_ATTESTATION_PRODUCER = 'proxy'`.\n  - Non-streaming (`signCompletionAttestation`, AC-12): binds each `CompletionResult`\n    tool call; `arguments` is a provider JSON string, `JSON.parse`d then hashed via the\n    shared `argsHash`.\n  - Streaming (`accumulateStreamAttestation`, AC-13): accumulates tool-call argument deltas\n    and signs **only** at the terminal `done` event, over the fully-accumulated arguments.\n  - Sidecar (`AttestationSidecarStore`, AC-14/AC-16): keyed by request id, indexed by every\n    bound tool-call id. The model response body is never mutated (bodies stay\n    provider-compatible).\n- **Correlation-grade — in-process genty** (`@a5c-ai/genty-core`,\n  [`in-process-attestation.ts`](../packages/genty/core/src/trust/in-process-attestation.ts),\n  AC-15). Signs one `ModelDecisionPayload` per model turn with the agent's own key, attached\n  to each call's `ToolExecutionContext`. Marker `IN_PROCESS_ATTESTATION_PRODUCER =\n  'in-process'`. **Because the key is inside the agent, a compromised agent can forge it** —\n  it is rejected for any action that requires proxy attestation (§3.3).\n\n**Proxy config (AC-11).** The proxy attestation identity is read from env\n([`config.ts`](../packages/adapters/transport/src/config.ts)):\n\n| Env var | Field |\n| --- | --- |\n| `AGENT_MUX_PROXY_ATTESTATION_ENABLED` | `attestationEnabled` |\n| `AGENT_MUX_PROXY_ATTESTATION_KEY_PATH` | `attestationKeyPath` |\n| `AGENT_MUX_PROXY_ATTESTATION_FINGERPRINT` | `attestationFingerprint` |\n| `AGENT_MUX_PROXY_ATTESTATION_SIDECAR_DIR` | `attestationSidecarDir` |\n\n**Passthrough gap (AC-18).** In proxy passthrough mode (no completion engine) bodies are\nforwarded unparsed, so **no attestation is produced**. Any action requiring proxy\nattestation — explicitly or by the AC-39 default — therefore **denies** on passthrough\n(fail-closed). Do not route high-assurance actions through passthrough transports.\n\n### 3.2 Human approvals — signed breakpoints and the proven bridge\n\n- **Enforced signed breakpoints (AC-4/AC-28/AC-48).**\n  [`enforce-signed-breakpoint.ts`](../packages/babysitter-sdk/src/breakpoints/enforce-signed-breakpoint.ts)\n  is the enforcement decision for a breakpoint answer. When a signature is required, an\n  unsigned / invalid / wrong-key / tampered / unsigned-`approved` answer is **rejected**\n  (the commit is refused, the effect stays pending). The security-critical set\n  `{breakpointId, approved, responderId}` MUST be inside the human-signed `signedFields`,\n  and only `approved === true` is honored. The trusted `human` fingerprints come **only**\n  from the manifest-verified trust-roots config, never from `task.json`.\n- **Proven bridge (AC-3/AC-43/AC-48).**\n  [`proven-bridge.ts`](../packages/adapters/policy/src/proven-bridge.ts) converts a legacy\n  `ProvenBreakpointAnswer` into `human-approval` evidence **without re-signing the human's\n  intent as the adapter's own**. It applies the AC-48 completeness assertion\n  (`{breakpointId, approved, responderId} ⊆ signedFields`, each present) **before** deriving\n  anything, requires `originalHumanFingerprint` to be a currently-valid, non-revoked `human`\n  root, verifies via proven `verifyAnswer`, and derives only when `approved === true`. The\n  derived envelope records `originalHumanFingerprint`; the evaluator anchors the\n  human-approval step on that human root, not on the adapter's bridging key.\n\n### 3.3 High-assurance: `requireProxyAttestation` and the credential default (AC-17/AC-39)\n\nA model-decision step is satisfied only by a `producer:'proxy'` root when proxy attestation\nis required. `requireProxyAttestation`:\n\n- **Defaults to `true`** for any action whose `match` names a `credentialScope` (a\n  credential-touching action). The in-process (agent-held) attestation is rejected unless\n  the policy author explicitly sets `requireProxyAttestation: false`.\n- **Defaults to `false`** for non-credential actions.\n\nThis is enforced in the evaluator\n([`policy-evaluator.ts`](../packages/adapters/policy/src/policy-evaluator.ts),\n`attestationIsProxy` + the `requireProxyAttestation` derivation in `evaluatePolicy`).\n\n---\n\n## 4. Config: trust roots, the signed manifest, and key ops\n\n### 4.1 The `.policy/` directory\n\nPublic material is git-tracked; private keys are never git-tracked. A signed `.policy/`\ntree at run time:\n\n```\n.policy/\n  config-manifest.json          # SignedEnvelope<ConfigManifestPayload>, signed by the\n                                #   off-workspace config root (AC-36); carries configEpoch (AC-46)\n  trust-roots.json              # public key material only (AC-26); human / engine / agent roots\n  credential-scope-source.json  # trusted credential→scope source (AC-40 / AC-40a / AC-55) [optional]\n  policies/\n    *.yaml                      # covered actions + trust chains (§5)\n  breakpoint-policy.json        # which breakpoints require a signature (used by the SDK breakpoint gate)\n```\n\nPrivate keys live outside git: human private keys under `.breakpoints/.keys/private/`\n(existing proven layout), engine/issuer private keys under `.policy/.keys/private/`.\n**Git-tracking a file does not make it trusted; presence in the current-epoch signed\nmanifest does.**\n\n### 4.2 Trust roots (`TrustRoot`, AC-6)\n\n```ts\ninterface TrustRoot {\n  fingerprint: string;                 // sha256 of the SPKI/DER public key\n  kind: 'human' | 'engine' | 'agent' | 'tool' | 'config';\n  publicKey?: string;                  // inline PEM/DER-base64 ...\n  publicKeyPath?: string;              // ... OR a path to it (exactly one)\n  label: string;\n  identityId?: string;                 // stable identity for quorum distinct-holder counting (AC-41)\n  producer?: 'proxy' | 'in-process';   // for `engine` roots: proxy vs in-process (AC-39)\n  expiresAt?: string;\n  revoked?: boolean;\n}\n```\n\nThe trust-roots file is `{ \"trustRoots\": [...], \"revoked\": [] }`. `loadTrustStore`\n([`policy-schema.ts`](../packages/adapters/policy/src/policy-schema.ts)) rejects a store\ncontaining **duplicate fingerprints** (a cross-kind-confusion vector).\n\n### 4.3 The trusted-store verifier (`verifyEnvelopeTrusted`, AC-35)\n\nThe single sanctioned entry to raw signature checking, in\n[`verify-envelope-trusted.ts`](../packages/adapters/policy/src/verify-envelope-trusted.ts).\nIt fails closed at the first failure, in order:\n\n1. Resolve key material **only** from the trusted store by fingerprint (the envelope's own\n   embedded key is ignored). Absent → deny.\n2. Require the resolved root's `kind` to equal the required kind; if the step supplied\n   `allowedFingerprints`, the resolved fingerprint must be in that set.\n3. Bind fingerprint to material: `sha256(resolvedPublicKey) === envelope.publicKeyFingerprint`.\n4. Cross-kind rejection (redundant with (2), stated explicitly).\n5. `signedFields` completeness + bound `payloadType` for the kind.\n6. Root validity: not `revoked`, not expired at `signedAt`.\n7. **Only now** call genty `verifySignature`. Any thrown exception in (1)–(7) is a denial.\n\n`verifyTrustChainTrusted` verifies delegation chains: each link's key is resolved from the\nstore (never taken from the link), and true delegation linkage is asserted\n(`delegatorFingerprint` must name the immediate predecessor's signer and\n`delegatorSignature` must equal the parent's signature), rejecting reordered or spliced\nchains.\n\n### 4.4 The signed config manifest (AC-36/AC-46) and epoch floor (AC-47)\n\nA single `SignedEnvelope<ConfigManifestPayload>` covers **all** config files together:\n\n```ts\ninterface ConfigManifestPayload {\n  payloadType: 'config-manifest';\n  configEpoch: number;              // monotonically increasing; strictly greater on every re-sign\n  files: { path: string; sha256: string }[];  // EVERY config file\n  issuedAt: string;\n}\n```\n\n`verifyConfigManifest`\n([`config-manifest.ts`](../packages/adapters/policy/src/config-manifest.ts)) runs before\nany trust root or policy is honored and denies-all on any mismatch:\n\n1. Verify the manifest via `verifyEnvelopeTrusted(..., 'config-manifest', ...)` against the\n   pinned config root only.\n2. Enforce the epoch floor: `configEpoch >= minEpoch` (else deny-all).\n3. Every on-disk config file must be listed in the manifest, and every listed file must be\n   present with a matching sha256.\n\nBecause trust-roots (including the revocation list) and every policy doc are hashed into\n**one** manifest under **one** epoch, partial rollback of a single file is impossible, and\nan older validly-signed but lower-epoch manifest is rejected by the floor.\n\n### 4.5 Off-workspace pins (env / deploy)\n\nTwo pins are held off the workspace (env / deploy image), never in a workspace file the\nagent can edit:\n\n| Env var | Meaning |\n| --- | --- |\n| `POLICY_CONFIG_ROOT_FP` | the pinned config-root fingerprint (AC-37). **Its presence toggles enforcement.** |\n| `POLICY_CONFIG_MIN_EPOCH` | the minimum acceptable `configEpoch` floor (AC-47); defaults to `1`. |\n| `POLICY_CONFIG_ROOT_KEY` | the config-root public key material (PEM/DER-base64) ... |\n| `POLICY_CONFIG_ROOT_KEY_PATH` | ... or a path to it. |\n| `POLICY_CONFIG_DIR` | override for the `.policy` directory (default `<projectRoot>/.policy`). |\n| `POLICY_ISSUER_ROOTS` | the `engine` trust roots that may sign a `CommandAuthorization` (JSON). |\n\nThese names are shared by the SDK breakpoint gate\n([`trusted-breakpoint-policy.ts`](../packages/babysitter-sdk/src/breakpoints/trusted-breakpoint-policy.ts))\nand the tool-layer gate\n([`policy-enforcement-wiring.ts`](../packages/adapters/policy/src/policy-enforcement-wiring.ts)),\nso one pinned anchor governs both.\n\n### 4.6 Key provisioning / rotation runbook\n\n> **Note on CLI status.** The design describes `policy-adapter init-config-root` /\n> `sign-config` CLI steps (AC-37). The **shipped** package exports the verification and\n> issuance library functions (`verifyConfigManifest`, `issueCommandAuthorization`,\n> `signPayload`, `createKeyPair`); the manifest-signing is performed by an out-of-agent\n> operator step (in the e2e fixture builder,\n> [`e2e-trust-chain.e2e.test.ts`](../packages/adapters/policy/src/__tests__/e2e-trust-chain.e2e.test.ts),\n> this is done in-process with `signPayload`). Treat the following as the operator\n> procedure the library supports; a packaged CLI binary is not yet shipped.\n\n1. **Generate the config root off the workspace.** `createKeyPair()`\n   (`@a5c-ai/genty-core/trust`) on the operator's trusted host (or KMS/HSM). Store the\n   private half where agents cannot read it. Pin the public half's fingerprint as\n   `POLICY_CONFIG_ROOT_FP` and its material as `POLICY_CONFIG_ROOT_KEY[_PATH]`, and pin\n   `POLICY_CONFIG_MIN_EPOCH` (start at `1`) — all in the deploy artifact, not the workspace.\n2. **Generate evidence-signing keys.** Human keys via proven `generateKeyPair`\n   (`kind:'human'`); engine/proxy/issuer keys via `createKeyPair` (`kind:'engine'`, with\n   `producer:'proxy'` / `'in-process'` where relevant); agent keys via `createAgentIdentity`\n   (`kind:'agent'`). Register their public halves in `.policy/trust-roots.json`.\n3. **Sign the manifest.** Hash `trust-roots.json` + every `policies/*.yaml`\n   (+ `credential-scope-source.json` / `breakpoint-policy.json` when present) into a\n   `ConfigManifestPayload` with `configEpoch = 1`, sign it with the config-root key, and\n   write `.policy/config-manifest.json`. Provision the issuer roots into `POLICY_ISSUER_ROOTS`.\n4. **Rotate a key.** Rotation reuses proven `rotateKey` (marks the old public key\n   `expiresAt`, provisions a new pair). A signature made while a key was valid still verifies\n   unless the key is revoked.\n5. **Revoke a key.** Set `revoked: true` in `trust-roots.json` — **and** re-sign a\n   strictly-higher `configEpoch` **and** advance the deploy-pinned `POLICY_CONFIG_MIN_EPOCH`.\n   Because the revocation list is hashed into the manifest under the epoch, this permanently\n   pushes the un-revoked (lower-epoch) state below the floor, so a rollback cannot un-revoke\n   a stolen key (AC-27/AC-46).\n\n---\n\n## 5. Policy document schema\n\nA policy document (YAML or JSON) is parsed and validated by `parsePolicyDocument`\n([`policy-schema.ts`](../packages/adapters/policy/src/policy-schema.ts)), which **fails\nclosed** (throws) on invalid YAML or any missing/invalid required field.\n\n```yaml\nversion: 1                          # required\nauthorizationTtlSeconds: 120        # default; per-action override allowed\ncommandDefaultAllow: false          # AC-38b: default-allow for command-bearing tools is OPT-IN (default false)\ndefaultDeny:                        # AC-23c: credentialScope globs that default-deny when uncovered\n  - \"aws:prod:*\"\nactions:\n  - id: aws-prod-write\n    effect: allow                   # optional; 'deny' makes an explicit deny action (deny > grant)\n    match:\n      tool: \"Bash\"                  # glob over tool name\n      argv:                         # AC-38: match on CANONICALIZED argv (absent → non-command tool)\n        program: \"aws\"\n        subcommandEquals: [\"s3 cp\", \"s3 rm\", \"s3 sync\"]\n        subcommandMatches: [\"s3 .*\"]      # optional; anchored regex over a normalized subcommand window\n        wrapperAllowlist: [\"time\", \"sudo\"] # AC-38c: transparent wrappers the matcher may recurse through\n        recognizedPrograms: [\"aws\"]        # programs recognized as legitimate for this scope\n      credentialScope: \"aws:prod:*\" # glob over the requested credential scope\n    requireProxyAttestation: true   # optional; defaults TRUE when credentialScope is present (AC-39)\n    authorizationTtlSeconds: 120    # optional per-action override\n    chains:                         # satisfied if ANY chain fully verifies (OR across chains)\n      - id: human-plus-opus\n        requirements:               # AND across a chain's requirements\n          - step:\n              kind: human-approval\n              trustedIdentities: [\"role:sre-oncall\"]\n              conditions:\n                scopeEquals: \"aws:prod:s3\"\n                notExpired: true\n          - step:\n              kind: model-decision\n              conditions:\n                modelIdMatches: \"claude-opus-.*\"\n```\n\n### 5.1 Action matchers\n\n- **`match.tool`** — a `*`-glob over the tool name.\n- **`match.argv` (AC-38, canonicalized/tokenized).** Matching operates on a canonicalized\n  argv, never a raw string (see\n  [`argv-matcher.ts`](../packages/adapters/policy/src/argv-matcher.ts)):\n  - `program` — the resolved binary **basename** (`/usr/local/bin/aws`, a symlink to it, and\n    bare `aws` all canonicalize to `aws`).\n  - `subcommandEquals` / `subcommandMatches` — matched as a **contiguous window over the\n    non-option token sequence**, so global options (`aws --region x s3 rm`) cannot hide a\n    covered/deny-listed subcommand. `subcommandMatches` regexes are anchored full-string over\n    the window.\n  - `wrapperAllowlist` (AC-38c) — a **closed per-scope allowlist** of transparent leading\n    wrappers (e.g. `time`, `sudo -u <user>`) the matcher recurses through. Any leading token\n    **not** on the allowlist, a shell `-c` payload, an interpreter running inline code\n    (`python -c`, `perl -e`, `node -e`, `python -m`), command substitution (`$()`/backticks),\n    variable-indirect programs (`$PROG`), `eval`, or inline env-assignment (`AWS=aws`) makes\n    the program **unresolvable**. An unresolvable program **that touches a covered scope** is\n    **denied** outright (covered-but-unauthorized, AC-38a) — it never falls through to\n    default-allow. (A shell `-c` payload's *inner* program is matched by recursing into it.)\n  - `recognizedPrograms` — the programs recognized as legitimate resolved programs for the\n    scope, used to decide whether an unresolvable command \"touches\" the scope.\n- **`match.credentialScope`** — a `*`-glob over the requested credential scope.\n\n### 5.2 Chains, requirements, conditions\n\n- **`chains[]`** — alternative trust-chain templates; the evaluator grants on the **first**\n  fully-satisfied chain (OR across chains, AC-19a). A grant action must declare ≥1 chain; a\n  `deny` action needs none.\n- **`requirements[]`** — a chain is a list of **requirements** evaluated with **AND**\n  (AC-41a). Each requirement is either `{ step: {...} }` or `{ quorum: {...} }`. An evidence\n  envelope may not be counted toward more than one requirement (no double-use).\n  - **Legacy sugar** accepted and normalized: a top-level `steps: [...]` (one `{ step }` per\n    entry) and a top-level `quorum: {of, min}` (with a sibling single `steps[0]` supplying\n    its `trustedIdentities`/`conditions`).\n- **`step`** — `{ kind, trustedIdentities?, conditions? }`. `kind` is one of `human-approval`\n  | `model-decision` | `delegation`. `trustedIdentities` are fingerprints or role labels\n  passed to the verifier as `allowedFingerprints`.\n- **`quorum` (AC-41 distinct-holder)** — `{ of, min, trustedIdentities?, conditions? }`.\n  Requires `min` evidences of that kind from **`min` distinct trusted-store identities**. For\n  human approvals, distinctness is keyed on the resolved trust root's `identityId` (falling\n  back to `label`, then fingerprint), **not** the payload's attacker-signed `approvedBy` — so\n  one human's two keys cannot meet a 2-of quorum.\n- **`conditions`** (all in\n  [`policy-schema.ts`](../packages/adapters/policy/src/policy-schema.ts) `StepConditions`,\n  evaluated in `policy-evaluator.ts` `conditionsHold`):\n  - `modelIdMatches` — anchored full-string regex over `payload.modelId` (the \"opus decided\"\n    allowlist; anchoring prevents `gpt-cheap-claude-opus-x` widening).\n  - `scopeEquals` — `payload.scope` must equal this value.\n  - `notExpired` — reuses `isPermissionValid` for `PermissionEvidence`.\n  - `tagContains` — a tag must be present in `payload.tags`.\n  - `requiresDelegation` / `expectedDelegatee` — a delegation requirement takes the chain\n    path, verifies true linkage via `verifyTrustChainTrusted`, and asserts the terminal\n    identity equals `expectedDelegatee` (a single independent link cannot satisfy it).\n\n### 5.3 Evaluation semantics (AC-20)\n\n`evaluatePolicy` returns `{ granted, matchedChainId?, reason, evidenceUsed, actionId?,\nrequiredStepCount?, stepBindings? }`. Precedence: a covered-but-disguised command (AC-38a)\ndenies unconditionally; then any matching `deny` action wins over grants; then the first\ngrant action whose any chain is fully satisfied grants. A below-floor `configEpoch` denies\nall. On a grant, `issueFromDecision`\n([`authorization-issuer.ts`](../packages/adapters/policy/src/authorization-issuer.ts)) mints\nthe `CommandAuthorization` **only** when `decision.granted === true`, binding\ntool/args/command/scope/toolCallId/epoch/chain from the granted decision and one evidence\nper satisfied requirement — never from free-form caller input.\n\n### 5.4 Worked example — `aws-cli` human + opus (with quorum alternate)\n\nFrom\n[`examples/aws-cli-human-plus-opus/policies/aws-prod-write.yaml`](../packages/adapters/policy/examples/aws-cli-human-plus-opus/policies/aws-prod-write.yaml)\n— copy-pasteable:\n\n```yaml\nversion: 1\nauthorizationTtlSeconds: 120\ncommandDefaultAllow: false\ndefaultDeny:\n  - \"aws:prod:*\"\nactions:\n  - id: aws-prod-write\n    match:\n      tool: \"Bash\"\n      argv:\n        program: \"aws\"\n        subcommandEquals: [\"s3 cp\", \"s3 rm\", \"s3 sync\"]\n        wrapperAllowlist: [\"time\", \"sudo\"]\n        recognizedPrograms: [\"aws\"]\n      credentialScope: \"aws:prod:*\"\n    # requireProxyAttestation omitted -> defaults to TRUE (credentialScope present, AC-39).\n    chains:\n      - id: human-plus-opus\n        requirements:\n          - step:\n              kind: human-approval\n              trustedIdentities: [\"role:sre-oncall\"]\n              conditions:\n                scopeEquals: \"aws:prod:s3\"\n                notExpired: true\n          - step:\n              kind: model-decision\n              conditions:\n                modelIdMatches: \"claude-opus-.*\"\n      - id: two-human-quorum\n        requirements:\n          - quorum:\n              of: \"human-approval\"\n              min: 2\n              trustedIdentities: [\"role:sre-oncall\"]\n              conditions:\n                scopeEquals: \"aws:prod:s3\"\n                notExpired: true\n```\n\n`s3 cp|rm|sync` under `aws:prod:*` is authorized by **either** a signed human approval AND\nan opus model-decision, **or** a distinct-holder 2-human quorum (OR across chains). Because\nthe action names a `credentialScope`, proxy attestation is required by default.\n\nThe scenario's trust roots and credential source are in\n[`trust-roots.template.json`](../packages/adapters/policy/examples/aws-cli-human-plus-opus/trust-roots.template.json)\n(two human roots with distinct `identityId`s, a `proxy` and an `in-process` engine root, and\nthe issuer engine root) and\n[`credential-scope-source.template.json`](../packages/adapters/policy/examples/aws-cli-human-plus-opus/credential-scope-source.template.json)\n(a KMS credential with two aliases canonicalizing to one identity → one scope, AC-55). The\n`.template.json` placeholders are filled by the e2e fixture builder with real Ed25519 keys.\n\n### 5.5 Worked example — 2-human quorum (configurability)\n\nFrom\n[`examples/aws-cli-two-human-quorum/policies/aws-prod-write-quorum.yaml`](../packages/adapters/policy/examples/aws-cli-two-human-quorum/policies/aws-prod-write-quorum.yaml)\n— a structurally different chain shape in the same format with **no code change**:\n\n```yaml\nversion: 1\nauthorizationTtlSeconds: 120\ncommandDefaultAllow: false\nactions:\n  - id: aws-prod-write-quorum\n    match:\n      tool: \"Bash\"\n      argv:\n        program: \"aws\"\n        subcommandEquals: [\"s3 cp\", \"s3 rm\", \"s3 sync\"]\n        recognizedPrograms: [\"aws\"]\n    # No credentialScope → proxy attestation NOT required by default; a pure human quorum.\n    chains:\n      - id: two-human-quorum\n        requirements:\n          - quorum:\n              of: \"human-approval\"\n              min: 2\n              trustedIdentities: [\"role:sre-oncall\"]\n              conditions:\n                scopeEquals: \"aws:prod:s3\"\n                notExpired: true\n```\n\nTwo distinct human identities approve ⇒ allow. One human's two keys ⇒ deny (distinct-holder\nrule). This proves the policy component is not hardcoded to the human+opus flow.\n\n---\n\n## 6. Enforcement gates\n\nCovered = the tool/command/creds match some policy `action`. Uncovered = no action matches.\nThe checked-in exec-seam registry\n([`exec-seam-registry.ts`](../packages/adapters/policy/src/exec-seam-registry.ts), AC-56)\nenumerates every tool-execution / dispatch / spawn entry seam with its enforcement role; the\nenumeration test fails the build if a new exec path is added without registering it.\n\n### 6.1 Uniform gate contract (AC-23)\n\nFor a **covered** action: resolve required evidence, evaluate the policy against the\nintegrity-verified doc (not granted → deny), obtain and verify the `CommandAuthorization`\nwith the exact `toolCallId`/`argsHash`/`commandHash`/`credentialScope` about to run\n(recomputed at the gate), and only then proceed. For an **uncovered** action: a\nnon-command tool passes through (default-allow) unless a `defaultDeny` scope matches; a\ncommand-bearing tool passes only if `commandDefaultAllow: true`, otherwise **denies**\n(default-deny, AC-38b). Any error, or an argv-canonicalization failure for a covered scope,\nis a denial.\n\n### 6.2 The three gates + genty seams (what each verifies)\n\n| Seam | Role | File | Enforces |\n| --- | --- | --- | --- |\n| **GATE 1** — tools-adapter `ToolDispatcher.beforeToolUse` | blocking (load-bearing) | `packages/adapters/tools/src/dispatch.ts` + `policy-verifier-wiring.ts` | Full covered/uncovered/authorization decision before `executor` runs; recomputes hashes; binds the executing `toolCallId`. |\n| **genty session** `definition.execute` | blocking (load-bearing) | `packages/genty/core/src/session.ts` (`policyToolGate`) | Same evaluator, in-process, using the attestation on `ToolExecutionContext`. |\n| **genty MCP dispatcher** `dispatcher.dispatch` | blocking (load-bearing) | `packages/genty/platform/.../orchestration/effects.ts` (`mcpPolicyGate`) | Same evaluator at the MCP routing seam. |\n| **GATE 2** — comm-adapter `preToolUse` | defense-in-depth | `packages/adapters/core/src/spawn-runtime-hooks.ts` | Hard-enforces **only** in `blocking` mode; advisory for non-blocking adapters. |\n| **GATE 3** — credential-injection backstop | defense-in-depth | `packages/adapters/core/src/spawn-invocation.ts` (+ `policy-spawn-gate.ts`, `spawn-runner.ts`) | Gates scoped-credential delivery channels (§6.3). |\n\n**Load-bearing vs defense-in-depth (AC-49).** GATE 1 and the genty session / MCP dispatcher\nseams are the **load-bearing, un-bypassable** blocking gates for **every** covered action —\ncredential-bearing or not. GATE 2 (advisory for non-blocking adapters) and GATE 3\n(credential-only) are defense-in-depth, never the sole line.\n\n**Load-bearing status accurately stated (per the design and code).** GATE 1's\n`PolicyVerifierHookBridge` primitive is production-wired via `loadPolicyVerifierBridge` /\n`composePolicyBridge`\n([`policy-verifier-wiring.ts`](../packages/adapters/tools/src/policy-verifier-wiring.ts)),\nwhich installs the bridge in front of the existing hooks bridge when the anchor is pinned.\nThe **load-bearing seams that must be present for every covered action** are the genty\nsession `policyToolGate` and the MCP dispatcher `mcpPolicyGate`\n([genty platform `policy-enforcement-wiring.ts`](../packages/genty/platform/src/harness/internal/createRun/orchestration/policy-enforcement-wiring.ts)),\nattached centrally via `withPolicyGate` / `resolveRunPolicyGates` so no tool-executing\nsession can omit them.\n\n### 6.3 GATE 3 — credential channels (AC-23a/AC-40/AC-50)\n\nGATE 3 (`Gate3Options` + `gateCredentialInjection` in\n[`spawn-invocation.ts`](../packages/adapters/core/src/spawn-invocation.ts)) mediates the\ncredential-delivery channels `spawn-invocation.ts` constructs: docker `-e` env and `-v`\nmounts, ssh `K=V`, k8s `env`/`--env`, and k8s secret/`--serviceaccount` references. Before a\n**scoped** credential is delivered, GATE 3 requires a valid `CommandAuthorization` whose\n`credentialScope` matches, recomputing `argsHash`/`commandHash` from the exact command being\nspawned. **No valid authorization → the channel is omitted** (env dropped, `-v` mount\nomitted, secret/serviceaccount ref stripped); if the policy marks the credential required,\nthe spawn is **denied** (`CredentialGateDenied`).\n\n- **The scope tag is trusted, out-of-agent (AC-40/AC-40a/AC-55).**\n  [`credential-identity.ts`](../packages/adapters/policy/src/credential-identity.ts) resolves\n  a credential's scope from `credential-scope-source.json`, keyed by the collision-resistant\n  `sha256(stableId)` identity (never the value prefix, env-var name, or agent alias). An\n  absent alias, an unknown identity, or an ambiguous identity (`>1` distinct credential)\n  **denies**. Multiple aliases of one physical credential canonicalize to one identity → one\n  scope (AC-55) via the alias map.\n- **Auto-activation from signed config.** The scoped-credential map (`scopedCredentials`) is\n  **deployment configuration** hashed into the signed manifest, not agent-writable input.\n  When the anchor is pinned and the signed source declares scoped credentials, GATE 3\n  **auto-activates** from config alone (`resolveSpawnGate3FromConfig` in\n  [`policy-spawn-gate.ts`](../packages/adapters/core/src/policy-spawn-gate.ts)) — no caller\n  `RunOptions.policyGate3` needed.\n\n### 6.4 Honest limitations / boundaries\n\n- **Substrate-delivered credentials are a bounded, warned non-goal (AC-50).** GATE 3 cannot\n  see credentials the process obtains without `spawn-invocation.ts` constructing the\n  delivery: cloud instance metadata (**IMDS / IRSA / workload-identity**), pre-existing files\n  on a mount the agent already controls, image-baked creds, and secret endpoints the process\n  calls itself. High-assurance scoped credentials **must** be delivered through a\n  GATE-3-mediated channel; substrate-delivered credentials are mitigated by substrate\n  controls (no broad instance role for agent pods, minimal images, egress policy), not by\n  this design.\n- **Passthrough proxy mode is a documented non-goal (AC-18).** No attestation is produced, so\n  actions requiring proxy attestation deny on that path.\n- **The remaining integration boundary is live evidence-collection wiring.** The\n  ALLOW-path production seam exists — `createAuthorizationStore`\n  ([`authorization-store.ts`](../packages/adapters/policy/src/authorization-store.ts)) plus\n  `registerRunAuthorizationResolver` — but populating it (issuing authorizations from\n  real proxy-sidecar / in-process attestations and signed breakpoint answers **at run time**)\n  is the orchestrator-side integration that closes the loop. Until an authorization store is\n  populated for a run, a covered action with no resolvable `CommandAuthorization` **denies**\n  (the correct fail-closed posture): with the anchor pinned, enforcement is on and denies\n  covered actions that lack proof.\n\n---\n\n## 7. Enabling / disabling enforcement\n\nEnforcement is toggled by whether the off-workspace anchor is pinned:\n\n- **Pinned (`POLICY_CONFIG_ROOT_FP` set) → enforcement ACTIVE, fail-closed.** The manifest is\n  verified; covered actions require a valid `CommandAuthorization`; any config error (missing\n  / edited / corrupt manifest or file, wrong signer, below-floor epoch, unreadable policy\n  docs) denies every covered action. If the policy adapter cannot even load while the anchor\n  is pinned, the run installs a **deny-all** gate (`denyAllGates`) — enforcement was requested\n  and must not be silently disabled.\n- **Not pinned → back-compat pass-through.** No gate is constructed; the SDK breakpoint gate\n  and the tool-layer gates return `enforcementActive: false` and existing runs are unaffected.\n  Opting in is a **deploy** operation (pinning the off-workspace root + floor), which an\n  in-workspace agent cannot perform.\n\nThe same anchor governs both the SDK signed-breakpoint gate and the tool-layer gates.\n\n---\n\n## 8. Troubleshooting\n\nEvery denial carries a `reason`. Common ones and their causes:\n\n| Deny reason (substring) | Cause / fix |\n| --- | --- |\n| `covered action has no CommandAuthorization` | The action is covered but no authorization was issued/resolved for the executing tool-call id. Populate the run's authorization store (evidence-collection wiring, §6.4), or provide the required evidence. |\n| `config manifest not trusted` / `hash mismatch` / `on-disk file not listed in manifest` | A config file was edited without re-signing the manifest, or the manifest signer is not the pinned config root. Re-run the manifest signing step (§4.6). |\n| `configEpoch below floor` / `configEpoch != current manifest epoch` | The config epoch is below `POLICY_CONFIG_MIN_EPOCH`, or a stale authorization from an older epoch. Advance the floor together with the new epoch (§4.6). |\n| `fingerprint not in trusted store` / `trust-root kind does not match required kind` | A signer is not registered as a trust root of the required kind (e.g. an engine key presented for a human step). Register the correct root, or fix the step's `trustedIdentities`. |\n| `required field not signed` / `payloadType mismatch` | The envelope omitted a required field from `signedFields`, or the wrong payload kind was presented. The producer must sign the full field set with the correct `payloadType`. |\n| `signer ... is not a trusted human root` (breakpoint) | A breakpoint answer verified against a key that is not a trusted `human` root (e.g. an agent-dropped key). Only manifest-verified human roots are honored. |\n| `disguised covered command` / `unresolvable ...` | A covered program was invoked via a shell `-c`, interpreter eval, `$()`/backtick, `$VAR`, `eval`, or non-allowlisted wrapper. Add a legitimate wrapper to the scope's `wrapperAllowlist`, or invoke the program directly. |\n| `uncovered command-bearing action, default-deny (AC-38b)` | A command-bearing tool is not covered by any action and `commandDefaultAllow` is false. Add a covering action or set `commandDefaultAllow: true` for the environment. |\n| `authorization expired` / `not yet valid` | The `CommandAuthorization` TTL lapsed (default 120s) between issuance and execution. Increase `authorizationTtlSeconds` if the gap is legitimate. |\n| `ambiguous identity (>1 distinct credential)` / `alias not in trusted alias map` | The credential→scope source cannot uniquely resolve the credential. Fix `credential-scope-source.json` (add the alias, disambiguate the identity). |\n\nCommon misconfigurations:\n\n- **Enforcement seems off.** `POLICY_CONFIG_ROOT_FP` is unset → back-compat pass-through.\n  Pin it (and the key material + floor) in the deploy artifact.\n- **Every covered action denies even with evidence.** The authorization store is not\n  populated at run time (§6.4), or `POLICY_ISSUER_ROOTS` does not include the issuer key that\n  signs authorizations.\n- **In-process attestation rejected for an aws-style action.** Expected: credential-touching\n  actions default to `requireProxyAttestation: true`. Route through the attesting proxy, or\n  explicitly set `requireProxyAttestation: false` (recorded as a lower-assurance opt-out).\n\n---\n\n## 9. Package surface\n\n`@a5c-ai/policy-adapter` public exports\n([`index.ts`](../packages/adapters/policy/src/index.ts)):\n\n| Export | Purpose |\n| --- | --- |\n| `verifyEnvelopeTrusted`, `verifyCommandAuthorization`, `verifyTrustChainTrusted` | Trusted-store verification (AC-35), gate CA check (AC-10), delegation chains. |\n| `REQUIRED_SIGNED_FIELDS`, `EXPECTED_PAYLOAD_TYPE` | Per-kind completeness + `payloadType` constants (AC-2/AC-51). |\n| `verifyConfigManifest` | Config-integrity manifest verification (AC-36/AC-46/AC-47). |\n| `bridgeProvenAnswer` | proven → human-approval bridge (AC-3/AC-43/AC-48). |\n| `canonicalizeArgs`, `canonicalizeArgv`, `argsHash`, `commandHash` | The one shared canonicalizer (AC-52). |\n| `matchArgv` | Canonicalized argv matcher (AC-38/AC-38a/AC-38c). |\n| `parsePolicyDocument`, `loadTrustStore` | Policy-doc + trust-store parse/validate (fail-closed). |\n| `evaluatePolicy` | Trust-chain evaluator (AC-9/AC-19a/AC-20/AC-41/AC-42). |\n| `issueCommandAuthorization`, `issueFromDecision` | Authorization issuance (grant-gated). |\n| `createAuthorizationStore` | Run-level ALLOW-path store + resolver (§6.4). |\n| `canonicalizeCredentialIdentity`, `resolveCredentialScope` | Credential→scope resolution (AC-40/AC-40a/AC-55). |\n| `EXEC_SEAM_REGISTRY` | Checked-in exec-seam registry (AC-56). |\n| `loadPolicyEnforcementGate`, `argvScopesOf` | Production gate loader + coverage helper (AC-49). |\n\nSubpath exports: `./trust` (`verify-envelope-trusted`), `./config-manifest`,\n`./proven-bridge`.\n\n---\n\n## 10. Acceptance-criteria traceability\n\nThe design's `AC-n` → milestone map is in\n[`docs/design/proof-based-policy-enforcement.md` §12](design/proof-based-policy-enforcement.md).\nMilestones: **A** trust-core, **B** policy-engine, **C** evidence-producers, **D**\ntool-layer-enforcement, **E** e2e-integration. Each source file's header comment names the\nACs it implements; tests live in\n[`packages/adapters/policy/src/__tests__/`](../packages/adapters/policy/src/__tests__) (unit\n+ threat-model + the `e2e-trust-chain.e2e.test.ts` end-to-end scenario).\n",
    "documents": []
  },
  "outgoingEdges": [],
  "incomingEdges": [
    {
      "from": "page:docs",
      "to": "page:docs-policy-enforcement",
      "kind": "contains_page"
    }
  ]
}

Shortcuts

Back to overview
Open graph tab