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

Available views

II.Record viewspp. 1 - 1
overviewarticlejsongraph
III.Related pagespp. 1 - 1
II.
Page reference

page:docs-design-proof-based-policy-enforcement

Reading · 65 min

Proof-Based Policy Enforcement — Design Specification reference

Status: Draft 3 (post second adversarial security review) · Date: 2026-07-03 · Owner: Security/Platform

Pagewiki/docs/design/proof-based-policy-enforcement.mdOutgoing · 0Incoming · 1

Proof-Based Policy Enforcement — Design Specification

Status: **Draft 3 (post second adversarial security review)** · Date: 2026-07-03 · Owner: Security/Platform Research input (frozen, read in full before implementing): `.a5c/processes/proof-policy-enforcement.research.md`

**Revision note (Draft 2).** An adversarial security review (score 62/100) found 5 viable

attacks against Draft 1. This revision closes every blocking issue with concrete, testable

acceptance criteria. Stable AC ids are preserved; ACs whose fix changed them are updated in

place; new criteria are added as **AC-34+**. The five closed issues and their governing ACs:

| # | Blocking issue (Draft 1) | Closed by |

|---|--------------------------|-----------|

| 1 | Model-decision cannot bind a specific tool call (attestation replayable to a different tool in the same turn) | **AC-34** (signed toolCalls[] with toolCallId+argsHash), AC-4/§4.1, AC-12/13/15/16, AC-30 |

| 2 | Trust-root key material unspecified; verifySignature trusts caller-supplied keys; cross-kind confusion | **AC-35** (trusted-store resolution + fingerprint binding + kind check), AC-5/6/7/26 |

| 3 | Trust-root / policy files not integrity-protected against the workspace-writable adversary | **AC-36/AC-37** (out-of-agent root-of-trust signs the config; bootstrap story), threat model, §1.2, AC-26/33 |

| 4 | "Must-be-signed" invariant is only a build-time lint | **AC-2/AC-8/AC-10** rewritten (runtime signedFields completeness assertion at the trust boundary) |

| 5 | Uncanonicalized regex command matching → alias bypass; global default-allow | **AC-38** (canonicalized argv matcher) + §7 schema + AC-23 (per-env opt-in default-allow) |

Non-blocking review improvements folded in: proxy attestation default for credential-touching

actions (AC-17/AC-39), trusted out-of-agent credential→scope source (AC-40), quorum distinct-holder

rule (AC-19/AC-41), argsHash recomputed at GATE 3 (AC-32/AC-23a), evidenceEnvelopeHashes covers

every required step (AC-9/AC-42), proven-bridge derived evidence still evaluates as human

(AC-3/AC-43), and a non-blocking-GATE-2 + passthrough-denial acceptance test (AC-44).

**Revision note (Draft 3).** A **second** adversarial security review (score 74/100) confirmed the

five Draft-1 attacks are closed but found **3 residual blocking attacks** against Draft 2. This

revision closes each precisely and completely, preserving every previously-closed AC. Stable AC ids

are preserved; ACs whose fix changed are updated in place; new criteria are added as **AC-46+**.

The three residual blocking issues and their governing ACs:

| # | Residual blocking issue (Draft 2) | Closed by |

|---|-----------------------------------|-----------|

| 6 | **Config rollback / downgrade.** AC-36/37 sign sha256(file bytes) with no version/counter, so an older validly-signed, more-permissive trust-roots + policy set (incl. an older revocation list — un-revoking stolen keys) can be swapped back in. | **AC-46** (signed config **manifest** with a monotonic configEpoch covering ALL config files together), **AC-47** (off-workspace minimum-epoch floor pinned beside POLICY_CONFIG_ROOT_FP; every gate rejects configEpoch < floor), AC-27/AC-36/AC-37/AC-45 revised |

| 7 | **Proven-bridge unsigned approved bit.** proven/verify.ts:60 rebuilds the signing payload from attacker-supplied provenAnswer.signedFields and never requires approved to be signed; the bridge then trusts payload.approved and mints "human" evidence. | **AC-48** (bridge asserts legacy signedFields ⊇ {breakpointId, approved, responderId} — the AC-2 completeness rule applied to the legacy proven answer — BEFORE deriving evidence; else deny), AC-3/AC-43 revised |

| 8 | **GATE 3 backstop scope.** GATE 3 only mediates scoped-credential ENV-VAR injection, so (i) policy-covered non-credential actions have no backstop when GATE 2 is advisory and GATE 1 is bypassed, and (ii) file/mount/IMDS-delivered credentials evade the env gate. | **AC-49** (GATE 1 + genty dispatcher/session seam declared LOAD-BEARING, un-bypassable for ALL covered actions; execution-path enumeration acceptance test), **AC-50** (GATE 3 extended to non-env credential channels spawn-invocation.ts controls — docker -v, k8s secrets — with an explicit bounded non-goal + warning for channels it cannot mediate, e.g. IMDS), AC-23a/AC-33/AC-40/AC-44 revised |

Additional review items folded in cheaply (Draft 3): **domain separation** — a bound

payloadType constant is added to every payload and to signedFields so a signature is

non-transferable across evidence kinds (**AC-51**); **one canonical argv/args serialization** shared

byte-for-byte by proxy and every gate, with a conformance test (**AC-52**, closes argsHash

divergence); **argv wrapper allowlist** — the wrapper handling of AC-38 becomes an allowlist of

recognized programs per covered scope rather than a denylist (**AC-38c**); **heterogeneous quorum

composition** — how quorum composes with typed steps[] so "human+opus AND 2-human-quorum" is one

chain (**AC-41a**); and **collision-resistant credential identity** for AC-40 scope keying with

deny-on-ambiguous (**AC-40a**).

0. Summary

Add a cryptographic policy-enforcement layer to the agent-orchestration monorepo so that a specific command, run with a specific tool and specific credentials, executes **only** when a declarative policy's required *trust chain of signed evidence* is satisfied. Evidence includes signed human breakpoint approvals (PermissionEvidence), signed model-decision attestations (ModelDecision, e.g. "opus decided to call **this specific** tool call with **these** args"), and delegation links. When a policy is satisfied, a short-lived **CommandAuthorization** envelope is issued binding the exact tool + tool-call id + command hash + args hash + credential scope + evidence fingerprints + evidence content hashes + expiry. The tool layer verifies that authorization at the point of execution and **fails closed** for policy-covered actions. Fallbacks are forbidden: any error during verification is a denial.

The design **reuses** genty's SignedEnvelope<T> + JSON canonical form (packages/genty/core/src/trust/) as the universal proof format, **extends** the two existing declarative policy engines rather than duplicating them, and adds one new workspace package, @a5c-ai/policy-adapter (packages/adapters/policy), that both genty and adapters consume without a circular dependency.

**Three trust boundaries the reused genty primitives do not enforce on their own — this design adds them in the policy adapter:**

1. **Tool-call binding.** The reused ModelResponsePayload has no field naming the tool call it authorized, so a valid attestation is replayable to a *different* tool call in the same turn. This design extends the model-decision payload (the one producer where the no-new-schema rule is relaxed) with a signed toolCalls[] array — each entry { toolCallId, name, argsHash } — and binds CommandAuthorization.toolCallId == attestation.toolCalls[i].toolCallId with matching argsHash (AC-34). 2. **Trusted-store key resolution.** Genty's verifySignature verifies a signature against a **caller-supplied** public key and never checks sha256(publicKey) == envelope.publicKeyFingerprint or that the key's declared *kind* matches the policy step. The policy adapter wraps it with a verifier that resolves key material **only** from the trusted store, selects by (requiredKind, allowedFingerprints), binds the fingerprint, and rejects cross-kind (AC-35). 3. **Config integrity + rollback resistance.** Trust-roots and policy files live on the workspace, which the in-scope adversary (compromised/workspace-writable agent) can edit. This design requires them to be signed by an **out-of-agent root of trust**, verified before any root or policy is honored (AC-36/AC-37). Draft 3 adds that the config root signs **one manifest** covering **all** config files together, carrying a **monotonic configEpoch** integer; every gate rejects any config whose epoch is below an off-workspace pinned floor (POLICY_CONFIG_MIN_EPOCH), so an older validly-signed, more-permissive config set — including an older revocation list — cannot be rolled back in (AC-46/AC-47).

**Two cross-cutting hardening rules Draft 3 makes explicit, because the reused primitives do not provide them:**

4. **Domain separation.** Genty's canonicalize binds signedFields but no payload-type tag, so a signature over one payload kind is byte-identical to (and thus transferable to) a structurally compatible payload of a *different* kind. Every payload in this design carries a bound payloadType constant that MUST be in signedFields; the verifier binds the expected payloadType per kind and denies on mismatch (AC-51).

5. **One canonical serialization for argv and args.** Proxy-side and every gate-side argsHash / commandHash MUST be produced by a **single, total, loss-preserving** canonicalizer exported from the policy adapter, so hashes are byte-identical across producers and consumers; a conformance test pins this (AC-52).

Every requirement below has a stable acceptance-criterion ID (AC-n) and is mapped to exactly one milestone (A–E) in §12.

---

1. Goals, non-goals, and milestones

1.1 Goals

  • A single canonical proof envelope for every producer and consumer.
  • A declarative, per-action policy language expressing *flexible* trust chains (multiple shapes).
  • Non-spoofable evidence producers for human approvals and model decisions.
  • Enforcement at every tool-execution gate, fail-closed for covered actions.

1.2 Non-goals (scope guard) — **AC-24**

The following are explicitly **out of scope** for this design and must not be built under it:

1. **Journal hash-chaining / audit hardening** (storage/journal.ts prevChecksum). Supporting, tracked separately; the research doc §Gap-5 lists it as non-core. 2. **General secret-management / vault integration.** Credential *scoping* is modeled; a secrets backend is not. 3. **Replacing the proxy's bearer-token auth** (server.ts isAuthorized, 148-175). It stays for transport auth; it is not reused as proof (research §6 caveat). 4. **A network trust-root distribution service / PKI CA.** Trust roots are file-based config (§10). No online revocation service (OCSP-style) is built; revocation is a local list. **In-scope correction (review issue 3):** although distribution stays file-based, the trust-roots file and every policy document **are** integrity-protected by an out-of-agent root-of-trust signature (AC-36/AC-37). "File-based config" no longer means "trusted because it is on disk." 5. **Policy authorship UI / TUI.** Policies are YAML/JSON files edited by hand in this iteration. Signing them (AC-37) is a mechanical policy-adapter sign-config CLI step, not a UI. 6. **Rewriting proven's canonical form immediately.** proven keeps its text canonical form for backward-compatible verification; new evidence uses the JSON form with a bridge (§4.3). **Draft-3 note:** the load-bearing defense against a proven answer with a stripped signedFields is the bridge-side AC-48 completeness assertion at the trust boundary; hardening proven/verify.ts:60 itself to reject an under-specified signedFields is desirable defense-in-depth but is a follow-up, not required for the guarantee. 7. **Signing the passthrough-proxy path in this iteration** (documented gap, §6.5). 8. **Gating substrate-delivered credentials** (cloud instance metadata / IMDS / IRSA / workload-identity, credentials in a container image, pre-existing files on a mount the agent already controls, secret endpoints the process calls itself). spawn-invocation.ts does not construct these deliveries, so GATE 3 cannot see or gate them (AC-50). This is a **bounded, warned** non-goal: the evaluator emits an audit-logged warning and refuses to claim a GATE-3 backstop for any scoped action whose credential would arrive by such a channel; mitigation is a substrate control (no broad instance role for agent pods, minimal images, egress policy).

1.3 Milestones

