diff --git a/docs/proposals/cascade-revocation.mdx b/docs/proposals/cascade-revocation.mdx new file mode 100644 index 0000000..68105da --- /dev/null +++ b/docs/proposals/cascade-revocation.mdx @@ -0,0 +1,138 @@ +--- +title: "Cascade Revocation for Agent Delegation Chains" +description: "Proposal for propagating revocation through agent delegation trees" +--- + +## Problem + +When agents delegate authority to other agents, revocation must propagate. If Agent A delegates to Agent B, and B sub-delegates to C, revoking A→B must also invalidate C's authority. Without cascade revocation, revoking a compromised agent leaves its downstream delegations active. + +In multi-agent systems where agents autonomously sub-delegate to specialists, a single compromised delegation can create an unbounded tree of active permissions that the human principal cannot recall. + +## Data Structures + +All data structures are defined in [cascade-revocation.schema.json](/spec/schema/cascade-revocation.schema.json). + +The core objects are: + +- **Delegation** — authority granted from one agent to another, with scope, spend limit, depth limit, and expiry +- **RevocationRecord** — signed proof that a delegation was revoked, including cascade count +- **ChainRegistry** — parent-child index populated at delegation creation time (not reconstructed at revocation time) +- **RevocationEvent** — event emitted for each revoked delegation in a cascade + +The `parentDelegationId` field uses `string | null` (not optional). Root delegations set this to `null` explicitly. This aligns with the ChainRegistry.parents map behavior. + +## Algorithm + +### Cascade Revocation + +When a delegation is revoked, all downstream delegations MUST be revoked synchronously. + +``` +cascadeRevoke(delegationId, reason, revokerKey): + 1. Look up delegation in store by delegationId + 2. If already revoked, return (idempotent) + 3. Mark delegation as revoked (revoked=true, revokedAt, reason) + 4. Sign revocation record with revokerKey + 5. Get all child delegationIds from chain registry + 6. For each childId: recursively cascadeRevoke(childId, reason, revokerKey) + 7. Emit revocation event + 8. Return RevocationRecord with cascadeCount +``` + +**Properties:** +- **Synchronous** — all descendants revoked in the same operation. No eventual consistency. +- **Deterministic** — same input always produces the same result. +- **Idempotent** — revoking an already-revoked delegation is a no-op. +- **Total (Transactional)** — no partial cascade. Either all descendants are revoked and their revocation records durably persisted, or none are. Implementations MUST execute steps 3-7 for the entire cascade within a single atomic transaction or equivalent mechanism that guarantees all-or-nothing visibility. In systems without multi-record transactions, implementations MUST document that cascade is best-effort and may leave partial state on failure. + +### Batch Revocation by Agent + +Revoke all delegations granted TO a specific agent, with cascade. + +``` +batchRevokeByAgent(agentId, reason, revokerKey): + 1. Find all delegations where delegateId == agentId + 2. For each: cascadeRevoke(delegation, reason, revokerKey) + 3. Return list of RevocationRecords +``` + +Use case: an agent is compromised. The principal revokes everything granted to that agent. All sub-delegations also die. + +### Chain Validation + +Before any action is permitted, the entire delegation chain back to the principal MUST be validated. + +``` +validateChain(delegationIds[]): + 0. Resolve delegations from registry: + - For each delegationId in delegationIds (in order provided): + - Lookup delegation in registry by delegationId + - If not found: return invalid ("unknown delegation") + - Append to orderedDelegations[] + 1. For each delegation in orderedDelegations: + - If revoked: return invalid + - If expired: return invalid + 2. For each adjacent pair (parent, child) in orderedDelegations: + - If parent.delegationId != child.parentDelegationId: return invalid ("invalid parent reference") + - If parent.delegateId != child.delegatorId: return invalid ("chain break") + 3. Return valid +``` + +## Sub-delegation Constraints + +When an agent sub-delegates, the child MUST be strictly narrower than the parent: + +- **Scope** — child scope MUST be a subset of parent scope. For array-valued scopes: for each `requiredScope` in the child's scope array, there MUST exist some `grantedScope` in the parent's scope array such that `scopeCovers(grantedScope, requiredScope)` is true, where `scopeCovers` operates on individual scope strings. +- **Spend limit** — child MUST NOT exceed parent spend limit. Currency is explicit (ISO 4217) or inherited from protocol default. +- **Depth** — child currentDepth MUST equal parent currentDepth + 1, MUST NOT exceed parent maxDepth +- **Expiry** — child MUST NOT exceed parent expiration + +Violation of any constraint MUST cause sub-delegation to fail. + +## Security Properties + +| Property | Requirement | +|----------|------------| +| No orphan delegations | Every non-root delegation MUST have a traceable parent | +| Idempotent revocation | Double-revoke MUST NOT re-emit events or increment counts | +| Irreversible | Revoked delegations CANNOT be un-revoked | +| Branching | Cascade MUST traverse all branches of multi-child delegations | +| Event propagation | Implementations SHOULD support revocation event subscriptions | + +## Integration with Policy Engines + +Cascade revocation is an identity-layer primitive. Policy engines (such as AIP's AgentPolicy) SHOULD reference delegation chain validity as a precondition for policy evaluation. + +``` +1. Agent presents action request with delegationId +2. Policy engine calls validateChain() on the delegation chain +3. If chain invalid (any link revoked/expired), deny immediately +4. If chain valid, proceed to policy evaluation +``` + +This ensures revocation takes effect immediately without requiring policy engines to maintain their own revocation state. + +## Adversarial Scenarios + +Any conforming implementation MUST mitigate the following: + +| Attack | Mitigation | +|--------|-----------| +| Replay revoked delegation | Chain validation checks revoked flag before any action | +| Sub-delegate after revocation | Sub-delegation checks delegation validity | +| Scope escalation via sub-delegation | Strict scope narrowing enforced | +| Spend limit escalation | Child spend limit capped at parent | +| Depth bomb (unbounded sub-delegation) | maxDepth enforced at sub-delegation time | +| Double-revoke event spam | Idempotent revocation | +| Orphan delegation after parent delete | Cascade revocation before deletion | + +## Scope Resolution + +Cascade revocation depends on correct scope matching. All implementations MUST use a single scope matching function: + +- Exact match: `code` covers `code` +- Hierarchical: `code` covers `code:deploy` +- Universal wildcard: `*` covers everything +- Prefix wildcard: `commerce:*` covers `commerce` and `commerce:checkout` +- No reverse: `code:deploy` does NOT cover `code` diff --git a/spec/schema/cascade-revocation.schema.json b/spec/schema/cascade-revocation.schema.json new file mode 100644 index 0000000..e34fd2f --- /dev/null +++ b/spec/schema/cascade-revocation.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aip.io/schema/proposals/cascade-revocation.schema.json", + "title": "Cascade Revocation", + "description": "Data structures for cascade revocation in agent delegation chains", + "type": "object", + "$defs": { + "Delegation": { + "type": "object", + "description": "A delegation of authority from one agent to another", + "required": ["delegationId", "delegatorId", "delegateId", "scope", "maxDepth", "currentDepth", "expiresAt", "createdAt", "parentDelegationId", "revoked", "signature"], + "additionalProperties": false, + "properties": { + "delegationId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier (UUID v4)" + }, + "delegatorId": { + "type": "string", + "description": "Agent granting authority" + }, + "delegateId": { + "type": "string", + "description": "Agent receiving authority" + }, + "scope": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Permitted action scopes" + }, + "currency": { + "type": "string", + "description": "Delegation currency (ISO 4217 code, e.g. 'USD'). If omitted, protocol default applies" + }, + "spendLimit": { + "type": "number", + "minimum": 0, + "description": "Maximum spend in smallest unit of currency (e.g. cents). If omitted, no spend constraint" + }, + "maxDepth": { + "type": "integer", + "minimum": 0, + "description": "Maximum sub-delegation depth allowed" + }, + "currentDepth": { + "type": "integer", + "minimum": 0, + "description": "Current depth in the chain (0 = root)" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 expiration timestamp" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 creation timestamp" + }, + "parentDelegationId": { + "oneOf": [ + { "type": "string", "format": "uuid" }, + { "type": "null" } + ], + "description": "ID of parent delegation (null for root)" + }, + "revoked": { + "type": "boolean", + "default": false, + "description": "Revocation status" + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 revocation timestamp" + }, + "revokedReason": { + "type": "string", + "description": "Human-readable revocation reason" + }, + "signature": { + "type": "string", + "description": "Ed25519 signature by delegator" + } + } + }, + "RevocationRecord": { + "type": "object", + "description": "Record of a delegation revocation", + "required": ["delegationId", "revokedBy", "revokedAt", "reason", "cascadeCount", "signature"], + "additionalProperties": false, + "properties": { + "delegationId": { + "type": "string", + "format": "uuid", + "description": "Delegation being revoked" + }, + "revokedBy": { + "type": "string", + "description": "Agent performing the revocation" + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp" + }, + "reason": { + "type": "string", + "description": "Revocation reason" + }, + "cascadeCount": { + "type": "integer", + "minimum": 0, + "description": "Number of downstream delegations also revoked" + }, + "signature": { + "type": "string", + "description": "Ed25519 signature by revoker" + } + } + }, + "RevocationEvent": { + "type": "object", + "description": "Event emitted when a delegation is revoked", + "required": ["delegationId", "cascadeDepth", "totalCascaded"], + "properties": { + "delegationId": { + "type": "string", + "format": "uuid" + }, + "cascadeDepth": { + "type": "integer", + "minimum": 0, + "description": "0 for the directly revoked delegation" + }, + "totalCascaded": { + "type": "integer", + "minimum": 0, + "description": "Running count of all revoked in this cascade" + } + } + }, + "ChainRegistry": { + "type": "object", + "description": "Tracks parent-child relationships between delegations. MUST be populated at delegation creation time.", + "required": ["children", "parents"], + "properties": { + "children": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "description": "Maps delegationId to list of child delegationIds" + }, + "parents": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string", "format": "uuid" }, + { "type": "null" } + ] + }, + "description": "Maps delegationId to parent delegationId (null for root)" + } + } + } + } +}