diff --git a/CHANGELOG.md b/CHANGELOG.md index 4602494..a75ca1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,44 @@ reference implements it. See [`spec.md` §2.1](spec.md) for the version matrix. ## [Unreleased] +### Added (0.4 — draft, adoption clauses; additive on the 0.3 preimage, not yet reference-backed) +The document moves to **0.4 (draft)**. 0.4 is an **adoption-first** track: every +clause is additive (it changes no hashed or signed bytes and MAY be adopted by a +0.3 implementation), and the one breaking item considered for 0.4 — folding +request headers/body into the `action_fingerprint` preimage — is **deferred** to +keep 0.4 free of a re-integration tax. The reference still implements **0.3**; +these clauses are spec-led (*draft — not yet in reference*). +- §2.2 — **Broker interface (the PEP contract)**: consolidates the Broker's + transport-agnostic obligations (reconstruct from the authorized action only, + refuse what it cannot reconstruct, verify a token per §9.1 if required, execute + at most once per authorization, report its outcome) into one contract a third + party can implement against, plus a **separated-gateway** request/response + profile (`schema/broker-gateway.json`) that distinguishes a Broker refusal from + an upstream result. Conformance line added to §10. +- §7.2 — **Approval lifecycle & routing metadata**: optional `reason`, + `routing_group`/`required_approvers`, and `expires_at` on the approval record + (expiry **fails closed** → `deny`). Advisory only — set by the Authorizer/policy, + never the Agent; MUST NOT affect the §7 resolution guards. +- §7.3 — **Approval notification & callback protocol**: takes the human decision + out of the local console onto any surface (chat/web/ticketing) with the Agent + out of the loop. Outbound notification + signed single-use decision callback + (`schema/approval-callback.json`); the approval principal MUST be separate from + the Agent and an unverifiable/replayed callback leaves the approval `pending` + (fail-closed). The callback carries no action, so the §7 P1/P2 guards still bind + the action at release time. Conformance line added to §10. +- §8.4 — **Receipt context (optional, unsigned)**: a `context` object *outside* + the signed payload for correlating receipts to operational identity (agent / + session / trace / principal) without a breaking receipt change. NOT + tamper-evident; ignored by §8.1 verification; MUST NOT carry + authorization-relevant data. `schema/receipt.json` gains an optional `context`. +- §10 — a **0.4 — additive (adoption)** conformance block; §11 — security notes + for approval-callback authenticity/replay, Broker idempotency, and unsigned + receipt context. +- New schemas `schema/approval-callback.json`, `schema/broker-gateway.json` and + examples `examples/approval-callback.json`, `examples/broker-gateway.json`, all + wired into `validate.py`. No CTK `hashing`/`decisions`/`resolve`/`chain`/`token` + vector changes — `conformance.py` is unaffected (reference 0.3 ≤ spec 0.4). + ### Fixed (spec prose — status bookkeeping caught up with the 0.3.0 query-fold) - `spec.md` had not been updated when the reference shipped the §4.2 query-fold (delego 0.3.0, CTK regenerated in #9) and contradicted itself: §2.1's version diff --git a/README.md b/README.md index 8fef126..db9204c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Contributions welcome](https://img.shields.io/badge/contributions-welcome-green.svg)](CONTRIBUTING.md) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Spec](https://img.shields.io/badge/spec-v0.3-blue.svg)](spec.md) +[![Spec](https://img.shields.io/badge/spec-v0.4--draft-blue.svg)](spec.md) delego is a **deterministic pre-action authorization layer for AI agents**: it sits between an agent that proposes actions and the credential broker that @@ -60,7 +60,7 @@ byte-for-byte. | Component | What it is | |-----------|------------| | **[Specification](spec.md)** | This document — the protocol. | -| **[Schemas](schema/)** | JSON Schemas for the policy, the audit receipt, and the authorization token. | +| **[Schemas](schema/)** | JSON Schemas for the policy, the audit receipt, the authorization token, and the 0.4-draft approval-callback and separated-gateway contracts. | | **[Conformance Test Kit](ctk/README.md)** | Language-agnostic vectors any implementation can check itself against. | | **[delego](https://github.com/Delego-Dev/delego)** | The reference implementation (Python) — policy engine, CLI, and MCP server. | @@ -76,20 +76,26 @@ reproduce them; see [§10 Conformance](spec.md#10-conformance). ## Status & versioning -**v0.3 — frozen.** The spec/protocol is versioned `0.x` (the reference *package* -is `0.x.y`). The reference implements **0.3** (delego ≥ 0.3.0; the §9 token -profile since 0.3.3); each prior protocol version has a standalone document of -record in [`versions/`](versions/README.md) ([0.1](versions/spec-v0.1.md), -[0.2](versions/spec-v0.2.md)). 0.3 has two tracks: **additive hardening clauses** -— the §4.2 Broker query obligation (≤ 0.2 preimage), policy-schema validation -(§5.1), the authorization properties P1–P4 (§7.1), head-anchoring (§8.3), and the -authorization-token profile (§9) — which tighten obligations **without changing -any hashed or signed bytes** and so MAY be adopted on the 0.2 preimage; and one -**breaking** change — folding the canonicalized URL query into the -`action_fingerprint` preimage (§4.2), reference-backed since delego 0.3.0 with -the `hashing` CTK vectors regenerated on the 0.3 preimage. See the -[§2.1 version matrix](spec.md#21-protocol-versions). A breaking change to the -receipt fields bumps the version (see [§8.2](spec.md#82-schema-versioning)). +**v0.4 — draft.** The spec/protocol is versioned `0.x` (the reference *package* +is `0.x.y`). **0.1–0.3 are reference-backed** — the reference implements **0.3** +(delego ≥ 0.3.0; the §9 token profile since 0.3.3); each prior protocol version +has a standalone document of record in [`versions/`](versions/README.md) +([0.1](versions/spec-v0.1.md), [0.2](versions/spec-v0.2.md)). 0.3 added **additive +hardening** (the §4.2 Broker query obligation, policy-schema validation §5.1, the +authorization properties P1–P4 §7.1, head-anchoring §8.3, the authorization-token +profile §9) plus one **breaking** change (folding the canonicalized URL query into +the `action_fingerprint` preimage, §4.2, reference-backed since delego 0.3.0). + +**0.4 (draft) is adoption-first** and **entirely additive** on the 0.3 preimage +(*draft — not yet in the reference*): a Broker-interface / separated-gateway +contract (§2.2), approval lifecycle & routing metadata (§7.2), an approval +**notification & callback protocol** that takes the human decision out of the +local console (§7.3), and an optional **unsigned** receipt `context` for +correlation (§8.4). The one breaking item considered for 0.4 — folding request +headers/body into the fingerprint — is **deferred** to avoid a re-integration +tax. See the [§2.1 version matrix](spec.md#21-protocol-versions). A breaking +change to the receipt fields bumps the version (see +[§8.2](spec.md#82-schema-versioning)). ## Contributing diff --git a/examples/approval-callback.json b/examples/approval-callback.json new file mode 100644 index 0000000..c05cb8d --- /dev/null +++ b/examples/approval-callback.json @@ -0,0 +1,23 @@ +{ + "_note": "Illustrative (spec.md §7.3, draft 0.4). The notification delivers a parked approval to a human surface; the decision callback resolves it. Validated against schema/approval-callback.json#/$defs/notification and #/$defs/decision. Signature is a placeholder.", + "notification": { + "approval_id": "apr_4c9183f7606f", + "instruction": "place a small order", + "action_summary": "POST api.example.com/orders", + "outcome": "needs_approval", + "reason": "amount exceeds the auto-allow threshold", + "routing_group": "payments-approvers", + "required_approvers": 1, + "nonce": "01JBQK9Z6X8N3M2P0R5T7V9W3A", + "exp": 1759000600 + }, + "decision": { + "approval_id": "apr_4c9183f7606f", + "decision": "approve", + "nonce": "01JBQK9Z6X8N3M2P0R5T7V9W3A", + "ts": "2026-06-11T12:00:30Z", + "kid": "approval-2026-06", + "decision_note": "verified the recipient out of band", + "signature": "PLACEHOLDER_approval_principal_signature_over_approval_id_decision_nonce_ts" + } +} diff --git a/examples/broker-gateway.json b/examples/broker-gateway.json new file mode 100644 index 0000000..2acb278 --- /dev/null +++ b/examples/broker-gateway.json @@ -0,0 +1,20 @@ +{ + "_note": "Illustrative (spec.md §2.2, draft 0.4). The request carries the authorized place-order action and its bindings (fpr/iht from ctk/vectors/hashing.json); the response reports one execution attempt. Validated against schema/broker-gateway.json#/$defs/request and #/$defs/response.", + "request": { + "action": { + "method": "POST", + "url": "https://api.example.com/orders", + "params": { "amount": 2400, "currency": "USD", "destination": "internal" } + }, + "intent_hash": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", + "action_fingerprint": "4327df2637072bf058622d1f8baea6e431726f7332a50cd12ec970d6e43c2fd2", + "authorization_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.PLACEHOLDER.PLACEHOLDER" + }, + "response": { + "executed": true, + "upstream": { + "status": 201, + "result": { "order_id": "ord_8f21" } + } + } +} diff --git a/schema/approval-callback.json b/schema/approval-callback.json new file mode 100644 index 0000000..348925c --- /dev/null +++ b/schema/approval-callback.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Delego-Dev/specification/blob/main/schema/approval-callback.json", + "title": "delego approval notification & decision callback", + "description": "Draft (0.4): the outbound notification that delivers a parked approval to a human surface, and the inbound signed decision callback that resolves it. See spec.md §7.3. Additive — no hashed or signed wire bytes change.", + "$defs": { + "notification": { + "type": "object", + "additionalProperties": false, + "description": "Outbound event emitted when an action is parked for approval. MUST NOT carry a credential or any secret.", + "required": ["approval_id", "instruction", "action_summary", "outcome", "nonce", "exp"], + "properties": { + "approval_id": { "type": "string", "description": "Identifier of the parked approval this notification is about." }, + "instruction": { "type": "string", "description": "The human-readable instruction the action was proposed under (plaintext of intent_hash)." }, + "action_summary": { "type": "string", "description": "Human-readable summary of the authorized action (e.g. 'POST api.example.com/orders')." }, + "outcome": { "const": "needs_approval" }, + "nonce": { "type": "string", "description": "Single-use value the matching decision callback MUST echo (replay defence)." }, + "exp": { "type": "integer", "minimum": 0, "description": "Epoch seconds after which a decision callback for this notification is a no-op (the approval has expired → deny)." }, + "reason": { "type": "string", "description": "Why approval is required (spec.md §7.2)." }, + "routing_group": { "type": "string", "description": "Who SHOULD decide (spec.md §7.2). Advisory; set by the Authorizer/policy, never the Agent." }, + "required_approvers": { "type": "integer", "minimum": 1, "description": "How many distinct approvals are required before status becomes approved (spec.md §7.2)." } + } + }, + "decision": { + "type": "object", + "additionalProperties": false, + "description": "Inbound callback that resolves a parked approval. Authenticated by an approval key the Agent does not possess (spec.md §7.3). Carries NO action — the §7 fingerprint/intent guards still bind the action at release time.", + "required": ["approval_id", "decision", "nonce", "ts", "signature"], + "properties": { + "approval_id": { "type": "string", "description": "The parked approval being resolved." }, + "decision": { "enum": ["approve", "deny"] }, + "nonce": { "type": "string", "description": "MUST match the notification's nonce; single-use." }, + "ts": { "type": "string", "description": "ISO-8601 UTC timestamp of the human decision." }, + "signature": { "type": "string", "description": "Authentication over (approval_id, decision, nonce, ts) by the approval principal (human-approval trust domain, §2). An unverifiable signature MUST leave the approval pending." }, + "kid": { "type": "string", "description": "OPTIONAL approval-key id, so a verifier can select the key and support rotation." }, + "decision_note": { "type": "string", "description": "OPTIONAL human note recorded for audit (spec.md §7.2). MUST NOT alter the binding." } + } + } + } +} diff --git a/schema/broker-gateway.json b/schema/broker-gateway.json new file mode 100644 index 0000000..9de0bf2 --- /dev/null +++ b/schema/broker-gateway.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Delego-Dev/specification/blob/main/schema/broker-gateway.json", + "title": "delego separated-gateway request & response", + "description": "Draft (0.4): the JSON contract between an Authorizer-side caller and a separated Broker (PEP). See spec.md §2.2. The Broker reconstructs the request from the authorized action only and reports a single execution attempt, distinguishing a refusal from an upstream result. Additive — no hashed or signed wire bytes change.", + "$defs": { + "request": { + "type": "object", + "additionalProperties": false, + "description": "Authorizer-side → Broker. Carries the authorized action and its bindings; the Broker MUST reconstruct the outgoing request from these fields only (spec.md §2.2).", + "required": ["action", "intent_hash", "action_fingerprint"], + "properties": { + "action": { + "type": "object", + "additionalProperties": false, + "required": ["method", "url"], + "properties": { + "method": { "type": "string", "description": "Uppercase HTTP method, or the action verb for a non-HTTP transport." }, + "url": { "type": "string", "description": "The authorized target (host/path/query as fingerprinted). The Broker refuses a #fragment (spec.md §4.2)." }, + "params": { "type": "object", "description": "The authorized, decision-relevant fields (spec.md §4)." } + } + }, + "intent_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "action_fingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "authorization_token": { "type": "string", "description": "OPTIONAL compact JWS (spec.md §9). If present, a token-requiring Broker MUST verify it per §9.1 before injecting a credential." } + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "description": "Broker → Authorizer-side. Reports a single execution attempt. 'refused' MUST be distinguished from an executed upstream result so the Authorizer records the right receipt (spec.md §2.2, §8).", + "required": ["executed"], + "properties": { + "executed": { "type": "boolean", "description": "True iff the Broker injected a credential and forwarded the request upstream." }, + "refused": { + "type": "object", + "additionalProperties": false, + "description": "Present iff the Broker would not or could not enforce. Mutually exclusive with an upstream result.", + "required": ["code", "message"], + "properties": { + "code": { "enum": ["reconstruction_failed", "fragment_present", "token_invalid", "unsupported", "already_consumed"] }, + "message": { "type": "string" } + } + }, + "upstream": { + "type": "object", + "additionalProperties": false, + "description": "Present iff executed is true: what the Service returned.", + "properties": { + "status": { "type": "integer", "description": "Upstream status code (HTTP) or transport-equivalent." }, + "result": { "description": "Opaque upstream result body, echoed for the caller." }, + "error": { "type": "string", "description": "Set when the execution attempt itself failed (e.g. a timeout); distinct from a Broker refusal." } + } + } + } + } + } +} diff --git a/schema/receipt.json b/schema/receipt.json index f68d830..0565327 100644 --- a/schema/receipt.json +++ b/schema/receipt.json @@ -31,6 +31,10 @@ "type": "string", "pattern": "^[0-9a-f]+$", "description": "Ed25519 signature over UTF-8(entry_hash), hex-encoded." + }, + "context": { + "type": "object", + "description": "OPTIONAL, UNSIGNED correlation metadata (spec.md §8.4, draft 0.4). NOT covered by entry_hash/signature and ignored by §8.1 verification; MUST NOT carry authorization-relevant data. Correlation only, e.g. agent_id / session_id / trace_id / principal." } } } diff --git a/spec.md b/spec.md index 1a99f37..2f0da36 100644 --- a/spec.md +++ b/spec.md @@ -1,6 +1,6 @@ # delego Wire Specification -**Version:** 0.3 · **Status:** Frozen · **License:** Apache-2.0 +**Version:** 0.4 (draft) · **Status:** Draft · **License:** Apache-2.0 This document specifies the delego protocol for **intent-bound action authorization**: every action an autonomous agent proposes is authorized @@ -75,12 +75,69 @@ of this document. | **0.1** | reference-complete, CTK-backed | Canonical JSON (§3); intent hash + action fingerprint (§4); deterministic policy & decision, first-match-wins, fail-closed (§5–§6); fingerprint-bound approval / confused-deputy guard (§7); append-only, hash-linked, Ed25519-signed audit chain + verification (§8). | | **0.2** | reference-complete, CTK-backed | Approval & audit hardening. Approvals are additionally bound to the `intent_hash` and made **single-use** (§7); an approved action's `execution` receipt carries the rule it was parked under, so `rate_limit` counts it (§5, §8); verification treats a malformed or partial receipt as a *failure* rather than aborting the walk (§8.1). | | **0.3** | reference-complete, CTK-backed | Two distinct tracks. **Additive hardening clauses** — the §4.2 Broker query obligation (≤ 0.2 preimage), policy-schema validation & fail-closed (§5.1), the authorization properties P1–P4 (§7.1), head-anchoring as the required truncation defense (§8.3), and the authorization-token profile (§9, reference-backed since delego 0.3.3) — are **additive on the 0.2 preimage**: they tighten obligations without changing any hashed/signed bytes, and **MAY** be adopted by a 0.2 implementation. **One breaking change** — folding the canonicalized URL query into the `action_fingerprint` preimage (§4.2) — changes every fingerprint; it is reference-backed since delego 0.3.0, with the `hashing` CTK vectors regenerated on the 0.3 preimage (the 0.2 preimage is preserved as `hashing-v0.2.json`). | - -This document is **frozen at 0.3**; the reference implements **0.3** (since -delego 0.3.0; the §9 token profile since 0.3.3). Clauses introduced after 0.1 -are tagged inline — *(since 0.2)* for 0.2 behaviour, *(0.3 — additive)* for the -additive-hardening clauses that may be layered on the 0.2 preimage, and -*(NORMATIVE, 0.3 — breaking)* for the query-fold that changes the preimage. +| **0.4** | **draft** — not yet reference-backed | **Adoption clauses, additive on the 0.3 preimage.** A Broker-interface / separated-gateway contract, so a Broker can be written against a spec rather than by reverse-engineering the reference (§2.2); approval lifecycle & routing metadata (§7.2); an approval **notification & callback protocol** that takes the human decision out of the local console onto any surface, with the Agent out of the loop (§7.3); and an optional, **unsigned** receipt `context` for correlating receipts to operational identity (§8.4). All **additive**: they change no hashed or signed bytes and **MAY** be adopted by a 0.3 implementation. | + +This document is at **0.4 (draft)**. **0.1–0.3 are reference-backed** — the +reference implements **0.3** (since delego 0.3.0; the §9 token profile since +0.3.3) — and the **0.4 clauses are draft, not yet in the reference**; like the +0.3 additive track they change no hashed or signed bytes and **MAY** be layered +on the 0.3 preimage. Clauses introduced after 0.1 are tagged inline — *(since +0.2)* for 0.2 behaviour, *(0.3 — additive)* and *(0.4, draft — additive)* for +additive clauses that may be layered on the preimage, and *(NORMATIVE, 0.3 — +breaking)* for the query-fold that changes the preimage. + +## 2.2 Broker interface (the PEP contract) *(0.4, draft — additive)* + +> **Draft — not yet reference-backed.** This section consolidates the Broker's +> existing transport-agnostic obligations (today scattered across §2, §4.2, §9.1) +> into one contract a third party can implement against, and adds a concrete +> **separated-gateway** request/response profile. It changes no hashed or signed +> bytes and is additive on the 0.3 preimage. + +A **Broker** (the PEP, §2) is the only component that touches a credential. It +acts **only** on an Authorizer verdict bound to the action's `action_fingerprint` +(§4), and **MUST** honour the following regardless of transport: + +- **Reconstruct from the authorized action only.** The Broker **MUST** build the + outgoing request from the *authorized* fields — `method`, `host`, `path`, + `params`, and (on the 0.3 preimage) the fingerprint-bound `query` — and **MUST + NOT** forward any agent-supplied bytes not derivable from them. On the 0.3 + preimage it forwards the bound query and refuses only a `#fragment`; on the + ≤ 0.2 preimage the §4.2 query obligation applies. +- **Refuse what it cannot reconstruct (fail-closed).** A Broker that cannot + faithfully reconstruct the request from the authorized action — an unsupported + scheme, an un-modelled body encoding, a present `#fragment` — **MUST** refuse + the action rather than guess. +- **Verify a token if it requires one.** A token-requiring Broker **MUST** verify + the authorization token per §9.1 (pin `alg = EdDSA`, exact `aud`, live `exp`, + unseen `jti`, unconsumed `cns`, and **recompute the fingerprint and require it + equals `fpr`**) before injecting a credential. +- **Execute at most once per authorization.** A single decision, or a single + consumed `cns` (§7.1 P3), authorizes **one** credential release. A Broker + **MUST NOT** re-execute in a way that could double-effect; on an ambiguous or + unknown outcome it **MUST NOT** silently retry unless the upstream is + idempotent or it uses an idempotency key derived from the authorization (e.g. + the `cns` or the `action_fingerprint`). +- **Report the outcome.** The Broker **MUST** return the execution outcome to the + Authorizer (or the Authorizer-side caller) so the `execution` receipt records + what happened (§8). An action **MUST NOT** be reported to the caller as + executed before its `execution` receipt is durably committed. + +The contract is **action-shaped, not HTTP-specific**: an HTTP gateway is one +profile of it; a non-HTTP Broker (an SDK call, a queue publish) honours the same +obligations over its own transport. + +### Separated-gateway profile (draft) + +When the Broker is a separate process or host — the case §9's token addresses — +the Authorizer-side caller and the Broker exchange a JSON request/response per +[`schema/broker-gateway.json`](schema/broker-gateway.json). The **request** +carries the authorized action and its bindings (and the token, if used); the +**response** reports a single execution attempt. A gateway **MUST** distinguish a +**refusal** (it would not or could not enforce — a reconstruction failure, a +token that fails §9.1, a `#fragment`) from an **upstream result** (it executed +and the Service responded), so the Authorizer records the right receipt. See +[`examples/broker-gateway.json`](examples/broker-gateway.json). ## 3. Canonicalization (NORMATIVE) @@ -403,6 +460,78 @@ Authoritative vectors for P1 and P3 are in case refused even when `status = approved`, and the denied-approval case that is not resurrected). See the §10 conformance line. +### 7.2 Approval lifecycle & routing metadata *(0.4, draft — additive)* + +> **Draft — not yet reference-backed.** Additive on the 0.3 preimage: these +> fields live in the approval record, **not** in the `action_fingerprint` or the +> signed receipt payload, and they **MUST NOT** affect the §7 resolution guards. + +When it parks an action, an Authorizer **MAY** attach operational metadata to the +approval record alongside the binding (`action_fingerprint` + `intent_hash`): + +- `reason` — a human-readable statement of *why* approval is required, surfaced to + the approver. +- `routing_group` / `required_approvers` — who SHOULD decide, and how many + distinct approvals are required before the status becomes `approved`. +- `expires_at` (or `expires_after`) — a parked approval **MAY** expire. An expired + approval **MUST** resolve as `deny`: expiry **fails closed** and **MUST NOT** + auto-approve on timeout. This is a derived transition `pending →(timeout)→ + expired`, where `expired` behaves as `denied` (terminal) for §7 resolution. + +An approver's decision **MAY** carry a `decision_note`, recorded for audit. + +**These fields are advisory.** They route and annotate a decision; they are +**not** authorization logic. The §7 guards (fingerprint → intent → status) remain +the sole basis for releasing an action, so attaching, changing, or omitting +routing metadata can never turn a `deny` into an `allow`. Because routing decides +*who* is trusted to approve, it is set by the Authorizer/policy and **MUST NOT** +be settable by the Agent (§2 trust boundary): an Agent that could choose its own +approver or expiry could route its action to a lax one. + +### 7.3 Approval delivery: notification & callback protocol *(0.4, draft — additive)* + +> **Draft — not yet reference-backed.** §7 defines the approval *decision*; it +> does not define how a human is *reached*. This profile defines a transport so a +> parked approval can be presented and resolved on any surface — chat, web, +> ticketing — **with the Agent out of the loop**. It is additive: it changes no +> hashed or signed bytes and binds no new authorization (the §7 guards still bind +> the action at release time). + +**Notification (outbound).** On parking an action, an Authorizer **MAY** emit a +notification to a configured sink (e.g. a webhook) per +[`schema/approval-callback.json`](schema/approval-callback.json). The event +carries `approval_id`, the human-readable `instruction`, the `action_summary`, +the §7.2 `reason`/routing, `outcome = needs_approval`, and a single-use `nonce` +with an `exp`. It **MUST NOT** carry a credential, the agent's raw input beyond +the authorized action summary, or any secret. + +**Decision callback (inbound).** A surface returns a signed approve/deny for an +`approval_id`. An Authorizer that accepts callbacks **MUST**: + +1. **Authenticate the principal.** The callback **MUST** be signed (or otherwise + authenticated) by a principal in the **human-approval trust domain (§2)**, + using an **approval key the Agent does not possess**. A callback whose + principal/signature cannot be verified **MUST** be ignored — the approval stays + `pending` (fail-closed). The approval key **MUST** be separate from any + credential available to the Agent; an Authorizer **MUST NOT** accept an + approve/deny from the same principal that proposes actions. *This is the trust + boundary that makes human approval meaningful: an Agent that could approve its + own actions defeats the entire control.* +2. **Reject replays.** The callback's `nonce` (matching the notification) is + single-use; a re-delivered or replayed callback **MUST NOT** change a status a + second time. Re-delivery of the same decision **MUST** be idempotent (keyed by + `approval_id` + `nonce`). +3. **Honour expiry.** A callback arriving after the approval's `expires_at` + (§7.2) **MUST** be treated as a no-op against the already-`deny` (expired) + approval. + +A verified approve transitions `pending → approved`; a verified deny → `denied` +(terminal). The action itself is **not** carried in the callback — the callback +decides an `approval_id` only — so even a compromised callback channel can at +most approve or deny *that specific parked approval*; it cannot re-point it at a +different action or instruction, because the §7 fingerprint/intent guards (P1/P2) +still run when the Agent later presents an action to release it. + ## 8. Receipt & audit chain (NORMATIVE) Every decision and execution is recorded as a **receipt**. Receipts form an @@ -499,6 +628,31 @@ anchor is advanced as the chain grows. See the §10 conformance line. +### 8.4 Receipt context (optional, unsigned) *(0.4, draft — additive)* + +> **Draft — not yet reference-backed.** Additive on the 0.3 preimage: it adds an +> **unsigned** field *outside* the signed payload, so it changes no hashed or +> signed bytes and never affects §8.1 verification. + +The signed payload is exactly the eleven fields of §8, and changing that set is a +**breaking** change (§8.2). To let a deployment correlate receipts to operational +identity *without* a breaking change, a receipt record **MAY** carry an OPTIONAL +`context` object **outside** the signed payload — it is **not** covered by +`entry_hash` or `signature`. + +- An Auditor **MUST** verify the chain (§8.1) over the signed payload alone and + **MUST** ignore `context`. Adding, changing, or removing `context` therefore + never affects a chain's validity. +- Because `context` is **not tamper-evident**, it **MUST NOT** carry + authorization-relevant data — anything a decision or an audit conclusion depends + on belongs in the signed payload or in the `action_fingerprint`. `context` is + for correlation only: e.g. `agent_id`, `session_id`, `trace_id`, or a bound + `principal` (a SPIFFE ID / ID-JAG subject). + +Binding selected context *into* the signed payload (a tamper-evident receipt v2) +is a candidate **breaking** change (§8.2) for a future revision; until then +`context` is advisory. + ## 9. Authorization Token (OPTIONAL PROFILE) *(0.3 — reference-backed since delego 0.3.3)* > **Status: reference-backed (optional profile).** The reference implements this @@ -661,6 +815,22 @@ CTK vectors. The reference implements **0.3**. remains on the ≤ 0.2 preimage checks itself against `hashing-v0.2.json` and is subject to the §4.2 Broker query obligation instead. +**0.4 — additive (adoption)** *(draft — not yet reference-backed; additive on the +0.3 preimage, MAY be adopted by a 0.3 implementation)* +- A **Broker** SHOULD honour the §2.2 interface contract: reconstruct only from + the authorized action, refuse what it cannot reconstruct, execute at most once + per authorization, verify a token per §9.1 if it requires one, and report its + outcome. A separated gateway SHOULD speak the `schema/broker-gateway.json` + request/response and distinguish a refusal from an upstream result. +- An **Authorizer** that exposes approvals beyond a local console **MUST** apply + the §7.3 trust-boundary rules: the approval-decision principal **MUST** be + separate from the Agent, callbacks **MUST** be authenticated and single-use, and + an unverifiable or replayed callback **MUST** leave the approval `pending` + (fail-closed). A parked approval that expires **MUST** resolve as `deny` (§7.2). +- Approval routing/lifecycle metadata (§7.2) and receipt `context` (§8.4) are + OPTIONAL and advisory; an implementation that uses them **MUST NOT** let them + alter the §7 resolution guards or §8.1 verification. + ## 11. Security considerations - **'Firewall' is an analogy, not the model.** delego is an **action-authorization @@ -697,6 +867,16 @@ CTK vectors. The reference implements **0.3**. The reference v0.1 uses file-backed state and is **not** safe under concurrent writers (a single-writer daemon is planned). - **Clock skew** — `exp`/`iat` comparisons SHOULD allow a small, bounded skew. +- **Approval-callback authenticity & replay** *(0.4 draft)* — the §7.3 approval + key is in the human-approval domain, distinct from any Agent credential; + callbacks are single-use (`nonce`) and fail closed when unverifiable. An Agent + that could forge an approve — or set its own routing/expiry (§7.2) — would + defeat human approval. +- **Broker idempotency** *(0.4 draft)* — a Broker executes at most once per + authorization and MUST NOT double-effect on retry (§2.2, §7.1 P3). +- **Unsigned receipt context** *(0.4 draft)* — `context` (§8.4) is not + tamper-evident and MUST NOT carry authorization-relevant data; it is for + correlation only (§8.4). ## 12. References diff --git a/validate.py b/validate.py index 760f194..a6c1289 100644 --- a/validate.py +++ b/validate.py @@ -39,6 +39,11 @@ def check(name: str, instance, schema) -> None: receipt_schema = load("schema/receipt.json") token_schema = load("schema/authorization-token.json") + +def subschema(schema: dict, ref: str) -> dict: + """A validator for one ``$defs`` member of a multi-definition schema.""" + return {"$schema": schema["$schema"], "$defs": schema["$defs"], "$ref": ref} + print("policy:") check("examples/policy.example.yaml", load("examples/policy.example.yaml"), policy_schema) @@ -50,6 +55,18 @@ def check(name: str, instance, schema) -> None: print("authorization token:") check("examples/authorization-token.json", load("examples/authorization-token.json"), token_schema) +print("approval notification & callback (§7.3, draft 0.4):") +ac_schema = load("schema/approval-callback.json") +ac = load("examples/approval-callback.json") +check("approval-callback.json#notification", ac["notification"], subschema(ac_schema, "#/$defs/notification")) +check("approval-callback.json#decision", ac["decision"], subschema(ac_schema, "#/$defs/decision")) + +print("separated-gateway request & response (§2.2, draft 0.4):") +bg_schema = load("schema/broker-gateway.json") +bg = load("examples/broker-gateway.json") +check("broker-gateway.json#request", bg["request"], subschema(bg_schema, "#/$defs/request")) +check("broker-gateway.json#response", bg["response"], subschema(bg_schema, "#/$defs/response")) + print() if errors: print(f"{errors} schema validation failure(s).")