IDMilestoneScope
**A**trust-coreUnified envelope (with bound payloadType), evidence taxonomy, identity/key model, CommandAuthorization type, trusted-store verifier wrapper, trust-roots config + config-integrity root-of-trust + monotonic-epoch config manifest + off-workspace epoch floor + key ops.
**B**policy-enginePolicy document schema (incl. canonicalized argv matcher + config-signature verification), evaluator, @a5c-ai/policy-adapter package, authorization issuance.
**C**evidence-producersProxy model-attestation, in-process genty attestation, enforced signed breakpoint approvals.
**D**tool-layer-enforcementVerification at adapters GATE 1/2/3 + genty dispatcher/session.
**E**e2e-integrationEnd-to-end trust chain (human approval + opus attestation → aws command), default-deny scopes, threat-case tests.

---

2. Architecture overview

Code
                        ┌─────────────────────────────────────────────┐
   evidence producers   │            @a5c-ai/policy-adapter            │  consumers
                        │        (packages/adapters/policy)            │
 human approval ──────► │                                             │
  (PermissionEvidence)  │  ┌────────────┐   ┌──────────────────────┐  │
                        │  │  Policy    │   │  Authorization        │  │ ◄── GATE 1 tools/dispatch.ts
 model decision ──────► │  │  Document  │──►│  Issuer               │  │ ◄── GATE 2 core/spawn-runtime-hooks.ts
 (ModelResponse attest) │  │  (schema)  │   │  (CommandAuthorization│  │ ◄── GATE 3 core/spawn-invocation.ts
   ▲ proxy (authoritative)│  └────────────┘   │   SignedEnvelope)     │  │ ◄── genty session.ts / MCP dispatcher
   ▲ genty (in-process)  │  ┌────────────┐   └──────────────────────┘  │
 delegation links ────► │  │ Trust      │   ┌──────────────────────┐  │
                        │  │ Roots      │   │  Authorization        │  │
                        │  │ (config)   │   │  Verifier             │  │
                        │  └────────────┘   └──────────────────────┘  │
                        └──────────────────────────────────────────────┘
                              uses @a5c-ai/genty-core/trust primitives

@a5c-ai/policy-adapter depends **only** on @a5c-ai/genty-core (for the trust primitives) and Node built-ins. @a5c-ai/genty-core already exists as a leaf that adapters can depend on, so placing the package under packages/adapters/policy lets @a5c-ai/tools-adapter, @a5c-ai/comm-adapter, @a5c-ai/tasks-adapter, and @a5c-ai/transport-adapter consume it, while @a5c-ai/genty-platform (which already depends on genty-core and can depend on adapters) consumes it too — with no cycle (§8).

---

3. Unified proof envelope (Milestone A)

3.1 Adopt `SignedEnvelope<T>` + JSON canonical form

**AC-1.** The universal proof format is genty's SignedEnvelope<T> (packages/genty/core/src/trust/types.ts:1-8): { payload, signature, publicKeyFingerprint, signedAt, signedFields, algorithm: 'Ed25519' }. All new evidence, CommandAuthorization, and migrated proven answers use it. Canonical serialization is genty's canonicalize (signing.ts:65-68): JSON.stringify({ _meta: deepSortKeys(meta), _payload: deepSortKeys(extractFields(payload, signedFields)) }). **No new envelope type or canonicalization routine may be introduced** — the policy adapter imports signPayload / verifySignature from @a5c-ai/genty-core (re-exported at trust/index.ts:1). Rationale: proven's text canonical form (proven/sign.ts:23-30, field=value\n) cannot represent the nested structures (evidence fingerprint arrays, args objects) this design requires; genty's form deep-sorts keys and supports arbitrary JSON, and the research §Gap-1 designates it authoritative.

**AC-2 (revised — runtime, not lint).** verifySignature (signing.ts:40-56) is the underlying signature-check primitive, but it is **never called directly** by a consumer; it is only reached through the policy adapter's verifyEnvelopeTrusted wrapper (AC-35). Verification recomputes the canonical form from envelope.payload and envelope.signedFields; a field present on payload but absent from signedFields is **not** covered by the signature.

The Draft-1 "documented invariant enforced by a lint/test" is **insufficient** (review issue 4): a compromised producer can emit an envelope that omits a security-critical field from signedFields, and a build-time lint never runs at the trust boundary. Therefore the **runtime** verifier MUST, before honoring any envelope, assert signedFields completeness for that envelope's declared kind and **DENY** on any missing field:

REQUIRED_SIGNED_FIELDS[kind] ⊆ new Set(envelope.signedFields) **and** every such field is actually present on envelope.payload.

required field is a verification failure (a denial), identical in effect to a bad signature.

  • For every evidence/authorization kind, the adapter defines a REQUIRED_SIGNED_FIELDS[kind] set.
  • verifyEnvelopeTrusted(envelope, kind) fails closed unless
  • This runs at each gate on the actual envelope being consumed — not once at build time. A missing

Required-field sets (each MUST appear in signedFields): see AC-8 (authorization) and AC-10 step 8 (per-evidence). The build-time lint is retained only as defense-in-depth for repo-authored payloads; it is **not** the enforcement mechanism.

**AC-51 (domain separation — bound payloadType).** Genty's canonicalize (signing.ts:65-68) binds signedFields and _meta but **no payload-type tag**, so a signature over payload kind X is byte-identical to a signature over a structurally compatible payload of kind Y — an engine-signed ModelDecisionPayload whose field shape overlaps another engine-signed type could be presented for the wrong step. To make signatures **non-transferable across payload types**, every payload type in this design carries a constant discriminant field payloadType: 'human-approval' | 'model-decision' | 'delegation' | 'command-authorization' | 'config-manifest', and payloadType MUST be a member of REQUIRED_SIGNED_FIELDS[kind] for **every** kind (so it is always inside the signature, per AC-2). verifyEnvelopeTrusted(envelope, kind, ...) takes the expected payloadType for that kind and denies unless envelope.payload.payloadType equals it **and** payloadType ∈ signedFields. This is a bound constant, not caller-supplied trust (the wrapper kind claim is checked against the trust root's kind per AC-5/AC-35 as before); payloadType closes the *cross-payload-type* transfer that the kind/trust-root check alone does not (two different payload types can both be engine-signed).

**AC-52 (one total, loss-preserving canonical argv/args serialization — closes argsHash divergence).** argsHash and commandHash are computed in at least three places — the proxy producer (AC-12/13), GATE 1 (§9.1), and GATE 3 (§9.3, the last recomputation before exec) — and any byte-level divergence between producer and consumer silently breaks the binding (a mismatch denies a legitimate call, or, worse, a lossy normalization lets two different arg objects collide). This design therefore mandates **one** canonicalizer, canonicalizeArgs(value): string and canonicalizeArgv(command): string[], exported from @a5c-ai/policy-adapter and imported by **every** producer and gate (the proxy imports it too — it is a leaf util with no adapter-runtime dependency). It MUST be:

and has an explicit deny-path for input it cannot represent (e.g. non-finite numbers), never a silent coercion.

(it builds on genty deepSortKeys for object-key ordering but preserves array order, string bytes, and value types; it does not lowercase, trim, or unicode-fold). argsHash = sha256(canonicalizeArgs(args)).

function. **AC-52a (conformance test):** an acceptance test feeds a fixed corpus of args/argv fixtures through the proxy path and each gate path and asserts the resulting argsHash/commandHash are byte-identical, and that two distinct fixtures never collide. A hash produced by any code path that does **not** route through canonicalizeArgs/canonicalizeArgv is a design violation caught by this test.

  • **Total** — defined for every JSON-representable args value; it never throws on well-formed input
  • **Loss-preserving** — it does not drop, reorder-collide, or fold distinct inputs to the same bytes
  • **Shared byte-for-byte** — the proxy-side and every gate-side hash are produced by the identical

3.2 Migration / bridge for proven breakpoint answers

**AC-3 (revised — Draft 3).** A bridge in @a5c-ai/policy-adapter converts a legacy ProvenBreakpointAnswer (text-canonical, proven/sign.ts) into a SignedEnvelope<PermissionEvidencePayload> **without re-signing the human's intent as the adapter's own**: the bridge verifies the legacy answer via proven verifyAnswer (proven/verify.ts:20-72) **against a fingerprint that is a human trust root** (AC-35), **passes the AC-48 legacy-completeness assertion**, and only on success emits a *derived* PermissionEvidence envelope whose payload records the original human publicKeyFingerprint, breakpointId, and approved.

**AC-48 (proven-bridge legacy signedFields completeness — closes residual issue 7).** verifyAnswer (proven/verify.ts:60) rebuilds the signing payload from the **attacker-supplied** provenAnswer.signedFields (buildSigningPayload(provenAnswer, provenAnswer.signedFields)) and imposes **no** requirement that approved — or any security-critical field — actually appears in the signed set. An attacker with any single validly-signed proven answer from a human key (e.g. a signed *rejection*, or an answer signed over only {id, text}) can present a forged ProvenBreakpointAnswer that sets approved: true, breakpointId: <target> as **unsigned** payload fields while listing a signedFields that omits them; verifyAnswer returns valid: true because the signature covers only the fields the attacker chose to include, and the Draft-2 bridge then reads provenAnswer.approved and mints "human approved" evidence. This is the same class of hole AC-2 closed for the new-form envelopes, but it was **not** applied to the legacy proven answer inside the bridge.

The bridge MUST, **before deriving any evidence**, apply the AC-2 completeness rule to the legacy answer and fail closed (deny, no evidence emitted) unless **all** hold:

and the identity/target-binding fields were **actually within the human-signed set**. (The canonical proven signer signs all three today, proven/sign.ts:9-17; a legitimate answer therefore passes, a stripped-signedFields forgery does not.)

is treated as missing → deny), mirroring AC-2's "present on payload" clause.

