diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e21d43..f98a250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,91 @@ reference implements it. See [`spec.md` §2.1](spec.md) for the version matrix. ## [0.3-draft] — Unreleased +0.3 has two tracks: a set of **additive hardening clauses** that tighten +obligations **without changing any hashed or signed bytes** (so they MAY be +adopted on the 0.2 preimage), and **one deferred breaking item** (folding the URL +query into the `action_fingerprint` preimage) held for a later draft. + +### Repositioned +- Abstract recast around **intent-bound action authorization**: delego is a + **deterministic pre-action authorization layer for AI agents** (a Policy + Decision Point) that answers `allow` / `needs_approval` / `deny` per action as a + pure function of (action, policy, audit ledger, time), bound to the action + fingerprint. Names the gap as **OWASP ASI03 (Excessive Agency)**. +- §1 / README gain a one-line definition. +- §2 names the **PDP/PEP split** explicitly (Authorizer = PDP, Broker = PEP; only + the Broker touches a credential) and maps `allow` / `needs_approval` / `deny` + onto the `ALLOW` / `REQUIRE_APPROVAL` / `DENY` triad of agent authorization + fabrics. +- §11 adds: **"'Firewall' is an analogy, not the model"** — delego is an + action-authorization layer (a PDP), not a network firewall. "Firewall" is + retained only as a loose analogy. + +### Added (0.3, draft — additive hardening on the 0.2 preimage) +- §4.2 — a **NORMATIVE Broker query obligation**: a Broker MUST NOT transmit any + query parameter not derivable from the authorized action, MUST reconstruct the + outgoing URL from the authorized `host`/`path`/`params` (not the agent-supplied + raw query), and MUST refuse an action whose URL it cannot reconstruct. Changes + no hashed bytes. Conformance line added to §10. +- §5.1 — **policy validation (NORMATIVE)**: an Authorizer MUST validate its policy + against `schema/policy.json` and **fail closed** on an invalid policy, including + rejecting unknown `match`/`constraints` keys (`additionalProperties: false`); it + MUST NOT silently skip an unknown constraint. Conformance line added to §10. +- §7.1 — **Authorization properties (NORMATIVE)**, four testable invariants: + (P1) exact-action binding / substitution-proof — fingerprint mismatch ⇒ `deny` + regardless of status, checked **before** status; (P2) exact-intent binding; + (P3) single-use — `approved → consumed` committed before the credential is + released, atomic, and terminal (`consumed`/`denied` never reopened); + (P4) no cross-approval reuse. Conformance line added to §10. +- §8.3 — **head-anchoring as the REQUIRED truncation/rollback defense**: defines + the external head anchor `(seq, entry_hash)`; an Authorizer advertising + rollback-resistance MUST maintain it; an Auditor with an anchor MUST reject a + mismatched head; an Auditor with **no** anchor MUST report that truncation + cannot be ruled out rather than an unqualified "valid". The §8.1 caveat now + points to §8.3. +- §5 / §11 — the `rate_limit` **consistency class** is stated: **exact** only + under a serialized single-writer ledger, **best-effort** under concurrent + writers; an implementation MUST document which it provides. +- §9 — the **authorization token** is recast as an **OPTIONAL PROFILE** (a + portable PDP→PEP decision artifact), not the protocol's load-bearing control. + Adds a `cns` (consumption nonce) claim binding the token to one credential + release (single-use inheritance); tightens `aud` (exact match), issuance + (TTL ≤ 60 s, unique `jti`, only for `allow`/released approval), and replay + (Broker retains `jti` until `exp`, refuses a consumed `cns`). New **§9.2 + Revocation** (expiry primary; optional revocation list) and **§9.3 Interop** + (PDP→PEP artifact; composes with an agent-passport / identity layer via the + optional `sub` claim; maps onto `ALLOW` / `REQUIRE_APPROVAL` / `DENY`). +- `schema/authorization-token.json` — adds `cns` (string, **required**) and an + optional `sub` (string); the example token and its markdown are updated to match. + +### Added (0.3, draft — CTK) +- `ctk/vectors/resolve.json` — two new **wired** cases for §7.1: **P1** + (fingerprint mismatch refused even when `status = approved`) and **P3** (a + `denied` approval is not resurrected). The current reference already enforces + both, so they are replayed by `conformance.py`. +- `ctk/vectors/policy_invalid.json` and `ctk/vectors/broker_query.json` — new + **pending** vectors documenting the §5.1 and §4.2 behaviour the reference does + not yet expose; **not wired** into `conformance.py`. They activate (get wired) + at reference ≥ 0.2.3. + +### Deferred (0.3, draft — breaking, NOT in 0.3) +- §4.2 — folding the URL **query string** into the `action_fingerprint` preimage. + This **changes the preimage** and is therefore a **breaking** change (§8.2); it + is intentionally **DEFERRED** to a future draft and recorded as a + forward-looking note only. It would bump the protocol version and ship with + regenerated `hashing` vectors when the reference implements it. (Earlier + 0.3-draft notes listed this as an Added item; it is now correctly tracked as + deferred, and the interim defense is the additive §4.2 Broker obligation above.) + ### Fixed - §8.1 corrected: hash-chaining does **not** detect truncation of the most recent receipts (a truncated prefix verifies clean), nor a holder of the local signing - key. Added a normative caveat requiring external head anchoring for rollback - detection — the prior "deleting any receipt breaks a check" claim was wrong. + key. The normative requirement to anchor the head externally now lives in §8.3; + the prior "deleting any receipt breaks a check" claim was wrong. - Version notation normalised to the `0.x` scheme everywhere (spec/protocol is - `0.x`; the reference *package* is `0.x.y`). `conformance.py` now parses a + `0.x`; the reference *package* is `0.x.y`). `conformance.py` parses a two-component spec version. -### Added (0.3, draft — not yet in reference) -- §4.2 — the URL **query string** is folded into the `action_fingerprint` - preimage, closing the confused-deputy gap where two requests differing only in - their query share a fingerprint. A breaking change to the preimage; ships with - updated `hashing` vectors when the reference implements it. -- §9 retagged as the 0.3 frontier (signed authorization token; unchanged content). - ### Added (0.2, now reference-backed) - §2.1 — a **Protocol versions** matrix (0.1 / 0.2 / 0.3) and the rule that the reference's `__protocol_version__` MUST be ≤ this document's version. diff --git a/README.md b/README.md index c43a194..d8f8b54 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Spec](https://img.shields.io/badge/spec-v0.3--draft-orange.svg)](spec.md) -The open specification for **delego** — a deterministic **authorization & audit -protocol for AI-agent actions**. It defines how an action proposed by an agent -is authorized (with no LLM in the decision path) *before* any credential is used, -bound to the originating human instruction, and recorded in a tamper-evident, -signed audit chain. +delego is a **deterministic pre-action authorization layer for AI agents**: it +sits between an agent that proposes actions and the credential broker that +executes them, and answers, for each action, `allow` / `needs_approval` / `deny` +— with no language model in the decision path, before any credential is used, +bound to the originating human instruction and the exact action, and recorded in +a tamper-evident, signed audit chain. It addresses the agent-authorization gap +catalogued as **OWASP ASI03 (Excessive Agency)**. This repository is the source of truth for the protocol. The reference implementation is **[delego](https://github.com/Delego-Dev/delego)**. @@ -28,10 +30,14 @@ implementation is **[delego](https://github.com/Delego-Dev/delego)**. Credential brokers ensure an agent never holds a raw secret — but they can't tell whether *this specific action* is the thing the human authorized. So a prompt injection can redirect an in-scope, validly-credentialed action: the -**confused deputy**. delego authorizes the *action* against a deterministic -policy before any credential is used, binds it to the human instruction, -requires human approval for sensitive actions, and writes a signed, hash-chained -audit trail. +**confused deputy**. delego is the **Policy Decision Point (PDP)** that authorizes +the *action* against a deterministic policy before any credential is used, binds +it to the human instruction, requires human approval for sensitive actions, and +writes a signed, hash-chained audit trail; the credential broker is the **Policy +Enforcement Point (PEP)** and the only component that ever touches a credential. +("Firewall" is sometimes used as a loose analogy for this — but delego is an +action-authorization layer, not a network firewall; see +[§11](spec.md#11-security-considerations).) This specification exists so that independent **authorizers**, **brokers**, and **auditors** — written by different people, in different languages — agree @@ -45,8 +51,9 @@ byte-for-byte. - [Proposed Action & hashing](spec.md#4-proposed-action) - [Policy & decisions](spec.md#5-policy) - [Approval binding — the confused-deputy guard](spec.md#7-approval-binding-the-confused-deputy-guard) +- [Authorization properties (P1–P4)](spec.md#71-authorization-properties-normative-03-draft--additive) - [Receipt & audit chain](spec.md#8-receipt--audit-chain-normative) -- [Authorization Token](spec.md#9-authorization-token-normative) +- [Authorization Token](spec.md#9-authorization-token-optional-profile) ## Ecosystem @@ -70,8 +77,13 @@ reproduce them; see [§10 Conformance](spec.md#10-conformance). ## Status & versioning **v0.3 — draft.** The spec/protocol is versioned `0.x` (the reference *package* -is `0.x.y`). 0.1–0.2 are reference-backed; 0.3 (query-string fingerprint §4.2, -authorization token §9) is specified but not yet in the reference. See the +is `0.x.y`). 0.1–0.2 are reference-backed. 0.3 adds **additive hardening clauses** +— the §4.2 Broker query obligation, 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. One item +is **deferred**: folding the URL query into the `action_fingerprint` preimage +(§4.2) is a breaking change held for a later draft. 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)). diff --git a/ctk/README.md b/ctk/README.md index 6b7ebf0..a4d66a6 100644 --- a/ctk/README.md +++ b/ctk/README.md @@ -13,13 +13,27 @@ conformant implementation MUST reproduce it. | [`vectors/decisions.json`](vectors/decisions.json) | Load the example policy, evaluate each action (spec §5–§6), and match `outcome` / `rule` / `reasons`. | | [`vectors/chain.jsonl`](vectors/chain.jsonl) + [`vectors/chain.expected.json`](vectors/chain.expected.json) | Verify the chain (spec §8.1) using [`vectors/signing_key.pub`](vectors/signing_key.pub); it MUST be valid with the listed `seqs`. | | [`vectors/chain.tampered.jsonl`](vectors/chain.tampered.jsonl) + [`vectors/chain.tampered.expected.json`](vectors/chain.tampered.expected.json) | The same chain with `seq 0` edited and not re-signed; verification MUST fail with a content-hash mismatch at `seq 0`. | -| [`vectors/resolve.json`](vectors/resolve.json) *(0.2)* | For each case, given a parked `approval` (its `action_fingerprint`, `intent_hash`, `status`, `rule`) and a `presented_action`, apply the §7 resolution rules (fingerprint guard → intent guard → status) and match `expected.outcome`; the emitted reason MUST contain `expected.reason_contains`. Exercises the confused-deputy guard, the intent guard, and single-use replay refusal. | +| [`vectors/resolve.json`](vectors/resolve.json) *(0.2; + 0.3 §7.1 P1/P3)* | For each case, given a parked `approval` (its `action_fingerprint`, `intent_hash`, `status`, `rule`) and a `presented_action`, apply the §7 resolution rules (fingerprint guard → intent guard → status) and match `expected.outcome`; the emitted reason MUST contain `expected.reason_contains`. Exercises the confused-deputy guard, the intent guard, and single-use replay refusal — and now the §7.1 authorization properties **P1** (fingerprint mismatch refused even when `status = approved`) and **P3** (a `denied` approval is not resurrected). The P1/P3 cases carry a `"property"` tag; they are already enforced by the reference and are wired into `conformance.py`. | The policy used for the decision and chain vectors is [`../examples/policy.example.yaml`](../examples/policy.example.yaml). The public key for verifying the chain signatures is `vectors/signing_key.pub` (the private key is never published). +## Pending vectors (documented, not yet wired) + +These vectors describe **0.3 additive behaviour that the current reference +(protocol 0.2) does not yet expose**, so `conformance.py` does **not** replay them +— wiring them now would fail against the unfixed reference. They are committed as +documentation and **activate (get wired into `conformance.py`) when the reference +is ≥ 0.2.3**. Each file carries its own `_status`, `_activate_at_reference`, +`_spec`, and `_how_to_apply` keys. + +| File | What it will check | Activate at | +|------|--------------------|-------------| +| [`vectors/policy_invalid.json`](vectors/policy_invalid.json) | Policy-schema validation and **fail-closed** loading (spec §5.1): each policy is invalid (unknown `match`/`constraints` key, unknown `decision`, missing `default`); a conformant Authorizer MUST reject it and MUST NOT silently skip an unknown constraint. | reference ≥ 0.2.3 | +| [`vectors/broker_query.json`](vectors/broker_query.json) | The Broker (PEP) **query obligation** (spec §4.2): the Broker MUST reconstruct the outgoing URL from the authorized `host`/`path`/`params`, MUST NOT forward the agent-supplied query verbatim, and MUST refuse an action whose URL it cannot reconstruct. (This is the additive obligation; folding the query into the fingerprint is a deferred breaking change and is **not** tested here.) | reference ≥ 0.2.3 | + ## Schema validation The example and the receipt vectors also validate against the @@ -29,4 +43,6 @@ runs it on every change. ## Regenerating The vectors are fixtures produced by the reference implementation. A port in any -language passes the CTK when it reproduces every value above. +language passes the CTK when it reproduces every value above. The pending vectors +above become fixtures (and get wired into `conformance.py`) once the reference +implements the corresponding §4.2 / §5.1 behaviour at ≥ 0.2.3. diff --git a/ctk/vectors/broker_query.json b/ctk/vectors/broker_query.json new file mode 100644 index 0000000..8b0ea1e --- /dev/null +++ b/ctk/vectors/broker_query.json @@ -0,0 +1,58 @@ +{ + "_about": "CTK vectors for spec §4.2 — the Broker (PEP) query obligation. These exercise NOT-YET-RELEASED behaviour: query reconstruction at the Broker is a 0.3 additive obligation that the current reference (protocol 0.2) does not yet expose a Broker for, so these vectors are PENDING and are NOT wired into conformance.py. Activate (wire into conformance.py) when a conformant Broker is available at reference >= 0.2.3.", + "_status": "pending", + "_activate_at_reference": ">=0.2.3", + "_spec": "§4.2 (Broker query obligation, additive) and §10 (0.3 additive hardening)", + "_how_to_apply": "For each case, the Broker has authorized the action described by `authorized.host`/`authorized.path`/`authorized.params`. The Agent presents `agent_supplied_url`. A conformant Broker MUST reconstruct the outgoing URL from the authorized host/path/params and MUST NOT forward the agent-supplied query verbatim. `expected.outgoing_url` is the URL the Broker MUST send (null when it MUST refuse). `expected.action` is `reconstruct` or `refuse`.", + "_note_deferred": "Folding the query into the action_fingerprint preimage is a DEFERRED breaking change (§4.2) and is NOT covered here; these vectors test only the additive Broker obligation, which changes no hashed bytes.", + "cases": [ + { + "name": "agent-supplied query is dropped; URL reconstructed from authorized fields", + "property": "no-verbatim-query-forward", + "authorized": { + "method": "GET", + "host": "api.example.com", + "path": "/orders", + "params": {} + }, + "agent_supplied_url": "https://api.example.com/orders?to=attacker&limit=100", + "expected": { + "action": "reconstruct", + "outgoing_url": "https://api.example.com/orders", + "reason_contains": "query not derivable from authorized action" + } + }, + { + "name": "decision-relevant value rides the query and is not in params: dropped", + "property": "no-decision-relevant-from-query", + "authorized": { + "method": "POST", + "host": "api.example.com", + "path": "/orders", + "params": { "amount": 2400, "currency": "USD", "destination": "internal" } + }, + "agent_supplied_url": "https://api.example.com/orders?destination=attacker", + "expected": { + "action": "reconstruct", + "outgoing_url": "https://api.example.com/orders", + "reason_contains": "query parameter not represented in params" + } + }, + { + "name": "Broker that cannot reconstruct the URL refuses the action", + "property": "refuse-when-irreconstructible", + "authorized": { + "method": "GET", + "host": null, + "path": null, + "params": {} + }, + "agent_supplied_url": "https://api.example.com/orders?to=me", + "expected": { + "action": "refuse", + "outgoing_url": null, + "reason_contains": "cannot reconstruct URL" + } + } + ] +} diff --git a/ctk/vectors/policy_invalid.json b/ctk/vectors/policy_invalid.json new file mode 100644 index 0000000..c49fee4 --- /dev/null +++ b/ctk/vectors/policy_invalid.json @@ -0,0 +1,100 @@ +{ + "_about": "CTK vectors for spec §5.1 — policy-schema validation and fail-closed loading. These exercise NOT-YET-RELEASED behaviour: the current reference (protocol 0.2) does not yet validate its policy against schema/policy.json at load time, so these vectors are PENDING and are NOT wired into conformance.py. Activate (wire into conformance.py) when the reference is >= 0.2.3.", + "_status": "pending", + "_activate_at_reference": ">=0.2.3", + "_spec": "§5.1 (policy validation, fail-closed) and §10 (0.3 additive hardening)", + "_how_to_apply": "Load each `policy` into the Authorizer. A conformant >= 0.2.3 Authorizer MUST reject it (fail closed): it MUST refuse to render decisions rather than load a policy it cannot fully interpret. Equivalently, evaluating any action against the loaded policy MUST yield `deny`. The `reason`/`schema_path` fields below are the schema/policy.json locations that make each policy invalid.", + "cases": [ + { + "name": "unknown constraint key is rejected (must not be silently skipped)", + "property": "fail-closed-on-unknown-constraint", + "policy": { + "version": 1, + "default": "deny", + "rules": [ + { + "name": "place-order", + "decision": "needs_approval", + "match": { "method": "POST", "host": "api.example.com", "path": "/orders" }, + "constraints": { + "amount": { "field": "amount", "max": 5000, "currency": "USD" }, + "max_velocity": { "field": "amount", "per_minute": 3 } + } + } + ] + }, + "schema_path": "$defs/constraints (additionalProperties:false) — key 'max_velocity'", + "expected": { + "valid": false, + "load": "reject", + "evaluation_outcome": "deny", + "reason_contains": "unknown constraint" + } + }, + { + "name": "unknown match key is rejected", + "property": "fail-closed-on-unknown-match-key", + "policy": { + "version": 1, + "default": "deny", + "rules": [ + { + "name": "read-accounts", + "decision": "allow", + "match": { "method": "GET", "host": "api.example.com", "path_prefix": "/accounts" } + } + ] + }, + "schema_path": "$defs/match (additionalProperties:false) — key 'path_prefix'", + "expected": { + "valid": false, + "load": "reject", + "evaluation_outcome": "deny", + "reason_contains": "unknown match" + } + }, + { + "name": "unknown decision value is rejected", + "property": "fail-closed-on-unknown-decision", + "policy": { + "version": 1, + "default": "deny", + "rules": [ + { + "name": "place-order", + "decision": "allow_with_audit", + "match": { "method": "POST", "host": "api.example.com", "path": "/orders" } + } + ] + }, + "schema_path": "$defs/rule/properties/decision (enum) — value 'allow_with_audit'", + "expected": { + "valid": false, + "load": "reject", + "evaluation_outcome": "deny", + "reason_contains": "decision" + } + }, + { + "name": "missing required 'default' is rejected", + "property": "fail-closed-on-missing-required", + "policy": { + "version": 1, + "rules": [ + { + "name": "read-accounts", + "decision": "allow", + "match": { "method": "GET", "host": "api.example.com", "path": "/accounts/**" } + } + ] + }, + "schema_path": "required: ['default']", + "expected": { + "valid": false, + "load": "reject", + "evaluation_outcome": "deny", + "reason_contains": "default" + } + } + ] +} diff --git a/ctk/vectors/resolve.json b/ctk/vectors/resolve.json index 4bdae4d..790133b 100644 --- a/ctk/vectors/resolve.json +++ b/ctk/vectors/resolve.json @@ -169,5 +169,58 @@ "outcome": "deny", "reason_contains": "approval already used: this single-use approval has already released its action" } + }, + { + "name": "P1: fingerprint mismatch is refused even when approved (substitution-proof, guard before status)", + "property": "P1", + "approval": { + "action_fingerprint": "c70d4ee57957202087887cb5e9d32222977b728bd06947b7761c283b6d4ed394", + "intent_hash": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", + "status": "approved", + "rule": "place-order" + }, + "presented_action": { + "instruction": "place a small order", + "method": "POST", + "url": "https://api.example.com/orders", + "params": { + "amount": 2400, + "currency": "USD", + "destination": "internal", + "recipient": "attacker" + } + }, + "presented_fingerprint": "dabddc8fc7e8fb30bdec6fb796a336b7897d4a2a12ae386727e2110d7e0e9572", + "presented_intent_hash": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", + "expected": { + "outcome": "deny", + "reason_contains": "approval/action mismatch: this approval was issued for a different action (possible confused-deputy / substituted action)" + } + }, + { + "name": "P3: a denied approval is not resurrected by a matching action", + "property": "P3", + "approval": { + "action_fingerprint": "c70d4ee57957202087887cb5e9d32222977b728bd06947b7761c283b6d4ed394", + "intent_hash": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", + "status": "denied", + "rule": "place-order" + }, + "presented_action": { + "instruction": "place a small order", + "method": "POST", + "url": "https://api.example.com/orders", + "params": { + "amount": 2400, + "currency": "USD", + "destination": "internal" + } + }, + "presented_fingerprint": "c70d4ee57957202087887cb5e9d32222977b728bd06947b7761c283b6d4ed394", + "presented_intent_hash": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", + "expected": { + "outcome": "deny", + "reason_contains": "human denied this action" + } } ] diff --git a/examples/authorization-token.json b/examples/authorization-token.json index 089cfef..1ca91e1 100644 --- a/examples/authorization-token.json +++ b/examples/authorization-token.json @@ -4,9 +4,11 @@ "iat": 1759000000, "exp": 1759000045, "jti": "01JBQK9Z6X8N3M2P0R5T7V9W1Y", + "cns": "01JBQK9Z6X8N3M2P0R5T7V9W2Z", "fpr": "c70d4ee57957202087887cb5e9d32222977b728bd06947b7761c283b6d4ed394", "iht": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", "apr": "apr_4c9183f7606f", + "sub": "agent:onecli/session-7f3a", "pol": { "version": 1, "rule": "place-order" diff --git a/examples/authorization-token.md b/examples/authorization-token.md index 6b9d902..e4238c3 100644 --- a/examples/authorization-token.md +++ b/examples/authorization-token.md @@ -1,8 +1,10 @@ # Authorization Token — example -> **Illustrative.** The authorization token (spec [§9](../spec.md#9-authorization-token-normative)) -> is the v0.3 protocol increment and is **not yet minted by the reference -> implementation**. The compact form below uses a placeholder signature. +> **Illustrative.** The authorization token (spec [§9](../spec.md#9-authorization-token-optional-profile)) +> is an **optional 0.3 profile** for carrying a PDP→PEP decision across a process +> or network boundary; it is **not** the protocol's load-bearing control and is +> **not yet minted by the reference implementation**. The compact form below uses +> a placeholder signature. A delego authorization token is a compact **JWS / JWT** (`header.payload.signature`, each segment base64url-encoded) signed with `alg = EdDSA` (Ed25519). @@ -22,15 +24,21 @@ each segment base64url-encoded) signed with `alg = EdDSA` (Ed25519). "iat": 1759000000, "exp": 1759000045, "jti": "01JBQK9Z6X8N3M2P0R5T7V9W1Y", + "cns": "01JBQK9Z6X8N3M2P0R5T7V9W2Z", "fpr": "c70d4ee57957202087887cb5e9d32222977b728bd06947b7761c283b6d4ed394", "iht": "76f8eef1b97e1213a59eec28cedf15bb999fdb00a3fd17f8343bc4676fdbb4f3", "apr": "apr_4c9183f7606f", + "sub": "agent:onecli/session-7f3a", "pol": { "version": 1, "rule": "place-order" } } ``` `fpr` and `iht` are the place-order's `action_fingerprint` and `intent_hash` from [`../ctk/vectors/hashing.json`](../ctk/vectors/hashing.json). +`cns` is the **consumption nonce**: it binds this token to a single credential +release, so the token inherits the single-use property of the authorization it +carries (spec §7.1 P3, §9). `sub` is the OPTIONAL agent/session identity, present +when the token composes with an agent-identity layer (§9.3). **Compact form** (illustrative): @@ -38,7 +46,9 @@ each segment base64url-encoded) signed with `alg = EdDSA` (Ed25519). eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.. ``` -Before injecting a credential, a Broker recomputes the `action_fingerprint` of -the request it is about to send and requires it equals `fpr` — so the token -authorizes **that exact action** and nothing else (spec -[§9.1](../spec.md#91-broker-verification-the-crux)). +Before injecting a credential, a Broker verifies the signature, requires `aud` to +match its own identifier **exactly**, checks `exp` is in the future, refuses a +previously-seen `jti` and a previously-consumed `cns`, and recomputes the +`action_fingerprint` of the request it is about to send, requiring it equals +`fpr` — so the token authorizes **that exact action**, **once**, and nothing else +(spec [§9.1](../spec.md#91-broker-verification-the-crux)). diff --git a/schema/authorization-token.json b/schema/authorization-token.json index cbd043b..7f26048 100644 --- a/schema/authorization-token.json +++ b/schema/authorization-token.json @@ -5,16 +5,18 @@ "description": "The decoded claim set of a delego authorization token (JWS, alg=EdDSA). See spec.md §9.", "type": "object", "additionalProperties": true, - "required": ["iss", "aud", "iat", "exp", "jti", "fpr", "iht"], + "required": ["iss", "aud", "iat", "exp", "jti", "cns", "fpr", "iht"], "properties": { "iss": { "type": "string", "description": "Authorizer identifier." }, - "aud": { "type": "string", "description": "Intended broker/service identifier." }, + "aud": { "type": "string", "description": "Intended broker/service identifier; the broker MUST require an exact match." }, "iat": { "type": "integer", "description": "Issued-at (epoch seconds)." }, - "exp": { "type": "integer", "description": "Expiry (epoch seconds); TTL SHOULD be <= 60s." }, + "exp": { "type": "integer", "description": "Expiry (epoch seconds); TTL MUST be <= 60s after iat." }, "jti": { "type": "string", "description": "Unique token id (replay protection)." }, + "cns": { "type": "string", "description": "Consumption nonce: binds the token to one credential-release event (single-use). The broker MUST refuse a second token bearing a consumed cns." }, "fpr": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Authorized action_fingerprint." }, "iht": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "intent_hash." }, "apr": { "type": "string", "description": "approval_id, when a human approved." }, + "sub": { "type": "string", "description": "OPTIONAL subject: the agent/session identity the authorization was issued to, for composition with an agent-identity layer (§9.3)." }, "pol": { "type": "object", "properties": { diff --git a/spec.md b/spec.md index f558a72..6c61542 100644 --- a/spec.md +++ b/spec.md @@ -2,10 +2,18 @@ **Version:** 0.3 (draft) · **Status:** Draft · **License:** Apache-2.0 -This document specifies the **delego protocol**: how an *action proposed by an -agent* is authorized — deterministically, with no LLM in the decision path — -before any credential is used, and how that decision is bound to the originating -human instruction and recorded in a tamper-evident, signed audit chain. +This document specifies the delego protocol for **intent-bound action +authorization**: every action an autonomous agent proposes is authorized +deterministically — with no language model in the decision path — and before any +credential is used, with the authorization cryptographically bound to the +originating human instruction and the exact action, and recorded in a +tamper-evident, signed audit chain. delego is a deterministic pre-action +authorization layer for AI agents; it sits between an agent that proposes actions +and the credential broker that executes them, and answers, for each action, +`allow` / `needs_approval` / `deny` as a pure function of (action, policy, audit +ledger, time), bound to the action's fingerprint so it cannot be transferred to a +different action. This addresses the agent-authorization gap catalogued as OWASP +ASI03 (Excessive Agency). The goal is **interoperability**: an authorizer, a credential broker, and an auditor written by different parties, in different languages, must agree @@ -15,6 +23,10 @@ authoritative test vectors generated by the reference implementation ## 1. Conventions +delego is a **deterministic pre-action authorization layer for AI agents**: it +decides `allow` / `needs_approval` / `deny` for each proposed action, with no +language model in the decision path, before any credential is used. + The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHOULD**, **SHOULD NOT**, **MAY**, and **OPTIONAL** are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and @@ -31,6 +43,18 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHOULD**, | **Service** | The upstream API the action targets. | | **Auditor** | Verifies the audit chain after the fact. | +**PDP/PEP split.** delego separates the *decision* from the *enforcement*. The +**Authorizer is the Policy Decision Point (PDP)**: it renders the deterministic +verdict and never holds a credential. The **Broker is the Policy Enforcement +Point (PEP)**: it is the *only* component that touches a credential, and it +injects one only after the PDP has authorized that exact action. Because the +credential is unreachable except through the PEP, and the PEP acts only on a PDP +verdict bound to the action's fingerprint (§4, §9), an Agent that is redirected +cannot reach a credential on its own authority. delego's `allow` / +`needs_approval` / `deny` is the deterministic-authorization counterpart of the +`ALLOW` / `REQUIRE_APPROVAL` / `DENY` verdict triad in emerging agent +authorization fabrics (§9.3). + **Trust boundary.** The decision that gates a credential is made by the Authorizer in deterministic code. An Authorizer **MUST NOT** consult a language model, or any non-deterministic input, when rendering a decision (§6). The Agent @@ -50,11 +74,13 @@ 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** | **draft — not yet in reference** | Query-string-bound fingerprint (§4.2); signed authorization token (§9, §9.1). | +| **0.3** | **draft** | Two distinct tracks. **Additive hardening clauses** — the §4.2 Broker query obligation, 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) — 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 deferred breaking item** — folding the URL query string into the `action_fingerprint` preimage (§4.2) — changes the preimage and is therefore **DEFERRED** to a future draft; it is described as a forward-looking note only and is **not** part of 0.3. | This document is at **0.3 (draft)**; the reference implements **0.2**. Clauses introduced after 0.1 are tagged inline — *(since 0.2)* for reference-backed -behaviour, *(0.3, draft)* for the not-yet-implemented frontier. +behaviour, *(0.3, draft — additive)* for the additive-hardening frontier that may +be layered on the 0.2 preimage, and *(0.3, draft — deferred, breaking)* for the +query-fold that changes the preimage and is held for a later draft. ## 3. Canonicalization (NORMATIVE) @@ -131,7 +157,7 @@ order {…, recipient:"attacker"} (one param added) See [`ctk/vectors/hashing.json`](ctk/vectors/hashing.json) for the full set. -### 4.2 Query string *(0.3, draft — not yet in reference)* +### 4.2 Query string Through 0.2 the fingerprint covers `method`, `host`, `path`, and the agent-declared `params`; the URL's **query string is not part of the @@ -139,11 +165,26 @@ fingerprint**. Two requests that differ only in their query (e.g. `/orders?to=me` vs `/orders?to=attacker`) therefore share one fingerprint — a confused-deputy gap if decision-relevant data rides the query. -Until 0.3, an Authorizer and Broker **MUST** ensure no decision-relevant value is -taken from the query string: a Broker **MUST NOT** forward query parameters that -are not represented in `params`. +**Broker query obligation (NORMATIVE)** *(0.3, draft — additive)*. Because the +query is outside the fingerprint preimage, the Broker (PEP) **MUST** neutralise +it at enforcement time rather than trusting it: + +- A Broker **MUST NOT** transmit any query parameter to the Service that is not + derivable from the authorized action. +- A Broker **MUST** reconstruct the outgoing URL from the `host`, `path`, and + `params` as authorized, and **MUST NOT** forward the agent-supplied raw URL's + query string verbatim. +- A Broker that cannot reconstruct the URL from the authorized action **MUST** + refuse the action. + +This is an obligation on the *enforcement* point and changes no hashed or signed +bytes; it is therefore additive on the 0.2 preimage and **MAY** be adopted by a +0.2 implementation. See the §10 conformance line. -In **0.3** the fingerprint folds the canonicalized query into its preimage: +**Deferred: folding the query into the fingerprint (breaking)** *(0.3, draft — +deferred, breaking)*. A stronger defense is to make the query part of the +`action_fingerprint` itself, so the *decision* — not merely the Broker — is bound +to the query: ``` query = the URL query parsed into a list of [name, value] pairs, @@ -157,9 +198,11 @@ action_fingerprint = sha256(canonical_json({ })) ``` -This changes the fingerprint preimage and is therefore a **breaking** change -(§8.2); it bumps the protocol version and ships with updated `hashing` CTK vectors -when the reference implements it. +This **changes the fingerprint preimage** and is therefore a **breaking** change +(§8.2): it would bump the protocol version and ship with regenerated `hashing` +CTK vectors. It is intentionally **DEFERRED** to a later draft and is **not** part +of 0.3; the Broker query obligation above is the interim, additive defense. This +note is forward-looking only. ## 5. Policy @@ -200,10 +243,38 @@ an empty match matches nothing. time; an action parked for approval is counted only once released, so several actions parked before any release MAY each execute even past `max`. + **Consistency class (NORMATIVE)** *(0.3, draft — additive)*. The `rate_limit` + count is **exact** only under a **serialized single-writer** audit ledger: + every counted receipt is committed before the next decision reads the window. + Under **concurrent writers** the count is **best-effort** — two decisions may + read the window before either commits and both pass, so the effective cap can + exceed `max` by the number of in-flight writers. An implementation **MUST** + document which consistency class it provides for `rate_limit` (see §11 + Concurrency); a deployment that needs an exact cap **MUST** run a serialized + single-writer ledger. + > **Glob note (v0.1).** Path globbing is coarse: `**` and `*` are treated alike > and both span `/`. Per-segment globbing is a planned refinement; a conformant > v0.1 implementation MUST reproduce the coarse behavior. +### 5.1 Policy validation (NORMATIVE) *(0.3, draft — additive)* + +Before evaluating any action, an Authorizer **MUST** validate its policy +document against [`schema/policy.json`](schema/policy.json) and **fail closed** on +an invalid policy: it **MUST** refuse to render decisions (equivalently, treat +every action as `deny`) rather than load a policy it cannot fully interpret. + +In particular, the policy schema sets `additionalProperties: false` on `match` +and on `constraints`, so an Authorizer **MUST** reject a policy that carries an +**unknown `match` key or unknown `constraints` key**. An Authorizer **MUST NOT** +silently ignore or skip an unrecognised constraint and proceed: an unknown +constraint is a constraint the Authorizer cannot enforce, and skipping it would +turn an intended restriction into a silent `allow`. Rejecting the policy is the +fail-closed behaviour. + +This is a load-time obligation; it changes no hashed or signed bytes and is +therefore additive on the 0.2 preimage. See the §10 conformance line. + ## 6. Decision (NORMATIVE) Evaluation order is fixed: @@ -272,6 +343,42 @@ Every refusal in this section **MUST** be recorded as an `execution`/`deny` receipt (§8). Authoritative vectors: [`ctk/vectors/resolve.json`](ctk/vectors/resolve.json). +### 7.1 Authorization properties (NORMATIVE) *(0.3, draft — additive)* + +The §7 guards above and the §8 audit chain together provide four testable +authorization invariants. A conformant Authorizer **MUST** uphold all four; they +are properties of the existing 0.2 behaviour stated explicitly, change no hashed +or signed bytes, and are additive on the 0.2 preimage. + +- **(P1) Exact-action binding / substitution-proof.** An authorization is bound + to one `action_fingerprint`. If the presented action's fingerprint does **not** + equal the bound one, the outcome **MUST** be `deny`, **regardless of the + approval's status** and **checked before status** (§7 step 1). An action + substituted under an approval — even an `approved` one — is refused. + +- **(P2) Exact-intent binding.** An authorization is bound to one `intent_hash` + (§7 step 2). If the presented action's `intent_hash` does not equal the bound + one, the outcome **MUST** be `deny` — an authorization granted for one stated + instruction cannot be re-pointed at another. + +- **(P3) Single-use.** An approved action is releasable **at most once**. The + transition `approved → consumed` **MUST** be committed **before** the Broker is + permitted to release the credential, **MUST** be atomic, and is **terminal**: + `consumed` and `denied` are end states that **MUST NOT** be reopened. A + replayed release of a `consumed` approval, and any release of a `denied` + approval, **MUST** be `deny`. + +- **(P4) No cross-approval reuse.** An authorization for action A **MUST NOT** + authorize action B. Equivalently, P1+P2 hold per approval: presenting a + different action (different fingerprint) or a different instruction (different + intent) under approval A is `deny`, and there is no aggregate "approved" state + an Agent can draw on across distinct actions. + +Authoritative vectors for P1 and P3 are in +[`ctk/vectors/resolve.json`](ctk/vectors/resolve.json) (the fingerprint-mismatch +case refused even when `status = approved`, and the denied-approval case that is +not resurrected). See the §10 conformance line. + ## 8. Receipt & audit chain (NORMATIVE) Every decision and execution is recorded as a **receipt**. Receipts form an @@ -319,10 +426,9 @@ most recent receipts leaves a shorter but internally consistent prefix that verifies clean — the chain commits each receipt to its predecessor, not to the *length* of the log. Likewise, an Auditor that only holds the Authorizer's public key cannot detect this, and an attacker who holds the (local) signing key can -re-sign an arbitrary rewrite. To detect truncation or rollback, the head **MUST** -be anchored outside the log: persist the latest `(seq, entry_hash)` somewhere the -writer can't unilaterally rewrite (a remote store, a second host, a transparency -log) and reject a chain whose last receipt doesn't match it. High-assurance +re-sign an arbitrary rewrite. The required defense is **head-anchoring**, defined +normatively in **§8.3**: persist the latest `(seq, entry_hash)` outside the log +and reject a chain whose last receipt doesn't match it. High-assurance deployments **SHOULD** also keep the signing key in an HSM/KMS. *(since 0.2)* A **structurally invalid** receipt — one not parseable as JSON, or @@ -342,17 +448,52 @@ The set of payload fields is part of the wire format. Any change to it is a **breaking** change: it MUST bump this specification's version and the receipt `schema` version together, or previously-signed chains stop verifying. -## 9. Authorization Token (NORMATIVE) *(0.3, draft — not yet in reference)* +### 8.3 Head-anchoring (NORMATIVE) *(0.3, draft — additive)* + +Head-anchoring is the **REQUIRED** defense against truncation and rollback (the +§8.1 caveat). It changes no hashed or signed bytes — it adds a small commitment +*about* the chain — and is therefore additive on the 0.2 preimage. + +**The head anchor** is the pair `(seq, entry_hash)` of the chain's most recent +receipt, persisted **outside the log** in a store the chain's writer cannot +unilaterally rewrite: a remote store, a second host, or a transparency log. The +anchor is advanced as the chain grows. + +- An Authorizer that advertises **rollback-resistance** **MUST** maintain a head + anchor: after committing a receipt at `seq = n` with `entry_hash = h`, it + **MUST** update the external anchor to `(n, h)` before treating that receipt as + durable. +- An Auditor that **holds an anchor** `(seq, entry_hash)` **MUST** reject a chain + whose last receipt does not match the anchor — either a different `entry_hash` + at that `seq`, or a chain shorter than `seq` (a truncated chain whose head is + below the anchored `seq`). A chain that matches the anchor and passes §8.1 is + valid through the anchored head. +- An Auditor that holds **no anchor** **MUST** report that **truncation cannot be + ruled out** rather than returning an unqualified "valid": absent an external + commitment to the head, a clean §8.1 walk proves only internal consistency of + the prefix it was given, not that the prefix is complete. + +See the §10 conformance line. + +## 9. Authorization Token (OPTIONAL PROFILE) *(0.3, draft — not yet in reference)* > **Status: draft, not yet implemented in the reference.** This section defines -> the 0.3 protocol that closes the gap where a Broker would inject a credential -> for *any* in-scope request. It converges with +> an **optional profile** layered on the 0.2 preimage. The token is **not** the +> protocol's load-bearing control — the load-bearing controls are the +> deterministic decision (§6), the fingerprint/intent binding and single-use +> approval (§7, §7.1), the audit chain (§8), and the PDP/PEP split (§2) in which +> only the Broker (PEP) ever touches a credential. The token is a way to make the +> PDP→PEP authorization **portable and verifiable across a process or network +> boundary** when the Broker is separated from the Authorizer; a deployment in +> which the PEP can consult the PDP directly need not use it. It converges with > [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) (token exchange). When the Authorizer renders `allow` — or releases a human-approved action — it **MAY** mint a short-lived **authorization token** and return it with the decision. A Broker that requires tokens **MUST NOT** inject a credential without -a valid one. +a valid one. A token **MUST** be minted only for an `allow`, or for the release +of an `approved` approval; it **MUST NOT** be minted for a `needs_approval`, +`deny`, `denied`, or already-`consumed` outcome. The token is a compact **JWS** ([RFC 7515](https://www.rfc-editor.org/rfc/rfc7515)) / **JWT** ([RFC 7519](https://www.rfc-editor.org/rfc/rfc7519)) with @@ -363,13 +504,15 @@ Header: `{ "alg": "EdDSA", "typ": "JWT" }`. Claims: | Claim | Meaning | |-------|---------| | `iss` | Authorizer identifier. | -| `aud` | Intended Broker/service identifier. | +| `aud` | Intended Broker/service identifier. A Broker **MUST** require an **exact** match against its own identifier (no wildcard, no prefix). | | `iat` | Issued-at (epoch seconds). | -| `exp` | Expiry; TTL **SHOULD** be ≤ 60 s. | -| `jti` | Unique token id (replay protection). | +| `exp` | Expiry; TTL **MUST** be ≤ 60 s after `iat`. | +| `jti` | Unique token id (replay protection); an Authorizer **MUST** make it unique per minting. | +| `cns` | **Consumption nonce.** Binds the token to **one** consumption event, so the token inherits the single-use property of the authorization it carries (§7.1 P3): one `cns` authorizes one credential release. A Broker **MUST** treat `cns` as single-use and refuse a second token (or a replay) bearing a `cns` it has already consumed. | | `fpr` | The authorized `action_fingerprint` (§4). | | `iht` | The `intent_hash` (§4). | | `apr` | The `approval_id`, if a human approved (else omitted). | +| `sub` | OPTIONAL subject — the agent/session identity the authorization was issued to, for composition with an agent-identity layer (§9.3). | | `pol` | `{ "version": , "rule": }`. | ### 9.1 Broker verification (the crux) @@ -377,18 +520,47 @@ Header: `{ "alg": "EdDSA", "typ": "JWT" }`. Claims: Before injecting a credential, a token-requiring Broker **MUST**: 1. verify the JWS signature against the Authorizer's public key; -2. check `exp` is in the future and `aud` matches itself (allowing small clock skew); -3. reject a previously-seen `jti` within the token's validity window; -4. **recompute the `action_fingerprint` of the request it is about to send and +2. check `exp` is in the future and `aud` matches **its own identifier exactly** + (allowing only small, bounded clock skew on `exp`/`iat`); +3. reject a previously-seen `jti` within the token's validity window — a Broker + **MUST** retain each accepted `jti` until at least the token's `exp`; +4. reject a previously-consumed `cns` — one consumption nonce releases at most + one credential (§7.1 P3); +5. **recompute the `action_fingerprint` of the request it is about to send and require it equals `fpr`.** -Step 4 is the point of the token: it binds the credential injection to *that +Step 5 is the point of the token: it binds the credential injection to *that exact action*. A token minted for action A cannot release action B, even if both are in policy scope. See [`examples/authorization-token.md`](examples/authorization-token.md) and [`schema/authorization-token.json`](schema/authorization-token.json). +### 9.2 Revocation + +Token lifetime is governed primarily by **expiry**: the ≤ 60 s `exp` is the +default revocation mechanism, and a deployment **SHOULD** rely on short TTLs +rather than stateful revocation wherever possible. An Authorizer **MAY** +additionally publish a **revocation list** of `jti` (or `cns`) values that a +token-requiring Broker consults before injecting a credential; a Broker that +consults such a list **MUST** treat a listed token as invalid. Because tokens are +short-lived, a revocation entry need only be retained until the corresponding +`exp`. + +### 9.3 Interoperability + +The authorization token is a **PDP→PEP decision artifact**: a signed, portable +statement of a deterministic verdict that a separated PEP can verify without +re-consulting the PDP. It is designed to **compose with** an agent-identity / +passport layer rather than replace one: the token answers *"is this exact action +authorized right now?"*, while a passport (e.g. an Open Agent Passport-style +credential) answers *"which agent/principal is acting?"* — the OPTIONAL `sub` +claim (§9) carries that identity into the token so the two bind together. The +token's verdict maps onto the `ALLOW` / `REQUIRE_APPROVAL` / `DENY` triad of +emerging agent authorization fabrics (§2): a minted token represents `ALLOW` (or a +released `REQUIRE_APPROVAL`), and the *absence* of a token for a `needs_approval` +or `deny` outcome represents `REQUIRE_APPROVAL` / `DENY`. + ## 10. Conformance An implementation declares the highest protocol version (§2.1) it implements, and @@ -409,29 +581,60 @@ CTK vectors. The reference implements **0.2**. - An **Auditor** MUST treat a malformed or partial receipt as a verification failure, not an error (§8.1). -**0.3** (draft — not yet in reference) -- An **Authorizer** binds the query string into the fingerprint (§4.2) and MAY - mint authorization tokens (§9). -- A **Broker** that participates in §9 MUST implement §9.1. +**0.3 — additive hardening** (additive on the 0.2 preimage; MAY be adopted by a +0.2 implementation) +- A **Broker** MUST satisfy the §4.2 query obligation: it MUST reconstruct the + outgoing URL from the authorized `host`/`path`/`params`, MUST NOT forward the + agent-supplied query verbatim or any query parameter not derivable from the + authorized action, and MUST refuse an action whose URL it cannot reconstruct. +- An **Authorizer** MUST validate its policy against `schema/policy.json` and fail + closed on an invalid policy, including rejecting unknown `match`/`constraints` + keys, and MUST NOT skip an unknown constraint (§5.1). +- An **Authorizer** MUST uphold the authorization properties P1–P4 (§7.1) and + reproduce the CTK `resolve` vectors for P1 (fingerprint mismatch refused even + when `status = approved`) and P3 (a denied approval is not resurrected). +- An **Authorizer** advertising rollback-resistance MUST maintain a head anchor; + an **Auditor** MUST reject a chain that does not match a held anchor and MUST + report that truncation cannot be ruled out when it holds no anchor (§8.3). +- An **Authorizer/Broker** that participates in the authorization-token profile + MUST implement §9 / §9.1 (exact `aud`, ≤ 60 s `exp`, unique `jti`, single-use + `cns`, fingerprint re-check). +- An implementation MUST document the `rate_limit` consistency class it provides + (§5, §11). + +**0.3 — deferred (breaking)** +- Folding the URL query into the `action_fingerprint` preimage (§4.2) is a + **breaking** change held for a future draft; it is **not** required by 0.3 and + has no CTK vector wired against the current reference. ## 11. Security considerations -- **Confused deputy.** The protocol assumes the Agent can be redirected; §7 and - §9 ensure a redirected action cannot ride an approval or token issued for a +- **'Firewall' is an analogy, not the model.** delego is an **action-authorization + layer — a Policy Decision Point (PDP)** that renders `allow` / `needs_approval` + / `deny` for proposed agent actions; it is **not** a network firewall. Where + this document or related material reaches for "firewall", it is a loose analogy + for "something that sits in front and gates", not a claim about packet + filtering or network topology. +- **Confused deputy.** The protocol assumes the Agent can be redirected; §7, §7.1, + and §9 ensure a redirected action cannot ride an approval or token issued for a different action. - **No LLM in the decision path** (§2, §6) — an injection cannot argue its way past a deterministic evaluator. - **Fail closed** — unmatched ⇒ `default` (`deny`); a matched-but-failed - constraint ⇒ `deny`. -- **Token replay** — short `exp` plus `jti` tracking; tokens are bearer - credentials and MUST be transmitted over confidential channels. + constraint ⇒ `deny`; an invalid or uninterpretable policy ⇒ refuse to decide + (§5.1). +- **Token replay** — short `exp`, `jti` tracking, and single-use `cns` (§9, §9.1); + tokens are bearer credentials and MUST be transmitted over confidential + channels. - **Key management** — the Authorizer's Ed25519 signing key is the root of audit trust; its private half MUST NOT be exfiltrable. Compromise lets an attacker - forge a chain. + forge a chain. Truncation/rollback is defended by head-anchoring (§8.3). - **Concurrency** — a single logical chain has a single writer at a time; - concurrent appenders can fork the chain. The reference v0.1 uses file-backed - state and is **not** safe under concurrent writers (a single-writer daemon is - planned). + concurrent appenders can fork the chain. This is also why `rate_limit` is + **exact only under a serialized single-writer ledger and best-effort under + concurrent writers** (§5): an implementation MUST document which it provides. + 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. ## 12. References @@ -439,4 +642,5 @@ CTK vectors. The reference implements **0.2**. - RFC 2119 / RFC 8174 — requirement keywords. - RFC 7515 (JWS), RFC 7519 (JWT), RFC 8037 (EdDSA in JOSE). - RFC 8693 — OAuth 2.0 Token Exchange. +- OWASP Agentic Security Initiative — ASI03 (Excessive Agency). - OWASP / Cloud Security Alliance — the confused-deputy problem in AI agents.