approved === true** (a signed rejection never yields an approval envelope).

  • { breakpointId, approved, responderId } ⊆ new Set(provenAnswer.signedFields) — i.e. approved
  • Each of those fields is **present** on provenAnswer (a signedFields entry naming an absent field
  • Only then does the bridge trust provenAnswer.approved; and it derives evidence **only when

Any exception in this check is a denial. This assertion runs at the bridge (the trust boundary), not as a proven-side lint, so a compromised producer emitting a stripped-signedFields answer is rejected at consumption. (Hardening proven/verify.ts itself to reject an under-specified signedFields is desirable defense-in-depth and noted in §1.2 non-goal 6's follow-up, but the **load-bearing** check is AC-48 in the bridge, because the bridge is where the approved bit is promoted to human evidence.)

**AC-43 (derived evidence still evaluates as kind:'human').** The derived envelope is *co-signed* by the adapter's bridging identity for storage integrity, but the policy evaluator MUST NOT treat the bridging (engine) signature as the trust anchor for a human-approval step. The bridge is honored **only if** the recorded originalHumanFingerprint is a currently-valid, non-revoked human trust root, the original proven verification passed, **and the AC-48 legacy-completeness assertion passed**; the evaluator resolves the human-approval step against originalHumanFingerprint (kind human), not against the adapter issuer key. Otherwise a compromised adapter could launder any approval into a "human" one by re-signing it. The derived-payload MUST carry { payloadType: 'human-approval', originalHumanFingerprint, breakpointId, approved, provenVerified: true } in signedFields (AC-51 binds payloadType).

During the transition, breakpoint producers MAY emit **both** the legacy .proven.json and a new PermissionEvidence envelope (dual-write); the research §Gap-1 permits "emit both during transition." The proven text form is not deleted in this iteration (§1.2 non-goal 6).

**AC-4.** New breakpoint approvals (post-milestone-C) are signed directly as SignedEnvelope<PermissionEvidencePayload> using signPermissionEvidence (trust/tool-signing.ts:36-41); the git-native backend's auto-sign path (backends/git-native.ts auto-sign on answer) is extended to write the envelope alongside .answer.json, keyed by breakpointId. The bridge (AC-3) becomes a no-op for these.

---

4. Evidence taxonomy, identity & key model (Milestone A)

4.1 Evidence types

Two of three evidence kinds reuse existing genty payload types unchanged. The **model-decision** kind is the single, deliberate exception to the no-new-schema rule (review issue 1): its reused ModelResponsePayload has no field that names the tool call it authorized, so it cannot bind "opus decided to call **this** tool call." §4.1a extends it with a signed toolCalls[] array.

EvidencePayload typeSourceProducer keyTrust-root kind
**human-approval**PermissionEvidencePayload (tool-signing.ts:13-20), reused with a bound payloadType:'human-approval' added (AC-51): {payloadType, action, scope, approvedBy, approvedAt, expiresAt?, conditions?}breakpoint answerhuman responder key (proven .keys/private)human
**model-decision****ModelDecisionPayload** — ModelResponsePayload (model-signing.ts:4-12) **extended** with payloadType:'model-decision' + signed toolCalls[] (§4.1a, AC-34/AC-51)transport proxy (authoritative) **or** genty session (in-process)proxy engine key **or** genty adapter keyengine
**delegation**DelegationChainLink (types.ts:29-33) carried in AgentRequestPayload.delegationChain (agent-signing.ts:12), with a bound payloadType:'delegation' (AC-51)agentagent identity keyagent

4.1a Model-decision payload extension — the tool-call binding (**AC-34**, review issue 1)

**Problem.** Draft 1 reused ModelResponsePayload verbatim. That payload carries modelId, inputMessagesHash, and outputContent, but **no toolCallId and no per-call argsHash**. A turn can emit many tool calls (session loop session.ts:1235 iterates result.toolCalls, each a NormalizedToolCall with a distinct id). A single valid attestation for the turn therefore satisfies a policy step for **any** tool call in that turn — an attacker replays the "opus decided" proof onto a *different*, unapproved call with different args. This is the model-decision-cannot-bind attack.

**AC-34.** The model-decision producer (proxy at §6.1/6.2, in-process genty at §6.2) signs a ModelDecisionPayload that **extends** ModelResponsePayload (this is the one relaxed no-new-schema exception — flagged; the new type lives in @a5c-ai/genty-core/trust next to model-signing.ts so both producers share it) with:

ts
interface SignedToolCall {
  toolCallId: string;   // the provider/harness tool-call id (NormalizedToolCall.id)
  name: string;         // tool name the model chose
  argsHash: string;     // sha256 of canonical JSON of that call's arguments (deepSortKeys)
}
interface ModelDecisionPayload extends ModelResponsePayload {
  payloadType: 'model-decision'; // AC-51 domain-separation constant, MUST be in signedFields
  toolCalls: SignedToolCall[];   // EVERY tool call the model emitted this turn, each bound
}

payloadType, toolCalls (and its toolCallId/name/argsHash sub-fields) MUST be in signedFields (enforced at runtime by AC-2 / AC-10 step 8 and AC-51). The argsHash is computed by the shared canonicalizeArgs (AC-52) — the **same** helper as CommandAuthorization.argsHash (AC-8) — so proxy-side and gate-side hashes are byte-identical.

**AC-34a (binding at issuance and verification).** A model-decision step is satisfied for a given executing tool call **iff** the attestation contains a SignedToolCall whose toolCallId equals the executing tool-call id **and** whose argsHash equals the sha256 of the args about to run. CommandAuthorization records that toolCallId (AC-8), and every gate asserts authorization.toolCallId == executing toolCallId (AC-10 step 3a). An attestation with no matching toolCallId, or a matching id with a mismatched argsHash, is a **denial** — so the same turn's attestation cannot be replayed to a sibling call. This closes AC-30's replay-within-turn variant.

**AC-5 (revised).** The policy adapter exposes an Evidence discriminated union { kind: 'human-approval' | 'model-decision' | 'delegation'; envelope: SignedEnvelope<...> } that wraps these three payloads and nothing else in v1. Adding a new evidence kind is a typed, reviewable change (closed set), not an open string. The kind on the Evidence wrapper is a *claim*, not a trust decision: it selects which requiredKind the verifier binds against (AC-35), and the verifier rejects if the resolved trust root's kind disagrees. A caller cannot upgrade an engine-signed envelope to human-approval by relabeling the wrapper.

4.2 Identity & key model — who holds which key

**AC-6 (revised — trust root carries key material).** Each producer has a distinct key and a declared trust-root **kind**. A TrustRoot record is:

ts
interface TrustRoot {
  fingerprint: string;                 // sha256 of the SPKI/DER public key
  kind: 'human' | 'engine' | 'agent' | 'tool' | 'config';  // 'config' = out-of-agent config root (AC-36)
  publicKey: string;                   // REQUIRED: the SPKI public key material (PEM or DER-base64)...
  publicKeyPath?: string;              // ...OR a repo-relative path to it (exactly one of the two)
  label: string;
  expiresAt?: string;
  revoked?: boolean;
}

Draft 1's trust-roots.json carried **no key material** and no rule binding a requiredKind to a specific key, so verification depended on a caller-supplied public key — the review's cross-kind confusion (an engine key satisfying a human step) and caller-supplied-key attacks. **AC-6 now requires every root to carry its public key** (inline publicKey or publicKeyPath, exactly one), and the verifier (AC-35) resolves material **only** from this store.

Fingerprints are SHA-256 of the SPKI/DER public key, exactly as both existing systems compute them (genty signing.ts:9-11; proven keys.ts:18) — the two are interchangeable, so proven-generated human keys are valid human trust roots without re-fingerprinting. The verifier still recomputes sha256(resolvedPublicKey) and rejects if it disagrees with the stored fingerprint (AC-35 c), so a mismatched or swapped key file is caught.

.breakpoints/.keys/private/<fp>.key.json (gitignored) with the public half git-tracked under trusted/. Registered as kind: 'human'.

Registered as kind: 'engine'. This is the authoritative model-decision producer.

path. Also kind: 'engine' but a *different fingerprint*; policies MAY require the proxy fingerprint specifically (§6.4, AC-15).

'engine'`, held by whichever process runs the policy adapter (typically the orchestrator).

  • **Human keys**: generated + rotated by proven (proven/keys.ts:9-37, 122-148), stored at
  • **Engine (proxy) key**: held **outside the agent process** by the transport proxy (§6).
  • **Engine (in-process genty) key**: the genty adapter identity key, used only on the non-proxied
  • **Agent keys**: createAgentIdentity (trust/identity.ts:4-11); kind: 'agent'.
  • **Policy-adapter issuer key**: signs CommandAuthorization and bridged evidence. `kind:

**AC-7 (revised).** Trust roots are configured in a single, integrity-protected file (§10, AC-36) whose entries map fingerprints to kinds **and carry key material** (AC-6). Verification uses trusted public key material **only** from this config (and the proven trusted/ directory, re-expressed as human roots); a signature from any fingerprint **not** present as a trust root of the *required* kind is a verification failure (no implicit trust). The single verification entry point is AC-35's verifyEnvelopeTrusted — no consumer calls genty verifySignature / verifyTrustChain directly.

**AC-35 (trusted-store verifier wrapper — the core key-resolution rule; review issue 2).** The policy adapter provides a thin wrapper over genty verifySignature / verifyTrustChain (**flagged as new code in @a5c-ai/policy-adapter**, because genty's primitives verify against a caller-supplied key and cannot be safely called directly at a trust boundary):

ts
verifyEnvelopeTrusted(envelope, requiredKind, allowedFingerprints?): TrustedVerification

It MUST, in order, and fail closed (deny) at the first failure:

envelope.publicKeyFingerprint from the (integrity-verified, AC-36) trust-roots config / proven trusted/. If absent → deny. The envelope's own embedded key, if any, is **ignored**.

requiredKind; if the policy step supplied allowedFingerprints (or roles that resolve to fingerprints), the resolved fingerprint MUST be in that set. Otherwise → deny.

envelope.publicKeyFingerprint. This is the check genty verifySignature omits; without it a caller can present material that does not match the claimed fingerprint. Mismatch → deny.

≠ the step's required kind (e.g. an engine root presented for a human-approval step) → deny. This closes "engine key satisfies a human step."

AC-51) → deny on any missing required field, or if payload.payloadType is absent from signedFields or does not equal the expected constant for requiredKind.

the envelope's signedAt (AC-27) → deny otherwise.

for delegation chains, with each link's public key resolved the same way, never taken from the link). Any thrown exception anywhere in (a)–(g) is a **denial**, never a pass.

  • **(a) Resolve key material only from the trusted store.** Load the TrustRoot for
  • **(b) Select by (requiredKind, allowedFingerprints).** The resolved root's kind MUST equal
  • **(c) Bind fingerprint to material.** Compute sha256(resolvedPublicKey) and require it to equal
  • **(d) Reject cross-kind.** Redundant with (b) but stated explicitly: if the resolved root's kind
  • **(e) Enforce signedFields completeness + bound payloadType** for the envelope's kind (AC-2,
  • **(f) Check root validity** — not revoked, and (for key-expiry-bearing roots) not expired at
  • **(g) Only now** call genty verifySignature(resolvedPublicKey, envelope) (or verifyTrustChain

---

5. `CommandAuthorization` envelope (Milestone A / B)

**AC-8.** CommandAuthorizationPayload (new type in @a5c-ai/policy-adapter, signed as SignedEnvelope<CommandAuthorizationPayload> by the issuer key) has exactly these fields, all of which MUST be in signedFields:

ts
interface CommandAuthorizationPayload {
  payloadType: 'command-authorization'; // AC-51 domain-separation constant, MUST be in signedFields
  policyId: string;            // which policy document granted this
  policyDocHash: string;       // sha256 of the integrity-verified policy doc (AC-36) that granted this
  configEpoch: number;         // AC-46: monotonic config epoch under which this authorization was issued
  matchedChainId: string;      // the specific chain that was satisfied (§7)
  toolName: string;            // exact tool identity (e.g. "Bash", MCP tool name)
  toolCallId: string;          // REQUIRED: the exact tool-call id this authorization is bound to (AC-34a)
  commandHash: string;         // sha256 of the canonicalized argv (AC-38), empty-string sentinel if N/A
  argsHash: string;            // sha256 of canonical JSON of the tool input/args (deepSortKeys)
  credentialScope: string;     // opaque scope label the creds are bound to (e.g. "aws:prod:s3-ro")
  evidenceFingerprints: string[]; // fingerprints of every evidence envelope that satisfied the chain
  evidenceEnvelopeHashes: string[]; // sha256 of each satisfying evidence envelope (binds identity AND content)
  evidenceStepBindings: { stepIndex: number; requiredKind: string; envelopeHash: string }[]; // one per REQUIRED step (AC-42)
  runId?: string;
  sessionId?: string;
  issuedAt: string;            // ISO
  expiresAt: string;           // ISO, short-lived (default 120s, per-policy override)
}

**All of the above fields MUST appear in signedFields** (the runtime completeness assertion of AC-2 enforces this — REQUIRED_SIGNED_FIELDS['command-authorization'] is the full field set above). An authorization missing any field from signedFields is denied at every gate.

commandHash/argsHash are computed with the **single shared** canonicalizeArgv/canonicalizeArgs helper (AC-52), which builds on genty's deepSortKeys ordering so hashing is byte-identical across the proxy producer and every gate. toolCallId is mandatory (Draft 1 made it optional "when known") because tool-call binding is now the load-bearing defense against replay-within-turn (AC-34). payloadType (AC-51) and configEpoch (AC-46) are part of the authorization's signed field set.

**Issuance rules — AC-9 (revised).** The issuer produces an authorization **iff** the policy engine (§7) returns granted for the requested {toolName, canonicalArgv, args, credentialScope, toolCallId} context; it binds evidenceFingerprints + evidenceEnvelopeHashes to the *specific* evidence envelopes consumed (not the fingerprints alone — content hash prevents swapping a different envelope from the same signer). expiresAt = issuedAt + policy.authorizationTtl (default 120s).

**AC-42 (evidence coverage of every required step).** evidenceStepBindings MUST contain exactly one entry per **required** step of the matched chain (including every step of a satisfied quorum), each pinning {stepIndex, requiredKind, envelopeHash}. The issuer MUST refuse to issue (and the evaluator MUST NOT report granted) if any required step lacks a bound, verified evidence envelope. evidenceEnvelopeHashes is the multiset of those envelopeHash values — so it covers **every** required step, not merely "the evidence that happened to be present." A gate later re-verifies each binding (AC-10 step 7), guaranteeing no required step was silently skipped at issue time. matchedChainId and policyDocHash are recorded so a gate can confirm the authorization was issued under the same policy document it is now enforcing.

**Verification rules — AC-10 (revised).** A gate accepts an authorization **iff all** hold, else it denies: 1. verifyEnvelopeTrusted(authorization, requiredKind: 'engine', allowedFingerprints: issuerRoots) passes (AC-35) — this subsumes signature check, trusted-store key resolution, fingerprint binding, cross-kind rejection, and signedFields completeness for the authorization. 2. now < expiresAt (not expired) and now >= issuedAt. 3. toolName equals the tool being executed. 3a. toolCallId equals the id of the tool call about to execute (AC-34a binding). 4. commandHash equals sha256 of the **canonicalized argv** (AC-38) of the actual command about to run — recomputed at this gate, not carried from an earlier gate (empty-sentinel tolerated only when the policy for this action declares the tool non-command-bearing). 5. argsHash equals sha256 of the actual args about to run, **recomputed at this gate** (TOCTOU binding, §11; at GATE 3 this is the last recomputation before exec, AC-23a). 6. credentialScope equals the scope of the credentials about to be injected (GATE 3, §9.3), where that scope is supplied by the trusted out-of-agent source (AC-40), not by the agent. 7. Every evidenceStepBindings[i] re-verifies: the referenced evidence envelope hashes to envelopeHash, and verifyEnvelopeTrusted(evidenceEnvelope, requiredKind = binding.requiredKind, allowedFingerprints = step.trustedIdentities) passes against a currently-valid, non-revoked trust root (AC-35). For a model-decision step, the envelope MUST additionally contain a SignedToolCall matching this call's toolCallId + argsHash (AC-34a). 8. **Per-evidence signedFields completeness** (AC-2) **and bound payloadType** (AC-51): for each evidence kind, its REQUIRED_SIGNED_FIELDS set is present in that envelope's signedFields and payload.payloadType equals the expected constant — human-approval: {payloadType:'human-approval', action, scope, approvedBy, approvedAt, expiresAt?}; model-decision: {payloadType:'model-decision', modelId, provider, inputMessagesHash, toolCalls} (incl. each toolCalls[].toolCallId/name/argsHash); delegation: {payloadType:'delegation', delegatorFingerprint, delegatorSignature, delegatedAt}. A missing field or wrong payloadType → deny. 9. policyDocHash equals the sha256 of the integrity-verified policy document (AC-36) governing this action at the gate, and matchedChainId names a chain that still exists in it. 10. **configEpoch floor** (AC-46/AC-47): authorization.configEpoch equals the configEpoch of the currently-honored config manifest at this gate **and** authorization.configEpoch >= the off-workspace pinned POLICY_CONFIG_MIN_EPOCH floor. A stale authorization issued under a rolled-back or below-floor epoch → deny.

Any exception thrown during steps 1–10 is a **denial**, never a pass (research §Constraints; CLAUDE.md "fallbacks are evil").

---

6. Model-attestation producer strategy (Milestone C)

Two producers emit the **same** ModelDecision evidence type (AC-34, ModelResponsePayload extended with signed toolCalls[]); both register as kind:'engine' trust roots with distinct fingerprints. Both MUST populate toolCalls[] — an attestation with an empty/absent toolCalls cannot satisfy any model-decision step for a tool call (AC-34a).

6.1 Authoritative: transport proxy (`@a5c-ai/transport-adapter`)

**AC-11.** The proxy signs ModelResponse attestations at the wire seam, using a key held by the proxy process (outside the agent — the agent cannot forge what model answered). ProxyConfig (transport/src/types.ts:15-24, built in config.ts:11-30, env in config.ts:32-43) is extended with attestation identity: attestationEnabled: boolean, attestationKeyPath: string, attestationFingerprint: string, attestationSidecarDir: string, read from new AGENT_MUX_PROXY_ATTESTATION_* env vars. The proxy identity key becomes an engine trust root.

**AC-12 (non-streaming, revised).** In each route handler (server.ts /v1/messages 1595-1611, /v1/chat/completions 1613-1629, /v1/responses 1631-1648), *after* trackCompletionOutcome (1324-1348) and *before* protocol encoding, when the result is a CompletionResult (not a Response), sign a ModelDecisionPayload (AC-34) from { modelId: config.targetModel, provider: config.targetProvider, inputMessagesHash, outputContent, toolCalls } where toolCalls is built from the CompletionResult.toolCalls[] (each {id, name, arguments}) as { toolCallId: id, name, argsHash: sha256(canonicalize(JSON.parse(arguments))) }. The argsHash uses the **same** canonical helper as the gate (AC-8) so proxy and gate hashes match byte-for-byte. inputMessagesHash is sha256 over plan.request.messages (available at the handler from createExecutionPlan, 369-389). The envelope is **not** injected into the response body (bodies stay provider-compatible, research §6 delivery-channel caveat) — it is written to the sidecar store keyed by request id, with a per-toolCallId index so the policy component can resolve by tool-call id (AC-16).

**AC-13 (streaming, revised).** For streamed completions, tool calls finalize only at the terminal done event. trackCompletionStream (server.ts:1287-1322) — already an async-iterable wrapper — is extended to accumulate tool-call deltas and, at event.type === 'done', build the per-call SignedToolCall[] (id + name + argsHash over the fully-accumulated arguments) and sign the ModelDecisionPayload from those + usage, writing it to the sidecar with the per-toolCallId index. The existing terminal-event points (anthropic ~740, openai-chat ~826, responses ~1008) are where accumulated calls are complete. Signing MUST happen only after every tool call's arguments are fully accumulated, so argsHash is over the final argument bytes.

**AC-14 (correlation).** An x-request-id middleware (~server.ts:1545) echoes / mints a request id. The attestation sidecar entry is keyed by that request id; the same id is returned as a response header so the harness can thread it forward. Where the engine already carries per-tool-call metadata (google thoughtSignature map, server.ts:1537; openai finish-reason accumulation, engines/openai.ts:280-288) that mechanism is the precedent for carrying a per-tool-call correlation hint. The policy component later resolves attestations by requestId (and, when present, toolCallId) from the sidecar store.

6.2 In-process: genty session (`@a5c-ai/genty-core`)

**AC-15 (revised).** For the non-proxied genty runtime path, endpoint.model (in scope at session.ts:1122-1123) is flowed into ToolExecutionContext (core/src/types.ts:156-165, extended with an optional modelAttestation?: SignedEnvelope<ModelDecisionPayload> and modelId?: string) and emitted on the tool_use event (session.ts:1236). The genty adapter signs **one** ModelDecisionPayload per model turn in runCompletionLoop (around 1216, where result.toolCalls is known), whose toolCalls[] binds **every** call in result.toolCalls with {toolCallId: id, name, argsHash} — computed once before the tool loop at session.ts:1235 iterates. The same signed envelope is attached to the ToolExecutionContext of **each** call in the turn; each gate then matches its own toolCallId against the signed toolCalls[] (AC-34a). This producer's fingerprint is a *different* engine trust root, and (per AC-39) is **not** accepted for credential-touching actions unless the policy explicitly opts out of requireProxyAttestation.

6.3 Attestation resolution by the policy component

**AC-16 (revised).** The policy adapter resolves a model-decision evidence for a given tool call by looking it up in the sidecar store (proxy path) **by tool-call id** (the per-toolCallId index of AC-12/13), then by the in-process attestation carried on ToolExecutionContext (genty path). "Falling back" here is **not** a security fallback: resolution returns whichever envelope is present, and the step still verifies it (§7, AC-35) and requires a SignedToolCall matching this call's toolCallId + argsHash (AC-34a). If neither source yields an envelope that binds this exact tool call, the model-decision step is **unsatisfied → deny**. There is no path where a missing or non-matching attestation is treated as satisfied.

6.4 High-assurance: requiring the proxy-signed variant

**AC-17 (revised).** A policy step MAY set requireProxyAttestation, which constrains the accepted model-decision evidence to the **proxy** engine fingerprint(s) only, rejecting the in-process genty attestation.

**AC-39 (proxy attestation is the DEFAULT for credential-touching actions; label the in-process variant correlation-grade).** Because the in-process genty attestation is signed by a key **inside** the agent process, a compromised agent can forge it; it is therefore **correlation-grade only** and labeled as such in code and docs. For any action whose match names a credentialScope (i.e. any action that can cause a scoped credential to be injected, §9.3), requireProxyAttestation **defaults to true** and must be *explicitly* set to false to accept the in-process attestation. The default flips the Draft-1 opt-in to an opt-out for exactly the actions that matter. Non-credential actions keep requireProxyAttestation: false as the default. The evaluator MUST reject an in-process (agent-held-key) attestation for a credential-touching action unless the policy author explicitly opted out, and that opt-out is recorded in the audit log.

6.5 Passthrough-mode gap (documented)

**AC-18.** When the proxy runs in passthrough mode (no completionEngine; proxyUpstream, server.ts:524-554), request/response bodies are forwarded unparsed, so no attestation is produced. This is a documented gap for this iteration. Policies that require proxy attestation — explicitly, or by the AC-39 default for **every credential-touching action** — therefore **deny** on passthrough (fail-closed, correct); passthrough model calls cannot satisfy a proxy-attestation requirement until a future iteration adds buffering-based signing. Operators must not route high-assurance actions through passthrough transports. AC-44 tests this denial.

---

7. Policy document schema (Milestone B)

**AC-19.** A policy document is a declarative file (YAML or JSON) with an array of actions. Each action declares matchers and **one or more** required trust-chain templates. Multiple chain shapes are a core requirement — an action lists alternative chains and is satisfied if **any** chain is satisfied (OR across chains; AND across a chain's steps).

yaml
version: 1
authorizationTtlSeconds: 120        # default; per-action override allowed
commandDefaultAllow: false          # AC-38b: default-allow for command-bearing tools is OPT-IN per env
defaultDeny: []                     # credentialScope globs that default-deny when uncovered (§9.4)
actions:
  - id: aws-prod-write
    match:
      tool: "Bash"                  # glob over tool name (reuses dispatch.ts globToRegex, :25-31)
      argv:                         # AC-38: match on CANONICALIZED argv, not a raw regex
        program: "aws"             # resolved binary basename (abs path/symlink resolved to real path)
        subcommandEquals: ["s3 cp", "s3 rm", "s3 sync"]  # normalized subcommand tokens
      credentialScope: "aws:prod:*" # glob over the requested credential scope
    # requireProxyAttestation omitted -> defaults to TRUE here (credentialScope present, AC-39)
    chains:                         # satisfied if ANY chain fully verifies
      - id: human-plus-opus
        steps:
          - kind: human-approval
            trustedIdentities: ["fp:human:alice", "role:sre-oncall"]
            conditions:
              scopeEquals: "aws:prod:s3"
              notExpired: true
          - kind: model-decision
            conditions:
              modelIdMatches: "claude-opus-.*"   # model allowlist
      - id: two-human-quorum
        quorum: { of: "human-approval", min: 2 }  # alternate shape: dual human approval
        steps:
          - kind: human-approval
            trustedIdentities: ["role:sre-oncall"]

Schema elements:

dispatch.ts globToRegex (25-31). argv (AC-38) replaces Draft-1's command: regex. An action with no argv matches non-command tools.

raw command **string** with a regex ("^aws s3 (cp|rm|sync)\\b"). That is trivially bypassed: sh -c 'aws s3 rm ...', npx aws ..., an absolute or symlinked path (/usr/local/bin/aws, /tmp/link->aws), or env indirection (AWS=aws; $AWS s3 rm) all fail the regex, so the action does **not match**, and an uncovered command-bearing action then default-allows — a default-allow bypass. AC-38 requires the matcher to operate on a **canonicalized argv**, not the raw string: 1. Tokenize the command into argv[] (respecting the tool's real quoting; for Bash, parse the command line, and if the program is a shell (sh/bash/zsh) with -c, recurse into the -c payload so the *inner* program is matched, not sh). 2. Resolve argv[0] to a **real absolute path** (follow symlinks, apply PATH), then take its canonical basename as program. program matching is on the resolved basename, so /usr/local/bin/aws, a symlink to it, and bare aws all canonicalize to aws. 3. Reject/deny (do not silently non-match) when the program cannot be resolved, or when a wrapper that defeats canonicalization is detected for a policy-covered scope — these MUST NOT fall through to default-allow; a covered scope with an unresolvable program is treated as a covered-but-unauthorized action → deny (AC-38a). 4. subcommandEquals / subcommandMatches operate on the **normalized** subcommand tokens (argv[1..] with flags separated), not the raw string.

denylist of defeating wrappers (env, xargs, $(), ...). A denylist is unbounded — the next unlisted wrapper (nice, stdbuf, time, nohup, setsid, doas, a shell builtin, an unknown launcher) slips through. Draft 3 inverts it: for a **policy-covered scope**, canonicalization **succeeds only if every leading token is on an allowlist of recognized, transparent wrappers** declared **per covered scope** (e.g. a scope may permit sudo -u <user> or time and recurse through them to the real program), and the finally-resolved program is a recognized program for that scope. Any leading token **not** on the scope's wrapper allowlist — or any construct that breaks static resolution (command substitution $()/backticks, variable-indirect program $PROG, eval, piping the program name in) — makes the program **unresolvable → deny** (AC-38a), never default-allow. The allowlist is closed and reviewable; adding a wrapper is a policy edit signed into the config manifest (AC-46), not an open bypass surface.

global default-allow-for-uncovered behavior is **retained only for non-command-bearing tools**. For command-bearing tools (any tool that can execute a shell command / carries an argv), default-allow is **off unless** commandDefaultAllow: true is set for the environment. When it is false (the default), an uncovered command-bearing invocation is **denied**, not passed through. This makes the dangerous default (arbitrary uncovered commands) an explicit per-environment opt-in rather than the global default, while non-command tools keep default-allow so the world does not break (§9, AC-23).

chains and the evaluator MUST grant on the first fully-satisfied chain.

trustedIdentities (fingerprints or role labels that resolve to fingerprints via trust roots and are passed to verifyEnvelopeTrusted as allowedFingerprints, AC-35 b), conditions.

(runtime/policy/types.ts:7 and governance/engine.ts matchCondition: eq/neq/gt/lt/gte/lte/contains/matches) plus evidence-specific sugar: modelIdMatches (regex, the "opus decided" allowlist), scopeEquals, notExpired, tagContains. Sugar compiles down to the base operators so there is one condition evaluator.

kind from **n distinct trust-root fingerprints** — *and*, because one human may hold several keys, from **n distinct human identities** (the responderId/approvedBy behind the fingerprint, not merely n distinct fingerprints). A two-human quorum therefore **cannot** be met by one human's two keys: the evaluator groups accepted evidences by the underlying identity resolved from the trust root and counts distinct identities, not distinct keys. Complements the platform quorum in approvalChains.ts:96-158.

chain could mix ordered typed steps *and* a quorum (so "human+opus AND 2-human-quorum" needed two separate chains, which changes the semantics from AND to OR). Draft 3 makes a chain a list of **requirements** evaluated with **AND**, where each requirement is one of: { step: {kind, trustedIdentities, conditions} } (a single typed step) **or** { quorum: {of, min, trustedIdentities?, conditions?} } (a distinct-holder quorum, AC-41). A chain is satisfied iff **every** requirement is satisfied (AND), and grant is on the first fully-satisfied chain (AC-19a). This lets one chain express human-approval(alice) AND model-decision(opus) AND quorum(human-approval, min:2) as three AND-ed requirements. evidenceStepBindings (AC-42) records one binding per satisfied requirement — for a quorum requirement, one binding **per contributing evidence** (min entries), each pinning its {stepIndex, requiredKind, envelopeHash}; the issuer refuses to issue if any requirement (or any of a quorum's min contributors) lacks a bound, verified envelope. The legacy top-level quorum: form in the §7 example remains valid sugar for a single-requirement chain. An evidence envelope MUST NOT be counted toward more than one requirement in the same chain (no double-use across a typed step and a quorum).

PermissionEvidence, and expiresAt/key-expiry checks (proven verify.ts:39-51) for keys.

  • **match** — { tool: glob, argv?: ArgvMatch, credentialScope?: glob }. Tool glob reuses
  • **argv (AC-38 — canonicalized/tokenized command matching; review issue 5).** Draft 1 matched a
  • **argv wrapper handling (AC-38c — allowlist, not denylist).** Draft 2 said to *detect* a
  • **commandDefaultAllow (AC-38b — default-allow becomes opt-in for command-bearing tools).** The
  • **chains[]** — alternative trust-chain templates (OR). **AC-19a**: an action MUST support ≥2
  • **steps[]** — ordered required evidence steps (AND). Each step: kind (evidence kind),
  • **conditions** — reuse the existing operator vocabulary from the policy engines
  • **quorum (AC-41 — distinct-holder rule).** { of: kind, min: n } requires ≥n evidences of that
  • **quorum composition with heterogeneous steps[] (AC-41a).** Draft 2 left it ambiguous whether a
  • **expiry** — per-step notExpired uses isPermissionValid (tool-signing.ts:50-55) for

**AC-20 (evaluation semantics).** The policy evaluator, given an action context and the resolved evidence set, returns { granted: boolean, matchedChainId?, reason, evidenceUsed: Evidence[] }. Precedence within the adapter mirrors the existing engines: an explicit deny action wins over grants (deny > grant > default), consistent with governance/engine.ts:94-145 and runtime/policy/engine.ts:65-110. When granted, the issuer (§5) mints the CommandAuthorization from evidenceUsed.

---

8. Component placement (Milestone B)

**AC-21.** A **new workspace package @a5c-ai/policy-adapter at packages/adapters/policy** is introduced (the only genuinely new module in this design). Placement rationale, grounded in the current dependency graph:

@a5c-ai/comm-adapter, @a5c-ai/tasks-adapter, @a5c-ai/transport-adapter all consume the new package with **no cycle** (they do not depend back into it).

it at the MCP dispatcher seam.

workspace glob (package.json:17) — **no root workspace-list edit needed**, avoiding one class of lockfile churn.

  • It depends **only** on @a5c-ai/genty-core (trust primitives, trust/index.ts) + Node built-ins.
  • @a5c-ai/genty-core is a leaf that adapters may already depend on; @a5c-ai/tools-adapter,
  • @a5c-ai/genty-platform (which already depends on genty-core and can depend on adapters) consumes
  • Placing it inside packages/adapters/* means it is already covered by the "packages/adapters/*"

**AC-21a (lockfile constraint).** Adding the package still requires a lockfile regeneration. Per the research §Constraints and MEMORY, this **must not** be done with bare npm install on Windows (it pins win32 native bindings non-optional and breaks Linux npm ci). Regeneration is done on Linux/CI or with the repo's sanctioned lockfile workflow; the design flags this as an explicit implementation gate.

**AC-22 (extend, do not duplicate the two engines).** The policy adapter **reuses** the condition evaluator shape and precedence of the two existing engines rather than forking a third:

precedence) — the effect-level engine. The new adapter's condition sugar compiles to the same PolicyConditionOp set (runtime/policy/types.ts:7). The SDK engine gains proof-awareness by delegating trust-chain steps to the policy adapter (it does not re-implement signature checks).

the platform engine calls the policy adapter for chain verification; it keeps its own rule precedence. Neither engine's public API is broken; both gain an optional "trust-chain" rule kind that hands off to @a5c-ai/policy-adapter.

  • @a5c-ai/babysitter-sdk runtime/policy/ (engine.ts:22-56 matchCondition, 65-110
  • @a5c-ai/genty-platform governance/engine.ts:94-145 — the harness-level engine. Same treatment:

---

9. Enforcement contract at each gate (Milestone D)

Covered = the tool/command/creds match some policy action. Uncovered = no action matches.

**AC-23 (uniform gate contract, revised).** At every gate, the action is first matched using the **canonicalized argv** (AC-38), never a raw string. For a **covered** action: 1. Resolve the required evidence (human approval by breakpoint id; model decision **by tool-call id** via §6.3, AC-16; delegation from the agent request). 2. Evaluate the policy (§7) against the integrity-verified policy doc (AC-36). If not granted → **deny** (fail closed). 3. Obtain / verify the CommandAuthorization (§5) with the exact toolCallId/argsHash/ commandHash(canonicalized argv)/credentialScope about to execute, recomputed at this gate. If verification fails or throws → **deny**. 4. Only on success does the tool/command/credential proceed.

For an **uncovered** action, coverage-and-default is now split by tool class (AC-38b):

credentialScope matches a configured defaultDeny glob (§9.4, AC-23c) → deny.

environment; otherwise **deny** (default-deny). An uncovered command that could not even be canonicalized (AC-38a) under a covered scope is denied regardless. Any error in steps 1–3, or any argv-canonicalization failure for a covered scope, is a denial, never a fallback-allow.

  • **Non-command-bearing tool:** pass through unchanged (default-allow), **unless** the action's
  • **Command-bearing tool:** pass through **only if** commandDefaultAllow: true for this

9.1 GATE 1 — `@a5c-ai/tools-adapter` dispatch (`tools/src/dispatch.ts:133-188`)

The beforeToolUse hook (dispatch.ts:149-164, ToolHookBridge, hooks.ts:28-49) is the injection point. A new PolicyVerifierHookBridge wraps or composes the existing bridge and returns { decision: 'deny', reason } (dispatch.ts:151-156) when policy verification fails for a covered call. ToolCallContext (tools/src/types.ts:194-202) already carries toolName, input, runId, sessionId — enough to compute argsHash and resolve model-decision evidence by sessionId/tool-call id.

9.2 GATE 2 — `@a5c-ai/comm-adapter` runtime hooks (`core/spawn-runtime-hooks.ts:97-120`)

The preToolUse blocking dispatch (spawn-runtime-hooks.ts:99-117, HookDecision deny at 113-116, type runtime-hooks.ts:53-60) gates spawned harnesses. A policy preToolUse handler returns { decision: 'deny', reason } for covered calls that lack a valid authorization. Only mode === 'blocking' adapters get hard enforcement here; for non-blocking adapters GATE 1 / GATE 3 are the enforcing gates (documented per-gate coverage).

9.3 GATE 3 — credential injection backstop (`core/spawn-invocation.ts`)

**AC-23a (revised — Draft 3 — credential binding across ALL delivery channels).** Credentials currently flow with no gate through **multiple** channels that spawn-invocation.ts controls, not env vars alone: docker -e K=V (86-89) **and** docker -v mounts (79, 81); ssh K=V prefix (120-124); k8s exec env K=V (211-216); k8s ephemeral --env=K=V (249-251) and --serviceaccount=/secret references (239). A credential about to be delivered by **any** of these channels is tagged with a credentialScope from the trusted out-of-agent source (AC-40/AC-40a). Before delivery, GATE 3 requires a valid CommandAuthorization whose credentialScope matches; **no valid authorization → the scoped credential is not delivered** by that channel (env var dropped, -v mount omitted, secret/serviceaccount reference stripped), and if the policy marks it required, the spawn is denied. GATE 3 is the **last** point before exec, so it **recomputes** argsHash and commandHash (shared canonicalizer, AC-52 / AC-38) from the exact command/args being spawned and re-checks them against the authorization (AC-10 steps 4/5) — the hashes are never carried forward from GATE 1. This is a backstop for the credential-delivery surface; the **load-bearing** enforcement for every covered action (credential-bearing or not) is AC-49. AC-50 defines exactly which credential channels GATE 3 mediates and which are a bounded, warned non-goal.

**AC-40 (trusted, out-of-agent credential→scope source).** The mapping from an actual credential to its credentialScope tag MUST come from a source **outside the agent process** — the same orchestrator/proxy trust domain that holds the issuer/config keys (e.g. a credential-broker config signed by the config root, or KMS/secret-store metadata). The agent process MUST NOT be able to assert "these creds are scope aws:prod:s3-ro" for creds that are actually broader. GATE 3 reads the scope tag from this trusted source keyed by the credential's identity, not from any agent-supplied hint. If the trusted source has no scope for a credential that a policy marks scoped, GATE 3 denies (fail closed) rather than injecting an untagged credential.

**AC-40a (collision-resistant credential identity; deny on ambiguous).** The "credential's identity" GATE 3 keys the scope lookup on MUST be **collision-resistant**, not a guessable/forgeable label: it is sha256 of the credential's *stable secret-store identity* (broker key id, KMS ARN/key-version, or secret name+version), **never** the credential value's prefix, the env-var name, or an agent-supplied alias. The trusted source (AC-40) is a map keyed by this identity. If two distinct credentials resolve to the **same** identity key (a collision or a mis-keyed broker entry), or if a credential's identity is **absent/ambiguous** in the trusted map, GATE 3 **denies** (fail closed) — it never guesses a scope, and never picks the narrower of two candidate scopes. This prevents an attacker from getting a broad credential tagged with a narrow scope by exploiting a weak identity key.

**AC-49 (load-bearing, un-bypassable gates for ALL covered actions — closes residual issue 8(i)).** Draft 2 leaned on GATE 3 as "the backstop," but GATE 3 only ever sees a *scoped credential injection*. A policy-covered action that injects **no** scoped credential (e.g. a covered Bash rm -rf, a covered MCP tool, a kubectl delete with the pod's ambient service-account) has **nothing** for GATE 3 to gate; if GATE 2 is advisory (non-blocking adapter, §9.2) and GATE 1 is bypassed, such an action would execute unchecked. Draft 3 therefore **designates GATE 1 and the genty dispatcher/session seam (§9.4) as the LOAD-BEARING, un-bypassable enforcement for every covered action — credential-bearing or not** — and requires proof that every tool-execution path traverses at least one blocking gate:

beforeToolUse deny, §9.1; **or** the genty dispatcher dispatcher.dispatch / session definition.execute verifier, §9.4) MUST evaluate the policy and be able to return deny **before** execution. GATE 2 (advisory for non-blocking adapters) and GATE 3 (credential-only) are **defense-in-depth, not the sole line**. A covered action reaching exec without having passed a blocking gate is a design defect.

(research §4 genty decision/execution points; §5 the three adapters gates; §6.3 dispatcher seam), the test **enumerates every tool-execution entry path** — genty session session.ts:1251 definition.execute; genty MCP dispatcher effects.ts:561-601 dispatcher.dispatch; adapters GATE 1 dispatch.ts:133-188; spawned-harness GATE 2 spawn-runtime-hooks.ts:99-117; and the spawn path spawn-invocation.ts (local/docker/ssh/k8s) — and asserts that for a covered action **each path hits at least one gate that blocks on policy denial**. The test fails if any enumerated path can reach execution of a covered action without a blocking gate. This converts "enforcement at all gates" from prose into an executable coverage check, and is the acceptance evidence for AC-33's alternate-path defense.

  • **Invariant.** For a **covered** action, at least one *blocking* gate on the path (GATE 1
  • **AC-49a (execution-path enumeration acceptance test).** From the research execution-path map

**AC-50 (GATE 3 credential-channel coverage; bounded, warned non-goal — closes residual issue 8(ii)).** GATE 3 mediates the credential-delivery channels that spawn-invocation.ts **constructs and can therefore see** — enumerated in AC-23a: env injection (docker -e, ssh K=V, k8s env/--env), docker -v file mounts, and k8s secret/service-account references (--serviceaccount, mounted secret volumes it emits). For each, "no valid authorization" means the channel is **omitted** (mount dropped, secret ref stripped), not merely the env var. Channels GATE 3 **cannot** mediate are an explicit, **bounded non-goal with a warning**, not a silent gap:

constructing the delivery — cloud **instance metadata (IMDS/IRSA/workload-identity)**, pre-existing files on a shared/mounted path the agent already controls, credentials embedded in a container image, and network-reachable secret endpoints the process calls itself. GATE 3 does not see these, so it **cannot** enforce scope on them.

for *injecting* credentials into a spawned process, so it is where scoped injection is gated. The out-of-scope channels are properties of the **execution substrate** (VM metadata, image contents), mitigated by substrate controls (no broad instance role for agent pods, minimal images, network egress policy) — called out here as an operator warning, and cross-referenced from §1.2.

the deployment **cannot** deliver through a GATE-3-mediated channel (i.e. it would arrive via IMDS or a pre-existing mount), the evaluator MUST emit a loud **audit-logged warning** at load time and, for such an action, MUST NOT report granted on the assumption GATE 3 will backstop it — because it will not. High-assurance scoped credentials MUST be delivered through a GATE-3-mediated channel; the design does not claim to gate substrate-delivered credentials.

  • **Out-of-scope (documented):** credentials the process obtains **without** spawn-invocation.ts
  • **Why env/mount is the primary channel:** spawn-invocation.ts is the one seam this design controls
  • **Mandatory warning + fail-closed default.** When a policy action declares a credentialScope that

9.4 genty dispatcher / session

**AC-23b.** The genty MCP dispatcher seam (platform/.../orchestration/effects.ts:561-601 dispatcher.dispatch(...)) and the genty session tool-execution point (session.ts:1251 definition.execute(...)) verify authorization before execution, using the in-process model attestation (§6.2) carried on ToolExecutionContext. Genty's existing CustomToolDefinition.metadata.requiresApproval (core/src/types.ts:224) is wired to map onto policy coverage (declared-but-unenforced today → enforced via the adapter).

**AC-23c (default-deny scopes).** defaultDeny (policy doc §7) is a configurable list of credential-scope globs (e.g. ["aws:prod:*"]) for which an **uncovered** action is denied rather than passed through. This makes "default-deny for production scopes, default-allow elsewhere" expressible without breaking every uncovered dev action.

---

10. Key management & ops (Milestone A)

**AC-25 (generation/provisioning).** Human keys use proven generateKeyPair + saveTrustedPublicKey/savePrivateKey (proven/keys.ts:9-71). Engine/agent/issuer keys use genty createKeyPair (trust/signing.ts:4-13) / createAgentIdentity (identity.ts:4-11). The proxy attestation key is provisioned to the proxy host and referenced by attestationKeyPath (§6.1); it is **never** placed where the agent process can read it.

**AC-26 (revised — Draft 3 — trust-roots config format, location, signed manifest).** Trust roots live in a single git-tracked file .policy/trust-roots.json at repo root (public material only), an array of TrustRoot records (§4.2, each now carrying key material per AC-6) plus a revoked list. Policy documents live under .policy/policies/*.yaml. Private keys are **never** git-tracked: human private keys stay under .breakpoints/.keys/private/ (gitignored, existing proven layout); engine/issuer private keys under .policy/.keys/private/ (added to .gitignore). Public keys are git-tracked (trusted/ + trust-roots.json).

**Draft 3 replaces per-file detached signatures with a single signed configEpoch manifest** (AC-46). Rather than one <name>.sig.json per file — which permits **partial rollback** of one file to an older validly-signed version — there is exactly **one** signed manifest, .policy/config-manifest.json, that lists **every** config file (trust-roots.json and every policies/*.yaml) with its sha256, plus a monotonically increasing configEpoch integer, all signed as one SignedEnvelope<ConfigManifestPayload> by the out-of-agent config root-of-trust (AC-36). Git-tracking a file does **not** make it trusted; presence in the current-epoch signed manifest does.

10.1 Config integrity — the out-of-agent root of trust (**AC-36/AC-37**, review issue 3)

**Threat being closed.** The threat model puts a compromised / workspace-writable agent in scope (§11 AC-33). Draft 1's trust-roots and policy files were merely git-tracked JSON/YAML on that same workspace, so such an agent could **add its own fingerprint as a human root, relax an argv matcher, or widen a credentialScope glob, then self-authorize.** No signature check stopped it.

**AC-36 (revised — Draft 3 — config root-of-trust; one signed manifest, verified before honoring).** There is a distinguished **config root-of-trust** key whose private half is held **outside every agent process** — the same place the transport proxy / issuer keys live (the orchestrator/proxy host, or a hardware token / KMS in production), never on the agent-writable workspace and never under any path an agent step can write. Its public half is pinned as a kind:'config' root. Configuration is covered by **one** signed manifest:

ts
interface ConfigManifestPayload {
  payloadType: 'config-manifest';   // AC-51 domain-separation constant
  configEpoch: number;              // AC-46: monotonically increasing; strictly greater on every re-sign
  files: { path: string; sha256: string }[];  // EVERY config file: trust-roots.json + every policies/*.yaml
  issuedAt: string;                 // ISO
}

Every gate and the evaluator MUST, before honoring **any** trust root or policy document: 1. Load .policy/config-manifest.json and verify it via verifyEnvelopeTrusted(manifest, requiredKind:'config', allowedFingerprints: [pinnedConfigRootFp]) (AC-35, which also enforces payloadType === 'config-manifest' per AC-51) against the pinned config-root fingerprint. 2. **Enforce the epoch floor (AC-47):** require manifest.payload.configEpoch >= POLICY_CONFIG_MIN_EPOCH (the off-workspace pin). Below the floor → **deny all**. 3. For **every** config file about to be honored, recompute sha256 of its canonical bytes and require it to equal the entry in manifest.files for that path; a config file **not listed** in the manifest, or whose hash disagrees, → **deny all**. (This binds trust-roots and every policy doc to the *same* epoch, so no single file can be rolled back independently — AC-46.) 4. Mismatch, missing manifest, wrong signer, below-floor epoch, or an unlisted/edited file → **deny all** (fail closed): if the config itself is untrusted, no action is authorized. CommandAuthorization.policyDocHash (AC-8) is the per-doc hash from this same manifest and CommandAuthorization.configEpoch records the manifest epoch, so a gate confirms it enforced the exact signed documents, under the exact epoch, that the authorization was issued under (AC-10 steps 9–10).

Because the signing key is off the workspace, an in-scope compromised agent can edit the files but **cannot produce a valid signature** for the edit; the tampered config is rejected. This is the integrity guarantee Draft 1 lacked. (Alternative considered and rejected for this iteration: placing the files on a read-only path the agent cannot write. Signing is preferred because it survives the files being copied, distributed, or served over git, and does not depend on per-deployment mount permissions.)

**AC-46 (monotonic config epoch — one manifest covers all config, closes residual issue 6).** Draft 2 signed sha256(file bytes) per file with **no version, counter, or nonce**. A validly-signed *older* config set therefore remains valid forever: an in-scope adversary who retains a previously-signed trust-roots.json + policy set (e.g. from git history, or a prior deploy) can **swap the older, more-permissive set back in** — and because the revocation list lives inside trust-roots.json, the rollback **un-revokes** any key that was revoked after that snapshot (re-enabling a stolen key), and restores any policy loosened since (wider credentialScope globs, relaxed argv matchers). Each file's signature verifies in isolation, so nothing detects the downgrade. **AC-46 binds a monotonically increasing integer configEpoch into a single signed manifest** that covers **all** config files together (AC-36):

**strictly greater** than the previous manifest's epoch on every re-sign (the sign-config CLI, AC-37, refuses to emit a manifest whose epoch is <= the epoch it is replacing, unless an explicit operator --force-epoch override records an intentional reset in the audit log).

manifest under the same epoch**, **partial rollback of one file is impossible**: you cannot pair an old trust-roots.json with a new policy set (or vice-versa) — the manifest hash for the swapped file will not match, and there is no per-file signature to verify it independently. Revocation therefore cannot be downgraded independently of the epoch.

  • The config root signs **one** ConfigManifestPayload per config change; configEpoch MUST be
  • Because trust-roots (incl. the revocation list) and every policy doc are hashed **into the same

**AC-47 (off-workspace minimum-epoch floor; every gate rejects below-floor config).** The rollback defense requires a monotonic reference the adversary cannot rewind. That reference is an **off-workspace pin**: POLICY_CONFIG_MIN_EPOCH is pinned in the **same** code/deploy artifact as POLICY_CONFIG_ROOT_FP (AC-37) — the deploy image / env, never a workspace file an agent can edit. Every gate and the evaluator, after verifying the manifest signature (AC-36), MUST reject any config whose configEpoch < POLICY_CONFIG_MIN_EPOCH (**deny all**), and MUST reject any CommandAuthorization whose configEpoch is below the floor or does not equal the currently-honored manifest epoch (AC-10 step 10). Advancing the floor is a deploy operation performed **together with** signing a new config epoch, so a rolled-back-but-validly-signed older manifest (epoch below the current floor) is rejected even though its signature verifies. The floor is what converts "monotonic counter" into an enforceable anti-rollback bound: the manifest proves *which* epoch; the pinned floor proves the *minimum acceptable* epoch, and neither can be moved by editing the workspace. (Where a hardware monotonic counter / KMS key-version is available in production, the floor MAY be sourced from it instead of a static pin; the static deploy pin is the baseline.)

**AC-37 (bootstrap / root-of-trust provisioning).** The top of the trust hierarchy is provisioned out-of-band, once, before any agent runs, and its story is:

policy-adapter init-config-root (**flagged new CLI in @a5c-ai/policy-adapter**). The private half is stored where agents cannot read it (proxy/orchestrator host secret store, KMS, or token); the public half's fingerprint is **pinned in code/deploy config** (POLICY_CONFIG_ROOT_FP env / a committed pinned-config-root.json that is itself part of the deploy image, not the workspace), so the pin cannot be swapped by editing a workspace file. **The minimum-epoch floor POLICY_CONFIG_MIN_EPOCH (AC-47) is pinned in the same artifact, beside POLICY_CONFIG_ROOT_FP.**

trusted host, hashes **all** config files into one ConfigManifestPayload with the next configEpoch (strictly greater than the current epoch — AC-46), and emits the single signed .policy/config-manifest.json. Agents never hold this key, so agents cannot sign config or mint a higher epoch. Advancing the deploy-pinned floor is done together with rolling out a new epoch.

(with the floor pinned at 1); trust-roots.json in turn names the human/engine/agent roots. Revocation of the config root itself is a deploy operation (rotate the pin), out of scope for online revocation (§1.2 non-goal 4).

config-manifest.json (epoch ≥ floor) covering trust-roots.json + policy docs → those name the evidence-signing roots → evidence chains authorize commands. Every link is verified by verifyEnvelopeTrusted; the only links anchored by a code/deploy pin (not a file) are the config root **and the epoch floor**, which together make the tree resistant to both tampering and rollback by the workspace-writable adversary.

  • The config root-of-trust keypair is generated on the operator's trusted host (or KMS/HSM) via
  • Signing a config change is an explicit operator step: policy-adapter sign-config runs on the
  • The config root signs (via the manifest) the initial trust-roots.json + policy docs at epoch 1
  • **Chain summary:** pinned config root + pinned epoch floor (off-workspace) → sign one

**AC-27 (revised — Draft 3 — rotation & revocation, downgrade-protected).** Rotation reuses proven rotateKey (keys.ts:122-148): it marks the old public key expiresAt and provisions a new pair. Verification honors key expiry at **signing time** (proven/verify.ts:39-51; isPermissionValid tool-signing.ts:50-55) so an old signature made while the key was valid still verifies unless the key is explicitly revoked. Revocation is a revoked: true flag / fingerprint list in trust-roots.json; a revoked fingerprint fails verification immediately regardless of signing time.

**Revocation cannot be downgraded independently (AC-46).** Because the revocation list lives inside trust-roots.json and trust-roots.json is hashed into the single signed manifest under the current configEpoch, rolling the revocation list back to un-revoke a stolen key requires swapping in an older trust-roots.json — which changes the manifest hash and lowers configEpoch below the pinned floor (AC-47), so it is rejected. **Every revocation MUST therefore be published by signing a new, strictly-higher config epoch and advancing the deploy floor**, so the un-revoked state (a lower epoch) is permanently below the floor. What is git-tracked: public keys, trust-roots config, policy docs, revocation list, config-manifest.json. What is local-only: all private keys. What is deploy-pinned (off-workspace): POLICY_CONFIG_ROOT_FP and POLICY_CONFIG_MIN_EPOCH (AC-37/AC-47).

---

11. Threat model (Milestone E test targets)

Each row is an acceptance criterion with a defense grounded in the design.

be a SignedEnvelope<PermissionEvidencePayload> signed by a human trust root; unsigned or wrong-signer approvals fail §5 step 7 / §7. Closes the current spoofable gap (research §3: SDK task_post/commitEffectResult accept unsigned answers today).

CommandAuthorization binds commandHash + argsHash + expiresAt, and human-approval conditions.scopeEquals + notExpired; an old approval for scope A does not satisfy a chain for scope B, and expiry bounds the window.

valid opus attestation for call A is replayed onto sibling call B in the same turn). Defense: model-decision evidence covers modelId + inputMessagesHash **and** a signed toolCalls[] binding each toolCallId to its argsHash (AC-34), signed by the **proxy engine key held outside the agent** (§6.1); the agent cannot forge which model answered nor which call it authorized. The gate requires a SignedToolCall matching the executing toolCallId+argsHash (AC-34a), so a turn's attestation is not replayable to a different call. modelIdMatches: "claude-opus-.*" plus requireProxyAttestation (defaulting to true for credential-touching actions, AC-39).

authorization TTL (§5), and immediate revocation via trust-roots.json. A stolen key is revoked by fingerprint; new authorizations from it fail at once.

the execution gate itself (§9) against the **exact** argsHash/commandHash/credentialScope about to run, not at an earlier decision point; a mutated command/args after issuance produces a hash mismatch → deny.

seam are the **load-bearing, un-bypassable** blocking gates for **every** covered action — credential-bearing or not (AC-49); the AC-49a execution-path enumeration test asserts each enumerated exec path hits a blocking gate. GATE 2 (advisory for non-blocking adapters) and the GATE 3 credential backstop across **all** delivery channels it controls (AC-50) are defense-in-depth: with no valid authorization the scoped credential is never delivered by any GATE-3-mediated channel, so an alternate path that skips GATE 1/2 still cannot obtain scoped credentials it controls. The credential→scope tag comes from the trusted out-of-agent source keyed by collision-resistant credential identity (AC-40/AC-40a), so the agent cannot relabel broad creds as a narrow scope. GATE 3's substrate-delivered-credential limits (IMDS, pre-existing mounts) are a bounded, warned non-goal (AC-50), not a silent hole.

argv matcher, widens a credentialScope, then self-authorizes). Defense: .policy/trust-roots.json and every policy doc are hashed into the **single out-of-agent config-root-signed manifest** (AC-36) whose fingerprint is pinned in deploy config, not on the workspace; every gate verifies the manifest signature and per-file hashes before honoring any root or policy and denies-all on mismatch. The agent can edit the files but cannot produce a valid signature (AC-37), so tampered config is rejected. CommandAuthorization.policyDocHash + configEpoch tie each authorization to the exact signed policy doc under the exact epoch.

more-permissive trust-roots + policy set back in, un-revoking a stolen key and re-widening scopes). Defense (residual issue 6): the single signed manifest carries a **monotonic configEpoch** (AC-46) covering all config files together, and every gate rejects any configEpoch below the off-workspace pinned POLICY_CONFIG_MIN_EPOCH floor (AC-47) and any authorization whose configEpoch is below the floor or ≠ the current manifest epoch (AC-10 step 10). Revocation is published by advancing the epoch + floor (AC-27), so the un-revoked older state is permanently below the floor. Partial rollback of one file is impossible — all files share one manifest hash + epoch. Test: sign epoch N, advance floor to N, present the epoch-(N-1) manifest → **deny all**.

approved:true as an **unsigned** field, listing a signedFields that omits it; verifyAnswer still returns valid because it rebuilds the payload from the attacker's signedFields, proven/verify.ts:60). Defense (residual issue 7): the bridge applies the AC-2 completeness rule to the legacy proven answer — it denies unless {breakpointId, approved, responderId} ⊆ provenAnswer.signedFields and each is present — **before** deriving any human evidence, and derives only when approved === true (AC-48/AC-3/AC-43). Test: a proven answer valid under a human key but with approved absent from signedFields yields **no** human evidence → chain unsatisfied → deny.

tool that injects no scoped credential, with GATE 2 advisory and GATE 1 skipped). Defense (residual issue 8(i)): GATE 1 and the genty dispatcher/session seam are load-bearing blocking gates for **all** covered actions (AC-49); the AC-49a enumeration test asserts every exec path hits a blocking gate, so no covered action reaches exec unchecked even when no credential is involved.

presented for a step expecting a different engine-signed type). Defense: every payload carries a bound payloadType constant in signedFields, and verifyEnvelopeTrusted denies unless payload.payloadType equals the expected constant for the step's kind (AC-51). Test: present a model-decision envelope for a command-authorization slot → deny.

behaviors that Draft 1 asserted but did not test: (i) for a **non-blocking** GATE-2 adapter (mode !== 'blocking', §9.2), GATE 2 cannot hard-enforce, so the test asserts that GATE 1 and/or GATE 3 still deny a covered-but-unauthorized call — i.e. the overall system is fail-closed even when GATE 2 is advisory. (ii) A model call routed through **passthrough proxy mode** (no completionEngine, §6.5) produces no attestation, so an action requiring proxy attestation (incl. any credential-touching action defaulting to it, AC-39) MUST be **denied** on that path. AC-44 is the explicit acceptance test for both. **(Draft 3)** The non-blocking-GATE-2 case is subsumed by the broader AC-49a enumeration test: with GATE 2 advisory, a blocking gate (GATE 1 or the genty seam) still denies a covered-but-unauthorized call whether or not it carries a credential.

  • **AC-28 — Spoofed approval** (fabricated approved:true). Defense: human-approval evidence must
  • **AC-29 — Replay across commands** (reuse an old approval for a new command). Defense:
  • **AC-30 — Model downgrade + attestation replay within a turn** (sonnet decides, claims opus; or a
  • **AC-31 — Expired / stolen keys.** Defense: signing-time key-expiry checks (§10 AC-27), short
  • **AC-32 — TOCTOU between authorization and execution.** Defense: the authorization is verified at
  • **AC-33 — Bypass via alternate execution path.** Defense: GATE 1 and the genty dispatcher/session
  • **AC-45 — Config / policy tampering by a compromised agent** (adds its own trust root, relaxes an
  • **AC-46 — Config rollback / downgrade by a compromised agent** (swaps a validly-signed *older*,
  • **AC-48 — Proven-bridge unsigned approved bit** (a forged ProvenBreakpointAnswer sets
  • **AC-49 — Covered non-credential action on a bypassed/advisory path** (a covered Bash rm -rf or MCP
  • **AC-51 — Cross-payload-type signature transfer** (an engine-signed envelope of one payload type
  • **AC-44 — Non-blocking-GATE-2 coverage + passthrough denial (acceptance test).** Two fail-closed

---

12. Acceptance-criteria → milestone map

MilestoneAcceptance criteria
**A — trust-core**AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7, AC-8, AC-25, AC-26, AC-27, **AC-34** (model-decision payload extension type), **AC-35** (trusted-store verifier wrapper), **AC-36** (config-integrity verification), **AC-37** (root-of-trust bootstrap), **AC-43** (proven bridge → human), **AC-46** (monotonic config-epoch manifest), **AC-47** (off-workspace epoch floor), **AC-48** (proven-bridge legacy signedFields completeness), **AC-51** (domain-separation payloadType), **AC-52**+**AC-52a** (shared canonical argv/args serialization + conformance test)
**B — policy-engine**AC-9, AC-10, AC-19, AC-19a, AC-20, AC-21, AC-21a, AC-22, **AC-38** (canonicalized argv matcher), **AC-38a**, **AC-38b** (command default-allow opt-in), **AC-38c** (argv wrapper allowlist), **AC-41** (quorum distinct-holder), **AC-41a** (heterogeneous quorum composition), **AC-42** (evidence covers every step)
**C — evidence-producers**AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, **AC-34a** (tool-call binding at produce/verify), **AC-39** (proxy-attestation default for credential actions)
**D — tool-layer-enforcement**AC-23, AC-23a, AC-23b, AC-23c, **AC-40** (trusted credential→scope source), **AC-40a** (collision-resistant credential identity), **AC-49** (load-bearing un-bypassable gates), **AC-50** (GATE 3 credential-channel coverage + bounded non-goal)
**E — e2e-integration**AC-24 (non-goals guard), AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, **AC-44** (non-blocking-GATE-2 + passthrough-denial test), **AC-45** (config-tampering threat), **AC-46** (config-rollback threat test), **AC-48** (proven-bridge unsigned-approved threat test), **AC-49a** (execution-path enumeration test), **AC-51** (cross-payload-type transfer test)

Every acceptance criterion maps to exactly one milestone. (AC-24, the non-goals guard, is verified in E as a scope-regression check.) New ACs from the Draft-2 security revision are assigned as above: type/verifier/config-integrity work lands in A, schema/evaluator work in B, producer binding in C, credential-scope sourcing in D, and the new threat/behavior tests in E. **Draft-3 note on dual-listed ids:** AC-46, AC-48, AC-49, and AC-51 name a *mechanism* (implemented in its home milestone — A for 46/47/48/51, D for 49/50) **and** a *threat test* (verified in E). The mechanism AC and its E-row test are the same criterion observed at implementation vs. verification time; the milestone of record for implementation is the home milestone (A/B/D), and E lists the acceptance **test** that exercises it — consistent with how AC-34/AC-34a and AC-30 already appear in both a producer milestone and the E threat model. AC-49a and AC-52a are test-only sub-criteria owned by E and A respectively.

---

13. Reuse ledger (extend, do not rebuild)

ConcernReused artifact (file:line)New?
Envelope + canonical formgenty/core/src/trust/signing.ts:4-86, types.ts:1-8reuse
Human-approval evidencetrust/tool-signing.ts:13-55reuse
Model-decision evidence (base)trust/model-signing.ts:4-26reuse
**Model-decision payload extension** (ModelDecisionPayload + SignedToolCall, AC-34)new type beside trust/model-signing.ts in genty-core**NEW type** (only relaxed no-new-schema exception)
Delegationtrust/agent-signing.ts:5-13, types.ts:29-33reuse
Chain verify (raw)trust/chain.ts:20-55reuse (never called directly, see wrapper)
**Trusted-store verifier wrapper** (verifyEnvelopeTrusted, AC-35)@a5c-ai/policy-adapter**NEW code** (wraps genty verify; adds fingerprint-binding + kind + trusted-store resolution)
**Config-integrity verification + signing CLI** (AC-36/AC-37)@a5c-ai/policy-adapter (init-config-root, sign-config, gate-side verifyConfigManifest)**NEW code**
**Config-epoch manifest + off-workspace floor** (AC-46/AC-47)ConfigManifestPayload + POLICY_CONFIG_MIN_EPOCH pin; sign-config mints strictly-higher epoch; gates enforce floor**NEW code**
**Proven-bridge legacy signedFields completeness** (AC-48)@a5c-ai/policy-adapter bridge (wraps proven/verify.ts:20-72; asserts {breakpointId,approved,responderId}⊆signedFields before deriving)**NEW code** (bridge assertion)
**Domain-separation payloadType** (AC-51)bound constant added to every payload + REQUIRED_SIGNED_FIELDS; checked in verifyEnvelopeTrusted**NEW field + check**
**Shared canonical argv/args serialization** (AC-52)canonicalizeArgs/canonicalizeArgv in @a5c-ai/policy-adapter (builds on genty deepSortKeys), imported by proxy + every gate**NEW code** (single shared util)
**Canonicalized argv matcher + wrapper allowlist** (AC-38/AC-38c)@a5c-ai/policy-adapter**NEW code** (tokenize + resolve program + per-scope wrapper allowlist + normalize subcommand)
**Load-bearing gate coverage + exec-path enumeration test** (AC-49/AC-49a)assertion over existing gates (GATE 1 dispatch.ts, genty seam effects.ts/session.ts); enumeration test**NEW test** (no new runtime module)
**GATE 3 non-env credential channels** (AC-50)extend GATE 3 to docker -v (spawn-invocation.ts:79,81), k8s secret/serviceaccount refs (239) — env channels already in AC-23a**extend** (bounded non-goal for IMDS/pre-mounts)
**Trusted credential→scope source** (AC-40)out-of-agent broker/KMS metadata; adapter reads it at GATE 3**NEW integration**
Human key gen/rotateproven/keys.ts:9-148reuse
proven bridgeproven/verify.ts:20-72reuse (bridge new)
Condition operatorsruntime/policy/engine.ts:22-56; governance/engine.ts:32-72reuse
Engine precedenceruntime/policy/engine.ts:65-110; governance/engine.ts:94-145reuse
GATE 1tools/src/dispatch.ts:133-188, hooks.ts:28-49reuse (verifier new)
GATE 2core/spawn-runtime-hooks.ts:99-117reuse (handler new)
GATE 3core/spawn-invocation.ts:86-89,120-124,211-216,249-251extend (binding new)
genty dispatcher seamplatform/.../orchestration/effects.ts:561-601reuse (verify hook new)
genty session model flowsession.ts:1122-1123,1216,1236,1251; types.ts:156-165extend
Proxy attestation seamtransport/server.ts:1287-1322,1394-1423,1595-1648; config.ts; types.ts:15-24extend
**Policy adapter**packages/adapters/policy (@a5c-ai/policy-adapter)**NEW package**

The **only** genuinely new *package* is @a5c-ai/policy-adapter. Everything else is an extension or composition of existing code, with the flagged exceptions the security revisions require: (1) a **new type** ModelDecisionPayload/SignedToolCall added beside trust/model-signing.ts in @a5c-ai/genty-core — the single, deliberate relaxation of the no-new-schema rule, needed to bind a model decision to a specific tool call (AC-34; Draft 3 adds a bound payloadType field to it and to every other payload, AC-51); and (2) **new security-critical code inside** @a5c-ai/policy-adapter — the verifyEnvelopeTrusted trusted-store wrapper (AC-35, incl. payloadType binding), the config-integrity verify/sign path with the **monotonic-epoch manifest** (AC-36/AC-37/AC-46/AC-47), the **proven-bridge legacy-completeness assertion** (AC-48), the **shared canonical argv/args serializer** (AC-52), and the canonicalized-argv matcher with a **per-scope wrapper allowlist** (AC-38/AC-38c) — which are extensions of, not forks of, existing genty/proven primitives. The Draft-3 GATE-3 extension (AC-50) and load-bearing-gate assertion (AC-49) add no new runtime module (they extend spawn-invocation.ts credential handling and add a coverage test over existing gates). No third policy engine is created (AC-22); the two existing engines delegate trust-chain steps to the adapter.

14. Owner approval addendum (Draft 3 accepted, 2026-07-03)

The owner approved this design at the design-approval breakpoint (security-review score 91/100) with the explicit condition that the four remaining non-blocking review notes are **promoted to REQUIRED acceptance criteria** — they are must-do before their milestone closes, not optional polish. These are now in scope:

fixture that round-trips a real provider arguments JSON string through JSON.parse at the proxy seam (transport/types.ts:57) → optional benign hook mutation → each gate's canonicalizeArgs, and asserts byte-identity of the resulting argsHash against the object the harness actually delivers to definition.execute (session.ts:1251). The design MUST also state that input-mutating GATE-1 hooks (dispatch.ts:158-163) are incompatible with covered actions unless they run before attestation; a covered action whose input a hook mutates after attestation MUST fail closed (deny), and a test MUST assert this. Availability-grade, but required so covered actions do not silently break.

  • **AC-53 (Milestone C — argsHash boundary conformance).** AC-52a's conformance corpus MUST include a

(proven/sign.ts:23-30, unescaped field=value\n) MUST be hardened with delimiter/length-prefix escaping (or the proven answer path MUST be migrated to the genty JSON canonical form) so the AC-48 bridge does not stand on an ambiguous concatenation. Existing proven signatures MUST continue to verify (dual-read during transition) and the AC-48 completeness assertion MUST be evaluated against the hardened form. A test MUST demonstrate two distinct field assignments can no longer collide to the same signing bytes.

  • **AC-54 (Milestone A — proven canonical-form hardening).** The proven legacy signing canonical form

source (AC-40/AC-40a) MUST canonicalize aliases of one physical credential (e.g. a KMS key referenced by ARN and by key-id) to a single collision-resistant identity BEFORE the GATE-3 deny-on-ambiguous rule is applied, so legitimate multi-alias credentials are not falsely denied. A test MUST cover a two-alias-one-credential case resolving to one scope.

  • **AC-55 (Milestone B — credential-identity alias canonicalization).** The trusted credential→scope

MUST be structured as an exhaustiveness assertion over a checked-in registry of tool-execution / dispatch / spawn entry seams (the execute/dispatch/spawn points in genty + adapters). Any new exec entry point not present in the registry MUST fail the build, so a future unregistered execution path breaks CI rather than silently bypassing enforcement.

  • **AC-56 (Milestone D — exhaustive exec-seam registry).** The AC-49a execution-path enumeration test

Each of AC-53..AC-56 maps to exactly one milestone as noted above and inherits the fail-closed, no-fallback discipline of the rest of this spec.

Article source

The article body is owned directly by this record.

Related pages

No related wiki pages for this record.

Shortcuts

Open overview
Open JSON
Open graph