From 6203a12b7bf6cb7ef4b0a18866ba5b85ee38e4b1 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 16:46:48 +0000 Subject: [PATCH 01/24] feat(missions): replace Mission model with spec mission blob + s256 identity Rewrite agent-side Mission to the approved mission blob (approver, agent, approved_at, description, approved_tools, capabilities) with verbatim body bytes and base64url(SHA-256) s256 identity. Add MissionState (active/ terminated), MissionTool, and AAuthCapabilitiesHeader.Union. Includes the missions/PS governance research and implementation plan. Phase 1 of missions/PS governance. --- .../implementation-plan.md | 458 ++++++++++++++++++ .../research.md | 293 +++++++++++ src/AAuth/Agent/AAuthCapabilitiesHeader.cs | 33 ++ src/AAuth/Agent/Mission.cs | 172 +++++-- src/AAuth/Agent/MissionState.cs | 17 + src/AAuth/Agent/MissionTool.cs | 10 + .../HttpSignatures/CapabilitiesHeaderTests.cs | 29 ++ .../Missions/MissionModelTests.cs | 102 ++++ .../Missions/MissionS256Tests.cs | 74 +++ 9 files changed, 1156 insertions(+), 32 deletions(-) create mode 100644 .agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md create mode 100644 .agent/plans/2026-06-05-missions-ps-governance/research.md create mode 100644 src/AAuth/Agent/MissionState.cs create mode 100644 src/AAuth/Agent/MissionTool.cs create mode 100644 tests/AAuth.Conformance/Missions/MissionModelTests.cs create mode 100644 tests/AAuth.Conformance/Missions/MissionS256Tests.cs diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md new file mode 100644 index 0000000..c41e422 --- /dev/null +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -0,0 +1,458 @@ +# Missions & PS Governance — Implementation Plan + +## Overview + +Bring the .NET SDK, samples, and docs into alignment with the AAuth spec's +**mission** model and the **Person Server as the contextual policy evaluation +point**. Fix the divergent `Mission` model, add the mission cryptographic +binding through the token chain, implement the PS governance endpoints +(client + minimal server seams), then update samples and docs to showcase the +end-to-end mission flow, with a final multi-subagent review. + +See [research.md](research.md) for the full spec model and gap inventory +(G1–G20). Every phase below cites the governing spec section in +`aauth-spec/draft-hardt-oauth-aauth-protocol.md`. + +## Context + +- **Spec:** `draft-hardt-oauth-aauth-protocol` — §Missions, §Mission Approval, + §Mission Management, §Mission Status Errors, §Resource Token, §Auth Token, + §Authorization Endpoint Request (signed `aauth-mission`), §PS Token Endpoint, + §Clarification Chat, §Permission/Audit/Interaction Endpoints, §Person Server + Metadata, §Policy Evaluation Points, §Why Missions Are Not a Policy Language. +- **Branch:** TBD. +- **Sequencing:** Phases 1→5 are SDK; Phase 6 samples; Phase 7 docs; Phase 8 review. + Samples (6) and docs (7) are intentionally separate phases per the agreed + workflow. + +## Cross-Cutting Decisions + +The spec and SDK are both still in **draft** and backward compatibility is **not** +a concern. Breaking changes to existing types, method signatures, DI registration, +and the HTTP-signature covered-components contract are acceptable; callers are +updated in place. This resolves prior open questions Q2 and Q3 directly: + +- **D1 — `Mission` replacement (resolved):** Replace `Mission` in place with the + spec blob. No rename, no `[Obsolete]` shim, no dual model — update all callers. +- **D2 — `aauth-mission` signing (resolved):** Auto-cover `aauth-mission` in + `AAuthSigningHandler` when the header is present. No explicit + `AdditionalComponentsKey` fallback path is required. +- **D3 — Server governance depth** (research Q4): SDK ships DTOs + serialization + + minimal endpoint mappers + store/relay interfaces; full policy stays in + MockPersonServer. (Scope choice, unaffected by compat.) +- **D4 — Verbatim mission bytes** (research Q1): model retains raw approval body + bytes for `s256`; no re-serialization. (Correctness requirement for `s256`.) + +--- + +## Phase 1 — Mission model & `s256` identity + +**Goal:** Replace the stale `Mission` model with the spec mission blob, store the +verbatim approval bytes, and compute/verify `s256`. Fixes G1–G4, G17. + +**Spec:** §Mission Approval (blob fields; `s256` = base64url(SHA-256(exact body +bytes)), store bytes "no re-serialization"); §Mission Management (states +`active`/`terminated`); §Mission Approval (`capabilities` union into +`AAuth-Capabilities`). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Agent/Mission.cs` | **Rewrite** — spec blob fields + raw bytes + `s256` | +| `src/AAuth/Agent/MissionState.cs` | **New** — `active`/`terminated` | +| `src/AAuth/Agent/MissionTool.cs` | **New** — `{name, description}` record | +| `src/AAuth/Crypto/` (existing hash util) or `Agent/Mission.cs` | **Modify/Use** — SHA-256 + base64url over raw bytes | +| `src/AAuth/Agent/AAuthCapabilitiesHeader.cs` | **Modify** — `Union(missionCapabilities, agentCapabilities)` | +| `tests/AAuth.Conformance/Missions/MissionModelTests.cs` | **New** | +| `tests/AAuth.Conformance/Missions/MissionS256Tests.cs` | **New** | + +### API Surface (illustrative) + +```csharp +public sealed class Mission +{ + public required string Approver { get; init; } + public required string Agent { get; init; } + public required DateTimeOffset ApprovedAt { get; init; } + public required string Description { get; init; } // Markdown + public IReadOnlyList ApprovedTools { get; init; } + public IReadOnlyList Capabilities { get; init; } + public required string S256 { get; init; } // computed identity + public ReadOnlyMemory RawBytes { get; } // verbatim approval body + + public static Mission FromApprovalBytes(ReadOnlySpan body); // parse + compute s256 + public bool VerifyS256(string expected); +} +``` + +### Implementation Decisions + +- D1: replace `Mission` in place; update all callers (no shim). +- D4 byte source: parse from the `mission_endpoint` response body bytes. + +### Definition of Done + +- [x] `Mission` exposes spec blob fields; non-spec fields removed. +- [x] States limited to `active`/`terminated` (§Mission Management). +- [x] `s256` computed as base64url(SHA-256(raw bytes)); raw bytes stored verbatim. +- [x] `VerifyS256` round-trips against a known-good blob fixture. +- [x] `AAuthCapabilitiesHeader.Union` merges mission ∪ agent capabilities (deduped). +- [x] Model + `s256` tests pass; no re-serialization in the hash path. +- [x] All existing callers updated to the new model (no shim). + +--- + +## Phase 2 — Mission binding through the token chain + +**Goal:** Carry `{approver, s256}` as the `mission` claim in resource and auth +tokens, surface it on verification, and ensure `aauth-mission` is covered by the +HTTP signature. Fixes G9–G12. + +**Spec:** §Resource Token Structure (`mission` claim when `AAuth-Mission` +present; `agent_jkt`); §Resource Token Verification step 7; §Auth Token +Structure (`mission` claim); §Auth Token Verification; §Authorization Endpoint +Request (~L619, add `aauth-mission` to signed components). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Tokens/ResourceTokenBuilder.cs` | **Modify** — first-class `Mission` ({approver,s256}) claim | +| `src/AAuth/Tokens/AuthTokenBuilder.cs` | **Modify** — first-class `Mission` claim | +| `src/AAuth/Tokens/TokenVerifier.cs` | **Modify** — surface `mission` on auth-token verify result | +| `src/AAuth/Tokens/VerifiedToken*.cs` | **Modify** — expose parsed `mission` | +| `src/AAuth/HttpSig/AAuthSigningHandler.cs` | **Modify** — auto-cover `aauth-mission` when header present (D2) | +| `tests/AAuth.Conformance/Tokens/MissionClaimTests.cs` | **New** | +| `tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs` | **New** | + +### Implementation Decisions + +- D2 (resolved): auto-cover `aauth-mission` in `AAuthSigningHandler` when the + header is present; signing method signatures may change as needed. + +### Definition of Done + +- [ ] `ResourceTokenBuilder` emits `mission` claim when a mission is present (§Resource Token). +- [ ] `AuthTokenBuilder` emits `mission` claim when a mission is present (§Auth Token). +- [ ] `TokenVerifier` exposes the verified `mission` claim on auth tokens. +- [ ] When `AAuth-Mission` header present, `aauth-mission` appears in + `Signature-Input` covered components (§Authorization Endpoint Request). +- [ ] Covered-components contract updated; token/signature tests adjusted to match. + +--- + +## Phase 3 — PS token-request params, clarification chat, mission errors + +**Goal:** Complete the PS token endpoint client surface: missing request +parameters, the clarification chat loop, and `mission_terminated` handling. +Fixes G13, G14, G16. + +**Spec:** §Agent Token Request (params `justification`, `login_hint`, `tenant`, +`domain_hint`, `platform`, `device`); §Clarification Chat (`requirement= +clarification`; agent responses: `clarification_response` POST / updated +`resource_token` POST / `DELETE` cancel; round limit; sanitization is PS-side); +§Mission Status Errors (`403 mission_terminated`). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — add `Justification`, `LoginHint`, `Tenant`, `DomainHint`, `Platform`, `Device` | +| `src/AAuth/Agent/TokenExchangeClient.cs` | **Modify** — emit new params; detect `requirement=clarification` | +| `src/AAuth/Agent/DeferredPoller.cs` | **Modify** — allow POST/DELETE to pending URL | +| `src/AAuth/Agent/ClarificationExchange.cs` | **New** — respond / update / cancel actions + round tracking | +| `src/AAuth/Headers/ClarificationRequirement.cs` | **New** — parse `{clarification, timeout?, options?}` | +| `src/AAuth/Errors/TokenError.cs` | **Modify** — add `mission_terminated` | +| `src/AAuth/Errors/AAuthMissionTerminatedException.cs` | **New** | +| `tests/AAuth.Conformance/Missions/ClarificationChatTests.cs` | **New** | +| `tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs` | **New** | + +### Definition of Done + +- [ ] All six token-request params serialized into the POST body (§Agent Token Request). +- [ ] `requirement=clarification` parsed into a typed model (question/timeout/options). +- [ ] Agent can `clarification_response` POST, updated-`resource_token` POST, and + `DELETE`-cancel against the pending URL (§Agent Response to Clarification). +- [ ] Clarification round limit enforced (default 5) (§Clarification Limits). +- [ ] `403 mission_terminated` → `AAuthMissionTerminatedException` across PS calls + (§Mission Status Errors). +- [ ] New tests pass; existing deferred/interaction tests unaffected. + +--- + +## Phase 4 — PS governance clients + metadata discovery + +**Goal:** Add agent-side clients for `mission_endpoint`, `permission_endpoint`, +`audit_endpoint`, `interaction_endpoint`, with DTOs, and parse the missing +metadata endpoints. Fixes G5–G8, G15, G19. + +**Spec:** §Mission Creation; §Permission Endpoint; §Audit Endpoint; +§Interaction Endpoint; §Person Server Metadata. Audit **requires** a mission; +permission/interaction optional. Reuse the deferred/`202` loop from Phase 3. + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Discovery/ServerMetadata.cs` | **Modify** — parse `permission_endpoint`, `audit_endpoint` | +| `src/AAuth/Agent/Governance/MissionClient.cs` | **New** — propose/approve (handles `202` review) | +| `src/AAuth/Agent/Governance/PermissionClient.cs` | **New** — `{action,description?,parameters?,mission?}` → granted/denied | +| `src/AAuth/Agent/Governance/AuditClient.cs` | **New** — fire-and-forget `201` (requires mission) | +| `src/AAuth/Agent/Governance/InteractionClient.cs` | **New** — interaction/payment/question/completion | +| `src/AAuth/Agent/Governance/*Request.cs` / `*Response.cs` | **New** — DTOs | +| `tests/AAuth.Conformance/Missions/GovernanceClientTests.cs` | **New** | + +### Definition of Done + +- [ ] `ServerMetadata` parses all four governance endpoints (§Person Server Metadata). +- [ ] `MissionClient.ProposeAsync` returns an approved `Mission` (verifies `s256`, + handles `202` review/clarification) (§Mission Creation). +- [ ] `PermissionClient.RequestAsync` returns granted/denied, honoring + `approved_tools` short-circuit and deferred responses (§Permission Endpoint). +- [ ] `AuditClient.RecordAsync` is fire-and-forget, requires a mission, expects + `201` (§Audit Endpoint). +- [ ] `InteractionClient` supports all four `type` values incl. `completion` + terminate/continue (§Interaction Endpoint). +- [ ] `mission_terminated` surfaces from each client (Phase 3 exception). +- [ ] Client tests pass against a stub PS. + +--- + +## Phase 5 — PS server-side governance seams + mission log + +**Goal:** Provide minimal SDK primitives so a PS (e.g. MockPersonServer) can +serve the governance endpoints without hand-rolling parsing: request parsing, +response helpers, store/relay interfaces, and a mission-log seam. Fixes G18, G20 +(per decision D3 — thin seams, not a full PS). + +**Spec:** §PS Governance Endpoints; §Mission Log; §Mission Status Errors; +§Policy Evaluation Points (PS evaluates against mission intent + log). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Server/Governance/IMissionStore.cs` | **New** — persist blob bytes + state | +| `src/AAuth/Server/Governance/IPermissionDecider.cs` | **New** | +| `src/AAuth/Server/Governance/IAuditSink.cs` | **New** | +| `src/AAuth/Server/Governance/IInteractionRelay.cs` | **New** | +| `src/AAuth/Server/Governance/IMissionLog.cs` | **New** — ordered append; read | +| `src/AAuth/Server/Governance/GovernanceEndpoints.cs` | **New** — minimal request parse + `mission_terminated` helper (optional minimal-API mappers) | +| `src/AAuth/DependencyInjection/*` | **Modify** — register governance seams | +| `tests/AAuth.Conformance/Missions/GovernanceServerTests.cs` | **New** | + +### Implementation Decisions + +- D3 boundary: SDK = DTO parse + helpers + interfaces; policy/UI in mock. +- `IPermissionDecider` returns a typed decision **with a reason** (in-scope / + prior consent / `approved_tools` / out-of-scope → prompt) so a PS can both act + on it and surface it to UIs; the SDK provides the inputs + reason enum, the PS + owns the policy (§Agent Token Request L385/L784/L828, §Permission L1017). + +### Definition of Done + +- [ ] Request parsers for permission/audit/interaction/mission-create map to DTOs. +- [ ] `mission_terminated` helper emits spec `403` body (§Mission Status Errors). +- [ ] `IMissionStore` stores verbatim blob bytes + `active`/`terminated` state. +- [ ] `IMissionLog` appends token/permission/audit/interaction/clarification + entries in order, and supports a prior-consent read keyed by + `(s256, resource, scope)` (§Mission Log, §Agent Token Request L784). +- [ ] `IPermissionDecider` is invoked with mission + log context for the consent + decision (§Person Server L385). +- [ ] DI registration wires the seams. +- [ ] Server-seam tests pass. + +--- + +## Phase 6 — Samples + +**Goal:** Showcase the end-to-end mission flow across actors. Update +MockPersonServer to a real governance PS and add an agent-side mission demo. + +**Spec:** §Missions; §PS Governance Endpoints; §Call Chaining (mission context); +§Agent Token Request (consent decision: L385, L784, L828); §Permission Endpoint +(`approved_tools`: L1017); the three worked flows in research (single-resource, +multi-resource + permission/audit/interaction, multi-hop chaining). + +Missions are an **optional, orthogonal** governance layer (§Overview L141, L201; +§Missions L397). Existing samples demonstrate valid no-mission flows and are +**not** spec-incorrect; the mission flow is **added alongside** them, not a +rewrite of existing flows. + +### Files + +| File | Action | +|------|--------| +| `samples/MockPersonServer/Program.cs` | **Modify** — serve `mission_endpoint`, `permission_endpoint`, `audit_endpoint`, `interaction_endpoint`; embed `mission` claim in issued auth tokens; compute `s256`; implement the three-gate consent decision and record a decision reason (in-scope / prior consent / `approved_tools` / out-of-scope) in the mission log so samples can display it; expose a minimal "terminate mission" demo hook; maintain mission log via Phase 5 seams | +| `samples/MockPersonServer/README.md` | **Modify** | +| `samples/MissionAgent/` | **New** — dedicated CLI agent: propose mission → operate under it → permission + audit + interaction → completion | +| `samples/Orchestrator/Program.cs` | **Modify** — show mission-governed downstream hop (call chaining) | +| `samples/SampleApp/Components/Pages/Mission.razor` | **New** — golden one-page mission example; visualizes all three consent gates, each labelled **prompt** vs **silent (in scope)** | +| `samples/SampleApp/Components/Pages/Home.razor` | **Modify** — add mission page link/card | +| `samples/GuidedTour/TourOptions.cs` | **Modify** — add `TourMode.Mission` | +| `samples/GuidedTour/TourSession.cs` | **Modify** — multi-step mission plan that drives each gate through **both** outcomes: (1) mission approval prompt; (2) in-scope token request that resolves **silently**; (3) out-of-scope token request that **prompts**; (4) `approved_tools` permission that resolves **silently**; (5) non-pre-approved permission that **prompts**; then audit / interaction / complete | +| `samples/GuidedTour/CodeSnippets.cs` | **Modify** — mission client snippets | +| `samples/GuidedTour/Components/SequenceDiagram.razor` (+ `EntityHighlighter`) | **Modify** — render mission interactions; mark each step **prompt** vs **silent** and show the decision reason (in-scope / prior consent / `approved_tools`) | +| `samples/GuidedTour/playwright-tests/mission.spec.ts` | **New** — drives `TourMode.Mission`; asserts each gate's **prompt** vs **silent** outcome + decision reason | +| `samples/SampleApp/playwright-tests/mission.spec.ts` | **New** — exercises `Mission.razor`; asserts prompt vs silent gate labels | +| `tests/e2e/playwright.config.ts` | **Modify (if needed)** — mission specs reuse existing `guided-tour`/`sample-app` projects + booted backends; add a PS env/policy toggle only if the silent-vs-prompt scenario needs pre-seeded `approved_tools` | +| `tests/e2e/README.md` | **Modify** — document the mission specs | +| `tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs` (or a dedicated integration test project) | **New** — **.NET integration test**: boot MockPersonServer + WhoAmI + MockAgentProvider, run `samples/MissionAgent`, and assert **every consent permutation** (see Consent Test Matrix) plus the `mission_terminated` path | +| `Makefile` | **Modify** — add `demo-mission` target and an `e2e-mission` target | +| `tests/e2e/package.json` | **Modify** — add `test:mission` script (Blazor mission specs) | +| `samples/README.md` | **Modify** — index the mission demo | + +### Implementation Decisions + +- **Test split:** Blazor apps (GuidedTour, SampleApp) are covered by **Playwright + e2e** in `tests/e2e/`; the `MissionAgent` CLI is covered by a **.NET + integration test** under `tests/` (spawns servers + CLI), run via `dotnet test`. +- **Sample shape:** new dedicated `samples/MissionAgent/` (not extending + AgentConsole) for a legible showcase; SampleApp gets a new `Mission.razor` + page; GuidedTour gets a new `TourMode.Mission` with a multi-step plan + (mirroring the federated multi-step style). Existing flows untouched. +- **`make demo` integration:** separate `make demo-mission` target (like + `demo-keycloak`) to keep the default `make demo` bundle uncluttered. +- **Consent decision (three gates)** — the PS prompts the user only when needed, + not on every deferred point (§Agent Token Request L385/L784/L828, §Permission + L1017): + 1. **Mission approval** — always prompt once at mission proposal + (§Mission Creation). + 2. **Token request** — silent when the resource+scope is within the approved + mission intent or matches a remembered prior consent in the mission log; + otherwise prompt (L385, L784, L828). + 3. **Permission request** — silent when the action matches `approved_tools`; + otherwise prompt (L1017). + Prior-consent memory keyed by `(mission s256, resource, scope)`. The decision + is mock PS policy implemented over the Phase 5 `IPermissionDecider` / + `IMissionStore` / mission-log seams — not SDK behavior. + +#### Consent Test Matrix (CLI integration test) + +The `MissionAgent` integration test MUST cover **all** permutations of the three +gates — both decision outcomes (approve/deny) and both PS paths (prompt/silent): + +| # | Gate | Scenario | Expected | +|---|------|----------|----------| +| 1 | Mission approval | user **approves** the proposed mission | `active` mission, `s256` bound | +| 2 | Mission approval | user **denies** the proposed mission | no mission; agent aborts cleanly | +| 3 | Token request | resource+scope **within** approved mission intent | **silent**, reason = in-scope | +| 4 | Token request | resubmit same `(s256, resource, scope)` after prior consent | **silent**, reason = prior consent | +| 5 | Token request | **out-of-scope** resource/scope → user **approves** | prompt → auth token issued | +| 6 | Token request | **out-of-scope** resource/scope → user **denies** | prompt → access denied | +| 7 | Token request (clarification) | user asks a question; agent responds; user approves | clarification round then issue | +| 8 | Token request (clarification) | agent **cancels** via `DELETE` | pending `410 Gone`; no token | +| 9 | Permission | action **in** `approved_tools` | **silent**, reason = approved_tools | +| 10 | Permission | action **not** pre-approved → user **approves** | prompt → granted | +| 11 | Permission | action **not** pre-approved → user **denies** | prompt → denied | +| 12 | Termination | mission terminated mid-flow, agent retries | `403 mission_terminated` surfaced | + +The PS consent decisions are driven deterministically in the test (scripted +approve/deny + pre-seeded `approved_tools` / prior-consent state) so each row is +reproducible without manual interaction. Each row also asserts the recorded +mission-log **decision reason**. + +### Definition of Done + +- [ ] MockPersonServer serves all four governance endpoints (§PS Governance). +- [ ] MockPersonServer embeds `{approver, s256}` in issued auth tokens (§Auth Token). +- [ ] MockPersonServer implements the three-gate consent decision: mission approved + once, then resource/tool access proceeds without re-prompting unless outside + approved scope / `approved_tools` (§Agent Token Request, §Permission Endpoint). +- [ ] MockPersonServer exposes a minimal "terminate mission" hook so the + `mission_terminated` path is exercised end-to-end (§Mission Status Errors). +- [ ] `samples/MissionAgent/` proposes a mission, accesses ≥1 resource under it, + requests a permission, records an audit entry, relays an interaction, and + completes it. +- [ ] SampleApp `Mission.razor` page renders the flow and labels each consent gate + as **prompt** or **silent (in scope)**; Home links to it; existing pages + unchanged. +- [ ] GuidedTour `TourMode.Mission` drives every gate through **both** outcomes + (mission approval prompt; in-scope token silent vs out-of-scope token prompt; + `approved_tools` permission silent vs non-pre-approved permission prompt) and + surfaces the PS decision reason for each; existing modes unchanged. +- [ ] The PS decision reason (in-scope / prior consent / `approved_tools` / + out-of-scope) is visible in both samples so the contrast between prompted + and silent gates is observable. +- [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining). +- [ ] `make demo-mission` boots the mission demo; existing `make demo` unchanged. +- [ ] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the + existing `guided-tour`/`sample-app` projects; existing specs still pass. +- [ ] **.NET integration test** for the `MissionAgent` CLI covers **all 12 rows** + of the Consent Test Matrix (every gate × approve/deny × prompt/silent), + including clarification and `mission_terminated`, each asserting the + recorded decision reason. +- [ ] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally and in CI. +- [ ] Sample READMEs updated. + +--- + +## Phase 7 — Docs + +**Goal:** Rewrite the stale missions doc and add PS-governance docs reflecting the +implemented surface. Separate phase from samples per the agreed workflow. + +**Spec:** §Missions; §Mission Approval; §Mission Log; §Policy Evaluation Points; +§Why Missions Are Not a Policy Language; §Permission/Audit/Interaction Endpoints; +§Clarification Chat. + +### Files + +| File | Action | +|------|--------| +| `docs/advanced/missions.md` | **Rewrite** — spec blob, `s256`, two states, lifecycle, binding chain | +| `docs/server/mission-governance.md` | **New** — PS as contextual policy point; permission/audit/interaction; mission log | +| `docs/workflows/mission-governed-access.md` | **New** — end-to-end walkthrough (the three research flows) | +| `docs/server/token-issuance.md` | **Modify** — add `s256` verify + mission claim emission | +| `docs/workflows/call-chaining.md` | **Modify** — mission forwarding + governance | +| `docs/concepts.md` / `docs/README.md` | **Modify** — index + concept of PS policy enforcement | + +### Definition of Done + +- [ ] `docs/advanced/missions.md` matches the implemented model (no stale fields/states). +- [ ] New governance doc explains the deterministic-vs-contextual split + (§Why Missions Are Not a Policy Language). +- [ ] Walkthrough doc covers create → operate → permission → audit → interaction → + completion, with the binding chain. +- [ ] All doc code samples compile against the Phase 1–5 API. +- [ ] docs index/README updated; cross-links valid. + +--- + +## Phase 8 — Review + +**Goal:** Validate each logical change set against the spec and the plan with a +dedicated subagent per set, then remediate findings. + +### Review subagents (one per change set) + +| # | Change set | Scope | +|---|------------|-------| +| R1 | Mission model + `s256` (Phase 1) | spec §Mission Approval/Management fidelity | +| R2 | Token binding + HTTPSig (Phase 2) | §Resource/Auth Token, §signed `aauth-mission` | +| R3 | Token params + clarification + errors (Phase 3) | §Agent Token Request, §Clarification Chat, §Mission Status Errors | +| R4 | Governance clients + metadata (Phase 4) | §Permission/Audit/Interaction, §PS Metadata | +| R5 | Server seams + mission log (Phase 5) | §PS Governance, §Mission Log | +| R6 | Samples (Phase 6) | flows run end-to-end; spec-faithful | +| R7 | Docs (Phase 7) | accuracy vs implemented API + spec | + +### Definition of Done + +- [ ] Each review subagent produces severity-graded findings with spec citations. +- [ ] All critical/high findings remediated or explicitly deferred (with rationale). +- [ ] Full solution builds; `AAuth.Tests` + `AAuth.Conformance` green. +- [ ] e2e mission flow green. +- [ ] research.md updated with any spec/behavior corrections discovered. + +--- + +## Out of Scope + +| Item | Reason | +|------|--------| +| Full AS implementation in SDK | SDK ships builders + verification; AS stays in mock/Keycloak | +| Mission revocation / delegation-tree queries / admin APIs | Spec defers to a companion specification (§Mission Management) | +| Payment settlement (x402/MPP) beyond surfacing `402` | Out of AAuth core scope; already surfaced as exception | +| Cross-PS agent correlation / multi-device regrouping | PS-side concern per bootstrap spec; not SDK | +| Pairwise `sub` directed-identifier generation strategy | Existing PS-asserted work; not mission-specific. Mock reuses its existing `sub` generation as-is; the `mission` claim is additive alongside it | diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md new file mode 100644 index 0000000..043f59d --- /dev/null +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -0,0 +1,293 @@ +# Missions & PS Governance — Research + +## Problem Statement + +The AAuth protocol defines **missions** (scoped authorization contexts for agent +governance) and positions the **Person Server (PS)** as the *contextual* policy +evaluation point — distinct from the deterministic policy enforced by resources +and access servers. The .NET SDK (`src/AAuth/`), samples (`samples/`), and docs +(`docs/`) implement only fragments of this surface, and the central `Mission` +model is materially divergent from the specification. + +This document captures the spec model, the current SDK/sample/doc state, and the +gap inventory. It contains **no** implementation steps — those live in +[implementation-plan.md](implementation-plan.md). + +## Source Documents + +| Document | Location | Relevant Sections | +|----------|----------|-------------------| +| AAuth Protocol | `aauth-spec/draft-hardt-oauth-aauth-protocol.md` | §Policy Evaluation Points; §Agent Governance; §Missions (overview + normative); §PS Governance Endpoints; §Person Server; §PS Token Endpoint; §Clarification Chat; §Permission Endpoint; §Audit Endpoint; §Interaction Endpoint; §Resource Token; §Auth Token; §Upstream Token Verification; §Call Chaining; §Person Server Metadata; rationale (Why Missions Are Not a Policy Language / Two States) | +| AAuth Bootstrap | `aauth-spec/draft-hardt-aauth-bootstrap.md` | PS lazy user-binding on first interaction; `ps` claim | +| Upcoming changes | `aauth-spec/upcoming-changes-02.md` | `capabilities` in PS token body; `prompt`/`provider_hint` params | + +> Spec section anchors are cited by `{#anchor}` name and approximate line where a +> stable anchor is absent. Line numbers reference the current revision of +> `draft-hardt-oauth-aauth-protocol.md` and may drift on spec updates. + +--- + +## Part 1 — Spec Model + +### 1.1 Policy Evaluation Points (§Policy Evaluation Points, ~L380) + +Policy is **distributed**; no single party is the decision point. Each of the +four server roles re-evaluates the agent's activity from its own vantage point, +and token lifetimes provide a natural re-evaluation cadence: + +- **Agent Provider** — issues/refuses agent tokens (device posture, attestation). +- **Person Server** — decides whether to issue an auth token for a resource/scope + based on **user consent** and, under a mission, the **mission intent + log** + against the PS governance policy. +- **Access Server** — decides issuance on behalf of the resource (resource policy, + PS-provided claims, deferred requirements). +- **Resource** — *decides what is required* when issuing a resource token, and + *enforces* the auth token at access time. + +### 1.2 The Mission as Contextual Governance (§Why Missions Are Not a Policy Language, ~L2789) + +The spec deliberately separates two authorization kinds: + +- **Deterministic policy** — scopes, resource tokens, AS policy. Machine-evaluable. +- **Contextual governance** — missions, justifications, clarification at the PS. + *Not* machine-evaluable; concentrated at the PS, the only party with the mission + content, the user relationship, and the full action history. + +Consequence: mission **content never leaves the PS**. Only the mission **hash** +(`s256`) travels in tokens/headers. Distributing mission content would be "a +privacy leak and a false promise of enforcement." + +### 1.3 Mission Object & Identity (§Mission Approval, ~L1259) + +The approved **mission blob** is JSON: + +| Field | Req? | Meaning | +|-------|------|---------| +| `approver` | MUST | HTTPS URL of approving entity (currently always the PS) | +| `agent` | MUST | Agent identifier `aauth:local@domain` | +| `approved_at` | MUST | ISO 8601 approval timestamp (makes `s256` globally unique) | +| `description` | MUST | Markdown describing approved scope | +| `approved_tools` | MAY | `[{name, description}]` usable without per-call permission | +| `capabilities` | MAY | e.g. `["interaction","payment"]` the PS can provide; agent unions into `AAuth-Capabilities` | + +**Identity** — `s256` = base64url(SHA-256(**exact approved response body bytes**)). +The agent MUST store the body bytes verbatim — *no re-serialization* — and +verifies by recomputing over those bytes (§Mission Approval, ~L1275). + +### 1.4 Mission Lifecycle + +- **Creation** (§Mission Creation, ~L1228): agent POSTs `{description, tools}` to + the PS `mission_endpoint` (signed, agent token via `Signature-Key: sig=jwt`). + PS MAY return `202` for human review + clarification; approved blob MAY differ + from the proposal. +- **States** (§Mission Management, ~L1322): exactly **two** — `active`, + `terminated`. No suspended state (§Why Missions Have Only Two States, ~L2805). +- **Errors** (§Mission Status Errors, ~L1331): a request referencing a + non-active mission → `403` `{error:"mission_terminated", mission_status:"terminated"}`. +- **Completion** (§Mission Completion, ~L1318): agent sends `type=completion` + to the interaction endpoint with a summary; user accepts (terminate) or + follows up (continues). +- **Mission Log** (§Mission Log, ~L1310): ordered record of *all* agent↔PS + interactions (token requests + justifications, permission req/resp, audit + records, interaction requests, clarification chats). PS-maintained. + +### 1.5 Cryptographic Binding Chain + +Mission binding is **by hash reference**, layered on top of **key-bound +proof-of-possession** and the **`act` delegation chain**: + +1. Agent in mission context adds `aauth-mission` to the **signed** HTTPSig + covered components (§Authorization Endpoint Request, ~L619). The `s256` + reference is thus covered by the agent's signature. +2. Mission-aware **resource** embeds `{approver, s256}` as the `mission` claim in + the **resource token** it signs (§Resource Token Structure, ~L780). Resource + token also binds `agent_jkt` (RFC 7638 thumbprint). +3. PS/AS embeds `{approver, s256}` as the `mission` claim in the **auth token** + it signs (§Auth Token Structure, ~L1560). Auth token binds `cnf.jwk` (PoP) + and `act` (RFC 8693 actor chain). +4. **Delegation**: in call chaining the PS nests the upstream `act` inside a new + `act` identifying the intermediary (§Upstream Token Verification, ~L1621), + preserving the full chain for downstream authorization decisions. + +**Integrity & provenance are cryptographic; appropriateness is a PS policy +decision** — the resource embeds the mission reference it received but does not +re-evaluate mission fitness. Only the PS resolves `s256` → content against the +mission log. + +### 1.6 PS Endpoints (§Person Server, ~L810) + +| Endpoint | Spec | Purpose | Mission? | +|----------|------|---------|----------| +| `token_endpoint` | §PS Token Endpoint ~L814 | Exchange resource token → auth token; consent; three/four-party | optional | +| `mission_endpoint` | §Mission Creation ~L1228 | Propose/approve missions | — | +| `permission_endpoint` | §Permission Endpoint ~L1013 | Pre-action governance for non-resource actions (tool calls) | optional | +| `audit_endpoint` | §Audit Endpoint ~L1077 | Fire-and-forget action logging (`201`) | **required** | +| `interaction_endpoint` | §Interaction Endpoint ~L1131 | Relay interaction/payment/question/completion to user | optional | + +**Token request params** (§Agent Token Request, ~L830): `resource_token` (req), +`upstream_token`, `justification`, `login_hint`, `tenant`, `domain_hint`, +`platform`, `device`. `capabilities`/`prompt` per upcoming-changes-02. + +**Clarification chat** (§Clarification Chat, ~L906): PS returns `202` +`requirement=clarification` with `{clarification, timeout?, options?}`. Agent +responds with one of: `clarification_response` POST, updated `resource_token` +POST, or `DELETE` to cancel. Round limit recommended ≤5; agent responses are +untrusted and MUST be sanitized by the PS before display. + +**Permission** (§Permission Endpoint): POST `{action, description?, parameters?, +mission?}` → `{permission: granted|denied, reason?}` or deferred. `approved_tools` +short-circuit the call. **Audit** (§Audit Endpoint): POST `{mission(req), +action, description?, parameters?, result?}` → `201`. **Interaction** +(§Interaction Endpoint): POST `{type, description?, url?, code?, question?, +summary?, mission?}`; `question` → `{answer}`, `completion` → terminate/continue. + +**PS Metadata** (§Person Server Metadata, ~L2199): publishes the optional +`mission_endpoint`, `permission_endpoint`, `audit_endpoint`, +`interaction_endpoint`. + +--- + +## Part 2 — Current SDK State + +### 2.1 What works (keep) + +| Capability | Evidence | Spec | +|------------|----------|------| +| `AAuth-Mission` structured header format | `AAuthMissionHeader.FormatStructured` ([Agent/Mission.cs](../../../src/AAuth/Agent/Mission.cs)) | §AAuth-Mission Header | +| Mission forwarding on downstream calls | [Agent/MissionForwardingHandler.cs](../../../src/AAuth/Agent/MissionForwardingHandler.cs); wired in [AAuthClientBuilder.cs](../../../src/AAuth/AAuthClientBuilder.cs) L556 | §Call Chaining | +| `act` chain build/read/validate | `AuthTokenBuilder.UpstreamAct`, [Tokens/ActChainBuilder.cs](../../../src/AAuth/Tokens/ActChainBuilder.cs), [Tokens/ActChainReader.cs](../../../src/AAuth/Tokens/ActChainReader.cs), [Tokens/UpstreamTokenValidator.cs](../../../src/AAuth/Tokens/UpstreamTokenValidator.cs); depth limit in [Tokens/TokenVerifier.cs](../../../src/AAuth/Tokens/TokenVerifier.cs) | §Upstream Token Verification | +| Deferred `202` loop, interaction, Retry-After/Prefer, `402`, polling errors | [Agent/DeferredPoller.cs](../../../src/AAuth/Agent/DeferredPoller.cs), [Agent/TokenExchangeClient.cs](../../../src/AAuth/Agent/TokenExchangeClient.cs) | §User Interaction; §Deferred Responses | +| `mission.approver` constraint on resource token | `TokenVerifier.VerifyResourceToken` (~L512) when `expectedApprover` supplied | §Resource Token Verification step 7 | +| Call-chaining router (mission.approver → PS) | [Server/CallChaining/CallChainingRouter.cs](../../../src/AAuth/Server/CallChaining/CallChainingRouter.cs) | §Call Chaining | +| PS metadata **emission** of all 4 governance endpoints | [Server/Metadata/AAuthPersonServerMetadataOptions.cs](../../../src/AAuth/Server/Metadata/AAuthPersonServerMetadataOptions.cs), [Server/Metadata/WellKnownEndpoints.cs](../../../src/AAuth/Server/Metadata/WellKnownEndpoints.cs) L178-184 | §Person Server Metadata | + +### 2.2 Gap Inventory + +Severity: **C** critical (incorrect/non-compliant behavior), **H** high (missing +core capability), **M** medium (missing convenience / ecosystem coverage). + +| # | Sev | Gap | Spec | Current state | +|---|-----|-----|------|---------------| +| G1 | C | `Mission` model uses 4 states `pending/approved/denied/completed` | §Mission Management (2 states) | [Agent/Mission.cs](../../../src/AAuth/Agent/Mission.cs) L17 | +| G2 | C | `Mission` missing required blob fields `approver`,`agent`,`approved_at`; carries non-spec `Id`,`Requirements`,`StatusUrl`,`InteractionUrl` | §Mission Approval | Agent/Mission.cs L12-44 | +| G3 | C | `Mission.FromJson` parses `mission_id` (throws if absent), wrong keys | §Mission Approval | Agent/Mission.cs L32-43 | +| G4 | C | No `s256` compute over exact body bytes; no verbatim byte storage; no verify | §Mission Approval ~L1275 | absent | +| G5 | H | No `mission_endpoint` client (propose/approve, 202 review) | §Mission Creation | absent | +| G6 | H | No `permission_endpoint` client | §Permission Endpoint | absent | +| G7 | H | No `audit_endpoint` client (fire-and-forget) | §Audit Endpoint | absent | +| G8 | H | No `interaction_endpoint` client (relay/question/completion) | §Interaction Endpoint | absent | +| G9 | H | `ResourceTokenBuilder` cannot emit `mission` claim | §Resource Token Structure ~L780 | [Tokens/ResourceTokenBuilder.cs](../../../src/AAuth/Tokens/ResourceTokenBuilder.cs) | +| G10 | H | `AuthTokenBuilder` cannot emit `mission` claim (only `AdditionalClaims`) | §Auth Token Structure ~L1560 | [Tokens/AuthTokenBuilder.cs](../../../src/AAuth/Tokens/AuthTokenBuilder.cs) | +| G11 | H | `TokenVerifier.VerifyAuthToken` does not surface `mission` claim | §Auth Token | TokenVerifier.cs L161-268 | +| G12 | H | HTTPSig does not auto-cover `aauth-mission` when header present | §Authorization Endpoint Request ~L619 | [HttpSig/AAuthSigningHandler.cs](../../../src/AAuth/HttpSig/AAuthSigningHandler.cs) L40 (fixed base components) | +| G13 | H | Clarification chat fully absent (parse, response POST, updated-request POST, DELETE cancel, round limit) | §Clarification Chat | DeferredPoller is GET-only | +| G14 | H | Token request missing params `justification`,`login_hint`,`tenant`,`domain_hint`,`platform`,`device` | §Agent Token Request ~L830 | [Agent/TokenExchangeRequest.cs](../../../src/AAuth/Agent/TokenExchangeRequest.cs) | +| G15 | H | `ServerMetadata` does not parse `permission_endpoint`/`audit_endpoint` | §Person Server Metadata | [Discovery/ServerMetadata.cs](../../../src/AAuth/Discovery/ServerMetadata.cs) L43-44 | +| G16 | H | No `mission_terminated` error code / typed exception / handling | §Mission Status Errors | not in [Errors/TokenError.cs](../../../src/AAuth/Errors/TokenError.cs) | +| G17 | M | No `capabilities` union (mission blob ∪ agent) into `AAuth-Capabilities` | §Mission Approval; §AAuth-Capabilities | [Agent/AAuthCapabilitiesHeader.cs](../../../src/AAuth/Agent/AAuthCapabilitiesHeader.cs) format/parse only | +| G18 | M | No PS-side governance handlers (mission/permission/audit/interaction) or DI seams | §PS Governance Endpoints | absent; MockPersonServer hand-rolls partial flows | +| G19 | M | No governance DTOs (Permission/Audit/Interaction/MissionCreate req+resp) | §PS Governance Endpoints | absent | +| G20 | M | No mission-log abstraction (store seam) | §Mission Log | absent | + +### 2.3 SDK role scope (context for plan) + +The SDK is **client/agent + resource-verification** focused; it ships token +**builders** but not full PS/AS servers. [samples/MockPersonServer/Program.cs](../../../samples/MockPersonServer/Program.cs) +hand-rolls token issuance and consent UI (`GET /interaction`, `POST +/interaction/{approve,deny}`) but no spec governance endpoints, no mission +extraction, no `s256`, no mission claim in issued tokens. Server-side governance +helpers (G18/G19/G20) should follow the existing pattern: thin SDK primitives +(DTOs + parse/format + optional minimal endpoint mappers / DI seams) that the +mock servers consume, rather than a full PS implementation. + +--- + +## Part 3 — Samples & Docs State + +### 3.1 Samples + +0 of 9 samples demonstrate mission creation, permission, audit, interaction +relay, or completion. Orchestrator forwards the `AAuth-Mission` header only. + +| Sample | Mission-aware | Governance demo | +|--------|---------------|-----------------| +| WhoAmI (resource) | no | no | +| Orchestrator (call chain) | header forward only | no | +| MockPersonServer | no mission claim/s256 | consent UI only | +| MockAgentProvider / MockAccessServer | n/a | no | +| GuidedTour / SampleApp / AgentConsole / LiveWhoAmITest | no | no | + +### 3.2 Docs + +| File | Status | +|------|--------| +| [docs/advanced/missions.md](../../../docs/advanced/missions.md) | **STALE** — mirrors broken model (4 states, `mission_id`/`status_url`/`interaction_url`); no `s256`, no blob fields, no governance endpoints | +| [docs/workflows/call-chaining.md](../../../docs/workflows/call-chaining.md) | partial — mentions forwarding, no mission blob/governance | +| [docs/workflows/ps-asserted-access.md](../../../docs/workflows/ps-asserted-access.md) | aligned for consent; no mission context | +| [docs/workflows/deferred-consent.md](../../../docs/workflows/deferred-consent.md) | aligned; no mission/permission context | +| [docs/workflows/federated-access.md](../../../docs/workflows/federated-access.md) | partial; no mission governance | +| [docs/server/token-issuance.md](../../../docs/server/token-issuance.md) | mentions `mission.approver`; no `s256`/lifecycle | +| docs/server/{permission,audit,interaction} | **absent** | + +Docs index: [docs/README.md](../../../docs/README.md) (slot new governance docs +under `docs/server/` + refresh `docs/advanced/missions.md` + add a +`docs/workflows/` mission-governance walkthrough). + +--- + +## Part 4 — Gaps & Open Questions + +1. **`s256` over which bytes?** Spec: exact response body bytes of the mission + approval. SDK must retain the raw `byte[]`/`string` from the `mission_endpoint` + response (and any persisted blob) — model must expose a verbatim-bytes + accessor, not a re-serialized `JsonObject`. (§Mission Approval ~L1275) +2. **Auto-signing `aauth-mission`.** Should `AAuthSigningHandler` auto-detect the + `AAuth-Mission` request header and add `aauth-mission` to covered components, + or should the mission-aware client layer set `AdditionalComponentsKey`? + Leaning auto-detect for correctness (G12), but must confirm it does not + double-add when callers also set it. (§Authorization Endpoint Request ~L619) +3. **Backward compatibility of `Mission`.** Replacing the model is a breaking + change. Confirm whether to rename (e.g. `ApprovedMission`) + keep a + `[Obsolete]` shim, or replace outright. (Affects docs + any sample usage.) +4. **Server governance depth.** How much PS-side to put in the SDK vs. the mock? + Proposed: DTOs + serialization + minimal endpoint mappers + store/relay + interfaces (`IMissionStore`, `IPermissionDecider`, `IAuditSink`, + `IInteractionRelay`); full policy lives in MockPersonServer. (§PS Governance) +5. **Clarification scope.** Implement the full agent-side three-action loop + (respond / update / cancel) plus a server-side helper, or agent-side only in + round one? (§Clarification Chat) +6. **`mission_terminated` propagation.** Where to surface — `TokenExchangeClient`, + governance clients, or a shared error mapper consumed by all PS calls? + (§Mission Status Errors) + +> **Update (2026-06):** Initial research complete. Open questions above to be +> resolved as Implementation Decisions per phase before coding. + +## Part 5 — Phase Findings + +### Phase 1 — Mission model & `s256` (2026-06-05, complete) + +- **Decisions resolved.** Q1 (verbatim bytes): `Mission.RawBytes` + (`ReadOnlyMemory`) stores the exact approval body; `FromApprovalBytes` + hashes those bytes directly (no `JsonNode` re-serialization). Q3 (compat): + given draft status + no back-compat constraint, the old `Mission` model was + **replaced in place** — no rename, no `[Obsolete]` shim. +- **Old model had zero external references.** `Mission` (with `Id`/`Status`/ + `Requirements`/`StatusUrl`/`InteractionUrl`) and `Mission.FromJson` were not + referenced anywhere in `src/`, `tests/`, or `samples/`. The rewrite therefore + broke no callers — confirmed by a full-solution build (0 warnings/errors). +- **`AAuthMissionHeader` kept as-is.** `FormatStructured(approver, s256)` already + matched spec; only the dead `Format(string missionId)` overload was removed. + `MissionForwardingHandler` (the sole consumer) is unaffected. +- **`s256` is byte-sensitive.** A test confirms pretty-printed vs compact JSON of + the same logical content produce **different** `s256` — reinforcing the + "store verbatim, never re-serialize" requirement (§Mission Approval). +- **Capabilities union** added as `AAuthCapabilitiesHeader.Union(mission, agent)` + (mission-first, order-preserving, case-sensitive dedupe). +- **New files:** `Mission.cs` (rewritten), `MissionState.cs`, `MissionTool.cs`. + **Tests:** `Missions/MissionModelTests.cs`, `Missions/MissionS256Tests.cs`, + plus `Union` cases in `CapabilitiesHeaderTests.cs`. +- **Validation:** 364 conformance + 371 unit tests green; full solution builds. +- **No new open questions or design choices for Phase 1.** `VerifyS256` uses a + fixed-time comparison (defensive; the value is not secret but the helper is + cheap and avoids early-exit surprises). diff --git a/src/AAuth/Agent/AAuthCapabilitiesHeader.cs b/src/AAuth/Agent/AAuthCapabilitiesHeader.cs index a569120..b167c06 100644 --- a/src/AAuth/Agent/AAuthCapabilitiesHeader.cs +++ b/src/AAuth/Agent/AAuthCapabilitiesHeader.cs @@ -46,4 +46,37 @@ public static IReadOnlyList Parse(string headerValue) return Array.Empty(); return headerValue.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); } + + /// + /// Union the mission-provided capabilities with the agent's own capabilities, + /// preserving order (mission first, then agent) and removing duplicates + /// case-sensitively. Per §Mission Approval, the agent unions the capabilities + /// the PS can provide for the session with its own when constructing the + /// AAuth-Capabilities request header. + /// + public static IReadOnlyList Union( + IEnumerable? missionCapabilities, + IEnumerable? agentCapabilities) + { + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + + void Add(IEnumerable? source) + { + if (source is null) + return; + foreach (var capability in source) + { + if (string.IsNullOrWhiteSpace(capability)) + continue; + var trimmed = capability.Trim(); + if (seen.Add(trimmed)) + result.Add(trimmed); + } + } + + Add(missionCapabilities); + Add(agentCapabilities); + return result; + } } diff --git a/src/AAuth/Agent/Mission.cs b/src/AAuth/Agent/Mission.cs index 6b8e49e..174154c 100644 --- a/src/AAuth/Agent/Mission.cs +++ b/src/AAuth/Agent/Mission.cs @@ -1,65 +1,173 @@ using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; using System.Text.Json.Nodes; +using Microsoft.IdentityModel.Tokens; namespace AAuth.Agent; /// -/// Represents an AAuth mission (§5) — a structured request for -/// multi-step approval, clarification, or audited access. -/// Agent-side model parsed from PS responses. +/// An approved AAuth mission (§Mission Approval) — the mission blob +/// returned by the PS's mission_endpoint. A mission is a scoped +/// authorization context that the PS uses to evaluate every subsequent request +/// in context. /// +/// +/// The mission's identity is its : the base64url-encoded +/// SHA-256 hash of the exact approval response body bytes. Per spec, the agent +/// MUST store the mission body bytes exactly as received — no re-serialization — +/// so the hash can be verified and the same value carried in the +/// AAuth-Mission header on subsequent requests. +/// public sealed class Mission { - /// Mission identifier. - public required string Id { get; init; } + /// HTTPS URL of the entity that approved the mission (currently always the PS). + public required string Approver { get; init; } - /// Mission status (pending, approved, denied, completed). - public required string Status { get; init; } + /// The agent identifier (aauth:local@domain) the mission was approved for. + public required string Agent { get; init; } - /// Required permissions or clarifications. - public JsonArray? Requirements { get; init; } + /// When the mission was approved (ensures the is globally unique). + public required DateTimeOffset ApprovedAt { get; init; } - /// Human-readable description of what the mission is for. - public string? Description { get; init; } + /// Markdown string describing the approved mission scope. + public required string Description { get; init; } - /// URL to check mission status. - public string? StatusUrl { get; init; } + /// + /// Tools the agent may use without a per-call permission request at the PS's + /// permission endpoint (§Permission Endpoint). MAY be a subset of the proposed tools. + /// + public IReadOnlyList ApprovedTools { get; init; } = Array.Empty(); + + /// + /// Capability strings (e.g. interaction, payment) the PS can provide + /// on behalf of the user for this session. The agent unions these with its own + /// capabilities when constructing the AAuth-Capabilities request header. + /// + public IReadOnlyList Capabilities { get; init; } = Array.Empty(); + + /// + /// The mission identity: base64url(SHA-256(approval body bytes)). Carried in the + /// AAuth-Mission header and embedded as the mission claim in tokens. + /// + public required string S256 { get; init; } + + /// + /// The verbatim approval response body bytes, stored exactly as received so the + /// can be verified without re-serialization. + /// + public ReadOnlyMemory RawBytes { get; init; } - /// URL for human interaction (approval UI). - public string? InteractionUrl { get; init; } + /// The mission lifecycle state (§Mission Management). + public MissionState State { get; init; } = MissionState.Active; - /// Parse from a JSON response body. - public static Mission FromJson(JsonObject json) + /// + /// Parse a mission from the exact approval response body bytes and compute its + /// identity. The bytes are stored verbatim in + /// — they are never re-serialized. + /// + public static Mission FromApprovalBytes(ReadOnlySpan body) { - ArgumentNullException.ThrowIfNull(json); + if (body.IsEmpty) + throw new ArgumentException("Mission approval body is empty.", nameof(body)); + + var bytes = body.ToArray(); + + if (JsonNode.Parse(bytes) is not JsonObject json) + throw new InvalidOperationException("Mission approval body is not a JSON object."); + + var approver = (string?)json["approver"] + ?? throw new InvalidOperationException("Mission blob missing required 'approver'."); + var agent = (string?)json["agent"] + ?? throw new InvalidOperationException("Mission blob missing required 'agent'."); + var description = (string?)json["description"] + ?? throw new InvalidOperationException("Mission blob missing required 'description'."); + + if (json["approved_at"] is not JsonValue approvedAtValue + || !DateTimeOffset.TryParse((string?)approvedAtValue, out var approvedAt)) + { + throw new InvalidOperationException("Mission blob missing or invalid 'approved_at'."); + } + return new Mission { - Id = (string?)json["mission_id"] ?? throw new InvalidOperationException("Missing 'mission_id'."), - Status = (string?)json["status"] ?? "pending", - Requirements = json["requirements"] as JsonArray, - Description = (string?)json["description"], - StatusUrl = (string?)json["status_url"], - InteractionUrl = (string?)json["interaction_url"], + Approver = approver, + Agent = agent, + ApprovedAt = approvedAt, + Description = description, + ApprovedTools = ParseTools(json["approved_tools"] as JsonArray), + Capabilities = ParseCapabilities(json["capabilities"] as JsonArray), + S256 = ComputeS256(bytes), + RawBytes = bytes, }; } + + /// + /// Verify that the supplied value (e.g. from the AAuth-Mission header) matches + /// the computed over the stored approval body bytes. + /// + public bool VerifyS256(string expected) + { + if (string.IsNullOrEmpty(expected)) + return false; + return CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(S256), + Encoding.ASCII.GetBytes(expected)); + } + + /// Compute base64url(SHA-256()) per §Mission Approval. + public static string ComputeS256(ReadOnlySpan body) + { + var hash = SHA256.HashData(body); + return Base64UrlEncoder.Encode(hash); + } + + private static IReadOnlyList ParseTools(JsonArray? tools) + { + if (tools is null || tools.Count == 0) + return Array.Empty(); + + var result = new List(tools.Count); + foreach (var node in tools) + { + if (node is not JsonObject tool) + continue; + var name = (string?)tool["name"]; + if (string.IsNullOrEmpty(name)) + continue; + result.Add(new MissionTool(name, (string?)tool["description"])); + } + + return result; + } + + private static IReadOnlyList ParseCapabilities(JsonArray? capabilities) + { + if (capabilities is null || capabilities.Count == 0) + return Array.Empty(); + + var result = new List(capabilities.Count); + foreach (var node in capabilities) + { + var value = (string?)node; + if (!string.IsNullOrEmpty(value)) + result.Add(value); + } + + return result; + } } /// /// The AAuth-Mission header value, used by the agent to declare its mission -/// context on outbound requests. +/// context on outbound requests (§AAuth-Mission Request Header). /// public static class AAuthMissionHeader { /// The HTTP header name. public const string Name = "AAuth-Mission"; - /// Format the header value with a mission ID. - public static string Format(string missionId) - { - ArgumentException.ThrowIfNullOrEmpty(missionId); - return missionId; - } - /// /// Format the structured header value with approver and s256 per §Call Chaining. /// diff --git a/src/AAuth/Agent/MissionState.cs b/src/AAuth/Agent/MissionState.cs new file mode 100644 index 0000000..a037118 --- /dev/null +++ b/src/AAuth/Agent/MissionState.cs @@ -0,0 +1,17 @@ +namespace AAuth.Agent; + +/// +/// The lifecycle state of a mission (§Mission Management). A mission has exactly +/// one of two states. +/// +public enum MissionState +{ + /// The mission is in progress. The agent can make requests against it. + Active, + + /// + /// The mission is permanently ended. The PS MUST reject requests with + /// mission_terminated (§Mission Status Errors). + /// + Terminated, +} diff --git a/src/AAuth/Agent/MissionTool.cs b/src/AAuth/Agent/MissionTool.cs new file mode 100644 index 0000000..1ee2c84 --- /dev/null +++ b/src/AAuth/Agent/MissionTool.cs @@ -0,0 +1,10 @@ +namespace AAuth.Agent; + +/// +/// A tool the agent may use within a mission. Mission proposals carry requested +/// tools; the approved mission blob carries approved_tools that the agent +/// may use without a per-call permission request (§Mission Approval). +/// +/// The tool name. +/// A human-readable description of the tool. +public sealed record MissionTool(string Name, string? Description = null); diff --git a/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs b/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs index 81b0f9c..7b5c094 100644 --- a/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs +++ b/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using AAuth.Agent; using AAuth.Crypto; @@ -36,6 +37,34 @@ public void Parse_Empty() Assert.Empty(caps); } + [Fact(DisplayName = "§Mission Approval — Union merges mission and agent capabilities, mission first")] + public void Union_MergesMissionFirst() + { + var union = AAuthCapabilitiesHeader.Union( + missionCapabilities: new[] { "interaction", "payment" }, + agentCapabilities: new[] { "clarification" }); + + Assert.Equal(new[] { "interaction", "payment", "clarification" }, union.ToArray()); + } + + [Fact(DisplayName = "§Mission Approval — Union deduplicates overlapping capabilities")] + public void Union_Deduplicates() + { + var union = AAuthCapabilitiesHeader.Union( + missionCapabilities: new[] { "interaction", "payment" }, + agentCapabilities: new[] { "payment", "mission" }); + + Assert.Equal(new[] { "interaction", "payment", "mission" }, union.ToArray()); + } + + [Fact(DisplayName = "§Mission Approval — Union tolerates null sources")] + public void Union_ToleratesNulls() + { + Assert.Equal(new[] { "interaction" }, AAuthCapabilitiesHeader.Union(new[] { "interaction" }, null).ToArray()); + Assert.Equal(new[] { "mission" }, AAuthCapabilitiesHeader.Union(null, new[] { "mission" }).ToArray()); + Assert.Empty(AAuthCapabilitiesHeader.Union(null, null)); + } + [Fact(DisplayName = "§14.1 — AAuthSigningHandler emits Capabilities header when configured")] public void SigningHandler_EmitsCapabilities() { diff --git a/tests/AAuth.Conformance/Missions/MissionModelTests.cs b/tests/AAuth.Conformance/Missions/MissionModelTests.cs new file mode 100644 index 0000000..17541a8 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/MissionModelTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Text; +using AAuth.Agent; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance tests for the mission blob model (§Mission Approval, §Mission Management). +/// +public class MissionModelTests +{ + private const string ApprovalBody = """ + { + "approver": "https://ps.example", + "agent": "aauth:assistant@agent.example", + "approved_at": "2026-04-07T14:30:00Z", + "description": "# Plan Japan Vacation\n\nPlan and book a trip.", + "approved_tools": [ + { "name": "WebSearch", "description": "Search the web" }, + { "name": "Read", "description": "Read files and web pages" } + ], + "capabilities": [ "interaction", "payment" ] + } + """; + + [Fact(DisplayName = "§Mission Approval — mission blob parses required fields")] + public void FromApprovalBytes_ParsesRequiredFields() + { + var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody)); + + Assert.Equal("https://ps.example", mission.Approver); + Assert.Equal("aauth:assistant@agent.example", mission.Agent); + Assert.Equal( + new DateTimeOffset(2026, 4, 7, 14, 30, 0, TimeSpan.Zero), + mission.ApprovedAt); + Assert.StartsWith("# Plan Japan Vacation", mission.Description); + } + + [Fact(DisplayName = "§Mission Approval — approved_tools parse into MissionTool list")] + public void FromApprovalBytes_ParsesApprovedTools() + { + var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody)); + + Assert.Equal(2, mission.ApprovedTools.Count); + Assert.Equal("WebSearch", mission.ApprovedTools[0].Name); + Assert.Equal("Search the web", mission.ApprovedTools[0].Description); + Assert.Equal("Read", mission.ApprovedTools[1].Name); + } + + [Fact(DisplayName = "§Mission Approval — capabilities parse into string list")] + public void FromApprovalBytes_ParsesCapabilities() + { + var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody)); + + Assert.Equal(new[] { "interaction", "payment" }, mission.Capabilities.ToArray()); + } + + [Fact(DisplayName = "§Mission Management — a new mission defaults to active state")] + public void FromApprovalBytes_DefaultsToActive() + { + var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody)); + + Assert.Equal(MissionState.Active, mission.State); + } + + [Fact(DisplayName = "§Mission Approval — optional fields default to empty when absent")] + public void FromApprovalBytes_OptionalFieldsDefaultEmpty() + { + const string minimal = """ + { + "approver": "https://ps.example", + "agent": "aauth:assistant@agent.example", + "approved_at": "2026-04-07T14:30:00Z", + "description": "Minimal mission" + } + """; + + var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(minimal)); + + Assert.Empty(mission.ApprovedTools); + Assert.Empty(mission.Capabilities); + } + + [Theory(DisplayName = "§Mission Approval — missing required field throws")] + [InlineData("{ \"agent\": \"a\", \"approved_at\": \"2026-04-07T14:30:00Z\", \"description\": \"d\" }")] + [InlineData("{ \"approver\": \"https://ps.example\", \"approved_at\": \"2026-04-07T14:30:00Z\", \"description\": \"d\" }")] + [InlineData("{ \"approver\": \"https://ps.example\", \"agent\": \"a\", \"description\": \"d\" }")] + [InlineData("{ \"approver\": \"https://ps.example\", \"agent\": \"a\", \"approved_at\": \"2026-04-07T14:30:00Z\" }")] + public void FromApprovalBytes_MissingRequiredField_Throws(string body) + { + Assert.Throws( + () => Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(body))); + } + + [Fact(DisplayName = "§Mission Approval — empty body throws")] + public void FromApprovalBytes_EmptyBody_Throws() + { + Assert.Throws(() => Mission.FromApprovalBytes(ReadOnlySpan.Empty)); + } +} diff --git a/tests/AAuth.Conformance/Missions/MissionS256Tests.cs b/tests/AAuth.Conformance/Missions/MissionS256Tests.cs new file mode 100644 index 0000000..0621e37 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/MissionS256Tests.cs @@ -0,0 +1,74 @@ +using System.Security.Cryptography; +using System.Text; +using AAuth.Agent; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance tests for the mission s256 identity (§Mission Approval): +/// base64url(SHA-256(exact response body bytes)), stored verbatim with no +/// re-serialization. +/// +public class MissionS256Tests +{ + private static readonly byte[] Body = Encoding.UTF8.GetBytes(""" + { + "approver": "https://ps.example", + "agent": "aauth:assistant@agent.example", + "approved_at": "2026-04-07T14:30:00Z", + "description": "Plan a trip" + } + """); + + [Fact(DisplayName = "§Mission Approval — s256 is base64url(SHA-256(body bytes))")] + public void S256_MatchesSha256OfBodyBytes() + { + var mission = Mission.FromApprovalBytes(Body); + + var expected = Base64UrlEncoder.Encode(SHA256.HashData(Body)); + Assert.Equal(expected, mission.S256); + } + + [Fact(DisplayName = "§Mission Approval — stored RawBytes are verbatim (no re-serialization)")] + public void RawBytes_AreVerbatim() + { + var mission = Mission.FromApprovalBytes(Body); + + Assert.Equal(Body, mission.RawBytes.ToArray()); + } + + [Fact(DisplayName = "§Mission Approval — VerifyS256 accepts the matching hash")] + public void VerifyS256_AcceptsMatchingHash() + { + var mission = Mission.FromApprovalBytes(Body); + + Assert.True(mission.VerifyS256(mission.S256)); + Assert.True(mission.VerifyS256(Base64UrlEncoder.Encode(SHA256.HashData(Body)))); + } + + [Fact(DisplayName = "§Mission Approval — VerifyS256 rejects a non-matching hash")] + public void VerifyS256_RejectsNonMatchingHash() + { + var mission = Mission.FromApprovalBytes(Body); + + Assert.False(mission.VerifyS256("not-the-hash")); + Assert.False(mission.VerifyS256("")); + } + + [Fact(DisplayName = "§Mission Approval — whitespace differences change the s256")] + public void S256_IsSensitiveToByteDifferences() + { + var compact = Encoding.UTF8.GetBytes( + "{\"approver\":\"https://ps.example\",\"agent\":\"aauth:assistant@agent.example\"," + + "\"approved_at\":\"2026-04-07T14:30:00Z\",\"description\":\"Plan a trip\"}"); + + var fromPretty = Mission.FromApprovalBytes(Body); + var fromCompact = Mission.FromApprovalBytes(compact); + + // Same logical content, different bytes — different identity. This is why + // the agent MUST store the body verbatim and never re-serialize. + Assert.NotEqual(fromPretty.S256, fromCompact.S256); + } +} From 530f802b27144d33f5b37c0ba72f3a06a5142a21 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 17:05:20 +0000 Subject: [PATCH 02/24] feat(missions): bind mission through token chain and HTTP signature Carry {approver, s256} as the optional `mission` claim in resource and auth tokens, surface it on verification, and cover `aauth-mission` in the RFC 9421 signature when the AAuth-Mission header is present. - Add MissionClaim {approver, s256} value type - ResourceTokenBuilder/AuthTokenBuilder emit `mission` only when set - TokenVerifier.VerifiedToken exposes the verified mission claim - AAuthSigningHandler auto-covers `aauth-mission` (last, per spec) with no double-cover - AAuthVerifier + verification middleware validate the `aauth-mission` covered component so signed mission requests round-trip Phase 2 of missions/PS governance. --- .../implementation-plan.md | 26 ++- .../research.md | 43 +++++ src/AAuth/HttpSig/AAuthSigningHandler.cs | 28 ++++ src/AAuth/HttpSig/AAuthVerifier.cs | 44 +++++- .../AAuthVerificationMiddleware.cs | 4 +- src/AAuth/Tokens/AuthTokenBuilder.cs | 11 ++ src/AAuth/Tokens/MissionClaim.cs | 37 +++++ src/AAuth/Tokens/ResourceTokenBuilder.cs | 12 ++ src/AAuth/Tokens/TokenVerifier.cs | 9 +- .../MissionSignedComponentTests.cs | 138 ++++++++++++++++ .../Missions/MissionClaimTests.cs | 149 ++++++++++++++++++ 11 files changed, 485 insertions(+), 16 deletions(-) create mode 100644 src/AAuth/Tokens/MissionClaim.cs create mode 100644 tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs create mode 100644 tests/AAuth.Conformance/Missions/MissionClaimTests.cs diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index c41e422..3c3be34 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -123,22 +123,36 @@ Request (~L619, add `aauth-mission` to signed components). | `src/AAuth/Tokens/TokenVerifier.cs` | **Modify** — surface `mission` on auth-token verify result | | `src/AAuth/Tokens/VerifiedToken*.cs` | **Modify** — expose parsed `mission` | | `src/AAuth/HttpSig/AAuthSigningHandler.cs` | **Modify** — auto-cover `aauth-mission` when header present (D2) | -| `tests/AAuth.Conformance/Tokens/MissionClaimTests.cs` | **New** | +| `src/AAuth/HttpSig/AAuthVerifier.cs` | **Modify** (added) — accept `mission` param + validate `aauth-mission` covered component | +| `src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs` | **Modify** (added) — pass `AAuth-Mission` header into the verifier | +| `src/AAuth/Tokens/MissionClaim.cs` | **New** (added) — `{approver, s256}` value carried in tokens | +| `tests/AAuth.Conformance/Missions/MissionClaimTests.cs` | **New** (placed under `Missions/`) | | `tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs` | **New** | ### Implementation Decisions - D2 (resolved): auto-cover `aauth-mission` in `AAuthSigningHandler` when the header is present; signing method signatures may change as needed. +- D5 (resolved, user-approved): the verifier side (`AAuthVerifier` + + `AAuthVerificationMiddleware`) is extended in Phase 2 so signed mission + requests round-trip; without it every mission-context request would fail + HTTP-signature verification. +- D6 — covered-component ordering (resolved per spec): `aauth-mission` is the + **last** covered component, after `signature-key` (spec §Authorization + Endpoint Request example, mission context). The pre-existing `authorization` + handling (appended after `signature-key`) is left unchanged — re-aligning it + to the spec's `authorization`-before-`signature-key` example is out of Phase 2 + scope. Verifier accepts the optional trailing pair `authorization` then + `aauth-mission`, in that order. ### Definition of Done -- [ ] `ResourceTokenBuilder` emits `mission` claim when a mission is present (§Resource Token). -- [ ] `AuthTokenBuilder` emits `mission` claim when a mission is present (§Auth Token). -- [ ] `TokenVerifier` exposes the verified `mission` claim on auth tokens. -- [ ] When `AAuth-Mission` header present, `aauth-mission` appears in +- [x] `ResourceTokenBuilder` emits `mission` claim when a mission is present (§Resource Token). +- [x] `AuthTokenBuilder` emits `mission` claim when a mission is present (§Auth Token). +- [x] `TokenVerifier` exposes the verified `mission` claim on auth tokens. +- [x] When `AAuth-Mission` header present, `aauth-mission` appears in `Signature-Input` covered components (§Authorization Endpoint Request). -- [ ] Covered-components contract updated; token/signature tests adjusted to match. +- [x] Covered-components contract updated; token/signature tests adjusted to match. --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index 043f59d..616b5fc 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -291,3 +291,46 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a - **No new open questions or design choices for Phase 1.** `VerifyS256` uses a fixed-time comparison (defensive; the value is not secret but the helper is cheap and avoids early-exit surprises). + +### Phase 2 — Mission binding through the token chain (2026-06-05, complete) + +- **Mission claim shape.** Introduced `MissionClaim(string Approver, string + S256)` (`src/AAuth/Tokens/MissionClaim.cs`) as the `{approver, s256}` value + carried in tokens. `ResourceTokenBuilder` and `AuthTokenBuilder` gained an + optional `Mission` property; the `mission` claim is emitted **only when set** + (§Resource Token Structure, §Auth Token Structure). +- **Verification surface.** `TokenVerifier.VerifiedToken` exposes a computed + `Mission` property (parses `payload.mission`; `null` when absent/malformed). + The existing `expectedApprover` constraint in `VerifyResourceTokenAsync` + (step 7) is unchanged. +- **DISCOVERY (mid-phase, surfaced to user).** Adding `aauth-mission` on the + signing side alone would **break** every mission-context request: the + production verifier `AAuthVerifier.Verify` rigidly rejected any covered + component beyond the base 4 + optional `authorization` (threw when + `components.Count > 5`). This required extending the verifier + + `AAuthVerificationMiddleware`, two files **not** in the original Phase 2 file + list. **User approved** adding them (design decision D5). +- **Covered-component ordering (D6, resolved per spec).** Spec + §Authorization Endpoint Request shows mission context as + `("@method" "@authority" "@path" "signature-key" "aauth-mission")` — i.e. + `aauth-mission` is the **last** component, after `signature-key`. The signer + appends it after the (pre-existing) `authorization` block, so the verifier + accepts the optional trailing pair `authorization` then `aauth-mission`. + **Pre-existing deviation noted:** the spec's §AAuth-Access example places + `authorization` *before* `signature-key`, but the SDK appends it *after*; + re-aligning that is out of Phase 2 scope (would churn existing tests). +- **No double-cover.** `aauth-mission` is added to the signer's `seen` set so an + explicit `AdditionalComponentsKey` request for it is ignored (covered once via + header auto-detection). Test asserts a single occurrence. +- **Header value consistency.** Signer covers the verbatim `AAuth-Mission` header + value (`approver="..."; s256="..."` via `AAuthMissionHeader.FormatStructured`). + Middleware passes `req.Headers["AAuth-Mission"].FirstOrDefault()`; single-valued + in practice so producer and verifier see identical bytes. +- **Files:** `MissionClaim.cs` (new); modified `ResourceTokenBuilder.cs`, + `AuthTokenBuilder.cs`, `TokenVerifier.cs`, `AAuthSigningHandler.cs`, + `AAuthVerifier.cs`, `AAuthVerificationMiddleware.cs`. **Tests:** + `Missions/MissionClaimTests.cs` (6), `HttpSignatures/MissionSignedComponentTests.cs` + (5) — note `MissionClaimTests` placed under `Missions/` (no `Tokens/` folder + exists in the conformance project). +- **Validation:** 375 conformance (+11) + 371 unit tests green; full solution + builds 0/0. diff --git a/src/AAuth/HttpSig/AAuthSigningHandler.cs b/src/AAuth/HttpSig/AAuthSigningHandler.cs index 8c47378..30873b0 100644 --- a/src/AAuth/HttpSig/AAuthSigningHandler.cs +++ b/src/AAuth/HttpSig/AAuthSigningHandler.cs @@ -218,6 +218,13 @@ public void Sign(HttpRequestMessage request) { AppendComponent(sb, "authorization", request.Headers.Authorization.ToString()); } + // §Mission context: when the agent operates in a mission context it + // includes the AAuth-Mission header and adds aauth-mission to the + // signed components. Auto-cover it so callers need not opt in. + if (TryGetMissionComponent(request, out var missionValue)) + { + AppendComponent(sb, "aauth-mission", missionValue); + } foreach (var (name, value) in additional) { AppendComponent(sb, name, value); @@ -252,6 +259,20 @@ private static void AppendComponent(StringBuilder sb, string name, string value) sb.Append('"').Append(name).Append("\": ").Append(value).Append('\n'); } + // Resolve the AAuth-Mission header to its on-the-wire field value so it can + // be covered as the `aauth-mission` component (§Mission context). Returns + // false when the header is absent or empty. + private static bool TryGetMissionComponent(HttpRequestMessage request, out string value) + { + value = string.Empty; + if (!request.Headers.TryGetValues(AAuthMissionHeader.Name, out var values)) + { + return false; + } + value = string.Join(", ", values); + return !string.IsNullOrWhiteSpace(value); + } + private static string BuildSignatureParams( long created, HttpRequestMessage request, IReadOnlyList<(string Name, string Value)> additional) @@ -270,6 +291,11 @@ private static string BuildSignatureParams( { sb.Append(" \"authorization\""); } + // §Mission context: aauth-mission is covered when the header is present. + if (TryGetMissionComponent(request, out _)) + { + sb.Append(" \"aauth-mission\""); + } foreach (var (name, _) in additional) { sb.Append(" \"").Append(name).Append('"'); @@ -301,6 +327,8 @@ private static string BuildSignatureParams( seen.Add(baseComponent); } seen.Add("authorization"); + // aauth-mission is auto-covered from the header; never add it twice. + seen.Add("aauth-mission"); var resolved = new List<(string, string)>(); foreach (var raw in requested) diff --git a/src/AAuth/HttpSig/AAuthVerifier.cs b/src/AAuth/HttpSig/AAuthVerifier.cs index 0415286..4f1301d 100644 --- a/src/AAuth/HttpSig/AAuthVerifier.cs +++ b/src/AAuth/HttpSig/AAuthVerifier.cs @@ -52,6 +52,7 @@ public sealed class AAuthVerifier /// Verbatim Signature header value. /// Public key for HTTP-signature verification (resolved from scheme). /// Verbatim Authorization header value, or null if absent. + /// Verbatim AAuth-Mission header value, or null if absent. /// If any check fails. public void Verify( string method, @@ -61,7 +62,8 @@ public void Verify( string signatureInput, string signatureHeader, IAAuthKey publicKey, - string? authorization = null) + string? authorization = null, + string? mission = null) { ArgumentException.ThrowIfNullOrEmpty(method); ArgumentException.ThrowIfNullOrEmpty(authority); @@ -78,20 +80,34 @@ public void Verify( var (paramsLine, components, created) = ParseSignatureInput(signatureInput); // Validate the covered-component list matches AAuth's expected shape. - // Base: @method, @authority, @path, signature-key - // When authorization is covered, it MUST appear after the base set. - var hasAuthzComponent = components.Count == 5 - && components[4] == "authorization"; + // Base (in order): @method, @authority, @path, signature-key. var baseMatch = components.Count >= 4 && components.Take(4).SequenceEqual(AAuthSigningHandler.CoveredComponents, StringComparer.Ordinal); - - if (!baseMatch || (components.Count == 5 && !hasAuthzComponent) || components.Count > 5) + if (!baseMatch) { throw new AAuthVerificationException( "Signature-Input covered components do not match AAuth's required set " + $"({string.Join(' ', AAuthSigningHandler.CoveredComponents)})."); } - if (components.Count < 4) + + // After the base set, the only permitted optional components are + // `authorization` (§AAuth-Access) then `aauth-mission` (§Authorization + // Endpoint Request, mission context), in that order. Any other trailing + // component or ordering is rejected. + var hasAuthzComponent = false; + var hasMissionComponent = false; + var index = 4; + if (index < components.Count && components[index] == "authorization") + { + hasAuthzComponent = true; + index++; + } + if (index < components.Count && components[index] == "aauth-mission") + { + hasMissionComponent = true; + index++; + } + if (index != components.Count) { throw new AAuthVerificationException( "Signature-Input covered components do not match AAuth's required set " + @@ -106,6 +122,14 @@ public void Verify( "Authorization header is present but 'authorization' is not in the covered components."); } + // §Authorization Endpoint Request: if the AAuth-Mission header is + // present, the signature MUST cover `aauth-mission`. + if (mission is not null && !hasMissionComponent) + { + throw new AAuthVerificationException( + "AAuth-Mission header is present but 'aauth-mission' is not in the covered components."); + } + // Freshness check on `created` (RFC 9421 §3.2.1). Asymmetric: allow // up to MaxAge in the past (the spec's `signature_window`) and only a // small MaxFutureSkew window for NTP drift. The previous symmetric @@ -133,6 +157,10 @@ public void Verify( { AppendComponent(sb, "authorization", authorization!); } + if (hasMissionComponent) + { + AppendComponent(sb, "aauth-mission", mission!); + } sb.Append("\"@signature-params\": ").Append(paramsLine); if (!publicKey.Verify(Encoding.ASCII.GetBytes(sb.ToString()), signatureBytes)) diff --git a/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs b/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs index 2c5c0f2..bb77d4e 100644 --- a/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs +++ b/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text.Json.Nodes; using System.Threading.Tasks; +using AAuth.Agent; using AAuth.Crypto; using AAuth.Discovery; using AAuth.Errors; @@ -120,7 +121,8 @@ public async Task InvokeAsync(HttpContext context) signatureInput: signatureInput, signatureHeader: signature, publicKey: publicKey, - authorization: req.Headers.Authorization.FirstOrDefault()); + authorization: req.Headers.Authorization.FirstOrDefault(), + mission: req.Headers[AAuthMissionHeader.Name].FirstOrDefault()); } catch (AAuthVerificationException ex) { diff --git a/src/AAuth/Tokens/AuthTokenBuilder.cs b/src/AAuth/Tokens/AuthTokenBuilder.cs index d958a3a..9dc32ed 100644 --- a/src/AAuth/Tokens/AuthTokenBuilder.cs +++ b/src/AAuth/Tokens/AuthTokenBuilder.cs @@ -64,6 +64,13 @@ public sealed class AuthTokenBuilder /// Pairwise pseudonymous user identifier. public string? Subject { get; init; } + /// + /// Mission claim (mission) — present when the auth token was issued in + /// the context of a mission (§Auth Token Structure). Carries only approver + /// and s256; the mission content stays at the PS. + /// + public MissionClaim? Mission { get; init; } + /// /// Enterprise tenant claim (§Auth Token, OpenID Connect for /// Enterprise) — identifies the principal's tenant/organization within the @@ -191,6 +198,10 @@ public string Build() { payload["groups"] = ToJsonArray(Groups); } + if (Mission is not null) + { + payload["mission"] = Mission.ToJsonObject(); + } if (AdditionalClaims is not null) { foreach (var (k, v) in AdditionalClaims) diff --git a/src/AAuth/Tokens/MissionClaim.cs b/src/AAuth/Tokens/MissionClaim.cs new file mode 100644 index 0000000..6d8f267 --- /dev/null +++ b/src/AAuth/Tokens/MissionClaim.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Nodes; + +namespace AAuth.Tokens; + +/// +/// The mission claim carried in resource and auth tokens (§Resource Token +/// Structure, §Auth Token Structure). Identifies the mission by its approver and +/// s256 hash — the mission content itself never leaves the PS. +/// +/// HTTPS URL of the entity that approved the mission. +/// base64url(SHA-256) of the approved mission JSON. +public sealed record MissionClaim(string Approver, string S256) +{ + /// Render the claim as the JSON object embedded in a token payload. + public JsonObject ToJsonObject() => new() + { + ["approver"] = Approver, + ["s256"] = S256, + }; + + /// + /// Parse a mission claim from a token payload object. Returns + /// when the claim is absent or malformed. + /// + public static MissionClaim? FromPayload(JsonObject? payload) + { + if (payload?["mission"] is not JsonObject mission) + return null; + + var approver = (string?)mission["approver"]; + var s256 = (string?)mission["s256"]; + if (string.IsNullOrEmpty(approver) || string.IsNullOrEmpty(s256)) + return null; + + return new MissionClaim(approver, s256); + } +} diff --git a/src/AAuth/Tokens/ResourceTokenBuilder.cs b/src/AAuth/Tokens/ResourceTokenBuilder.cs index 519c491..fdada25 100644 --- a/src/AAuth/Tokens/ResourceTokenBuilder.cs +++ b/src/AAuth/Tokens/ResourceTokenBuilder.cs @@ -47,6 +47,13 @@ public sealed class ResourceTokenBuilder /// Requested scopes, space-separated (scope). public string? Scope { get; init; } + /// + /// Mission claim (mission) — present when the resource is mission-aware + /// and the agent sent an AAuth-Mission header (§Resource Token Structure). + /// Carries only approver and s256; the mission content stays at the PS. + /// + public MissionClaim? Mission { get; init; } + /// Lifetime; spec says SHOULD NOT exceed 5 minutes. Default 5 minutes. public TimeSpan Lifetime { get; init; } = TimeSpan.FromMinutes(5); @@ -120,6 +127,11 @@ public string Build() payload["scope"] = Scope; } + if (Mission is not null) + { + payload["mission"] = Mission.ToJsonObject(); + } + return JwtWriter.SignCompact(header, payload, Key); } diff --git a/src/AAuth/Tokens/TokenVerifier.cs b/src/AAuth/Tokens/TokenVerifier.cs index 16d69f7..5ec9e2f 100644 --- a/src/AAuth/Tokens/TokenVerifier.cs +++ b/src/AAuth/Tokens/TokenVerifier.cs @@ -33,7 +33,14 @@ public sealed record VerifiedToken( JsonObject Header, JsonObject Payload, string Issuer, - string TokenType); + string TokenType) + { + /// + /// The mission claim ({approver, s256}) when present, otherwise + /// (§Resource Token, §Auth Token). + /// + public MissionClaim? Mission => MissionClaim.FromPayload(Payload); + } /// /// Verify a token whose issuer's public key has already been resolved. diff --git a/tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs b/tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs new file mode 100644 index 0000000..b69a2ea --- /dev/null +++ b/tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Crypto; +using AAuth.HttpSig; +using Xunit; + +namespace AAuth.Conformance.HttpSignatures; + +/// +/// Conformance for covering the aauth-mission component when the agent +/// operates in a mission context (§Authorization Endpoint Request — the agent +/// includes the AAuth-Mission header and adds aauth-mission to the +/// signed components). +/// +public class MissionSignedComponentTests +{ + private const string Approver = "https://ps.example"; + private const string S256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + private sealed class CaptureHandler : HttpMessageHandler + { + public HttpRequestMessage? Captured { get; private set; } + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Captured = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + private static async Task Sign( + AAuthKey key, string token, DateTimeOffset clock, string? missionHeader) + { + var capture = new CaptureHandler(); + var pipeline = new AAuthSigningHandler(key, () => token, () => clock) { InnerHandler = capture }; + using var client = new HttpClient(pipeline); + var request = new HttpRequestMessage(HttpMethod.Get, "https://r.example/path"); + if (missionHeader is not null) + { + request.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, missionHeader); + } + await client.SendAsync(request); + return capture.Captured!; + } + + [Fact(DisplayName = "§Authorization Endpoint Request — aauth-mission covered when AAuth-Mission present")] + public async Task SignatureInput_CoversMission_WhenHeaderPresent() + { + var key = AAuthKey.Generate(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var mission = AAuthMissionHeader.FormatStructured(Approver, S256); + + var req = await Sign(key, "a.b.c", clock, mission); + var input = req.Headers.GetValues("Signature-Input").Single(); + + Assert.Contains("\"aauth-mission\"", input); + // §spec example: aauth-mission is the last covered component, after signature-key. + Assert.EndsWith("\"signature-key\" \"aauth-mission\");created=" + clock.ToUnixTimeSeconds(), input); + } + + [Fact(DisplayName = "§Authorization Endpoint Request — aauth-mission absent when no header")] + public async Task SignatureInput_OmitsMission_WhenHeaderAbsent() + { + var key = AAuthKey.Generate(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + + var req = await Sign(key, "a.b.c", clock, missionHeader: null); + var input = req.Headers.GetValues("Signature-Input").Single(); + + Assert.DoesNotContain("aauth-mission", input); + } + + [Fact(DisplayName = "§Authorization Endpoint Request — signed mission request round-trips through the verifier")] + public async Task SignedMissionRequest_VerifiesSuccessfully() + { + var key = AAuthKey.Generate(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var mission = AAuthMissionHeader.FormatStructured(Approver, S256); + + var req = await Sign(key, "a.b.c", clock, mission); + + var verifier = new AAuthVerifier { Clock = () => clock }; + verifier.Verify("GET", "r.example", "/path", + req.Headers.GetValues("Signature-Key").Single(), + req.Headers.GetValues("Signature-Input").Single(), + req.Headers.GetValues("Signature").Single(), + AAuthKey.FromJwk(key.ToPublicJwk()), + mission: req.Headers.GetValues(AAuthMissionHeader.Name).Single()); + } + + [Fact(DisplayName = "§Authorization Endpoint Request — verifier rejects when mission header present but uncovered")] + public async Task Verifier_Rejects_WhenMissionHeaderPresentButUncovered() + { + var key = AAuthKey.Generate(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + + // Sign WITHOUT the mission header so aauth-mission is not covered... + var req = await Sign(key, "a.b.c", clock, missionHeader: null); + + var verifier = new AAuthVerifier { Clock = () => clock }; + // ...but present the mission header at verification time. + Assert.Throws(() => + verifier.Verify("GET", "r.example", "/path", + req.Headers.GetValues("Signature-Key").Single(), + req.Headers.GetValues("Signature-Input").Single(), + req.Headers.GetValues("Signature").Single(), + AAuthKey.FromJwk(key.ToPublicJwk()), + mission: AAuthMissionHeader.FormatStructured(Approver, S256))); + } + + [Fact(DisplayName = "§Authorization Endpoint Request — aauth-mission not double-covered when explicitly requested")] + public async Task SignatureInput_DoesNotDoubleCoverMission() + { + var key = AAuthKey.Generate(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var mission = AAuthMissionHeader.FormatStructured(Approver, S256); + + var capture = new CaptureHandler(); + var pipeline = new AAuthSigningHandler(key, () => "a.b.c", () => clock) { InnerHandler = capture }; + using var client = new HttpClient(pipeline); + var request = new HttpRequestMessage(HttpMethod.Get, "https://r.example/path"); + request.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, mission); + // Explicitly also request aauth-mission as an additional component. + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, + Array.AsReadOnly(new[] { "aauth-mission" })); + await client.SendAsync(request); + + var input = capture.Captured!.Headers.GetValues("Signature-Input").Single(); + var occurrences = input.Split("\"aauth-mission\"").Length - 1; + Assert.Equal(1, occurrences); + } +} diff --git a/tests/AAuth.Conformance/Missions/MissionClaimTests.cs b/tests/AAuth.Conformance/Missions/MissionClaimTests.cs new file mode 100644 index 0000000..4da20a4 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/MissionClaimTests.cs @@ -0,0 +1,149 @@ +using System.Text.Json.Nodes; +using AAuth.Crypto; +using AAuth.Tokens; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the optional mission claim ({approver, s256}) carried in +/// resource and auth tokens (§Resource Token Structure, §Auth Token Structure). +/// +public class MissionClaimTests +{ + private const string Iss = "https://resource.example"; + private const string Aud = "https://ps.example"; + private const string Agent = "aauth:alice@ap.example"; + private const string Approver = "https://ps.example"; + private const string S256 = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"; + + private static JsonObject PayloadOf(string jwt) + { + var parts = jwt.Split('.'); + return (JsonObject)JsonNode.Parse(Base64UrlEncoder.Decode(parts[1]))!; + } + + [Fact(DisplayName = "§Resource Token Structure — mission omitted when not set")] + public void ResourceToken_OmitsMission_WhenNotSet() + { + var jwt = new ResourceTokenBuilder + { + Issuer = Iss, + Audience = Aud, + Agent = Agent, + AgentJkt = "thumb", + Key = AAuthKey.Generate(), + KeyId = "r1", + Scope = "whoami", + }.Build(); + + Assert.False(PayloadOf(jwt).ContainsKey("mission")); + } + + [Fact(DisplayName = "§Resource Token Structure — mission emitted as {approver, s256} when set")] + public void ResourceToken_EmitsMission_WhenSet() + { + var jwt = new ResourceTokenBuilder + { + Issuer = Iss, + Audience = Aud, + Agent = Agent, + AgentJkt = "thumb", + Key = AAuthKey.Generate(), + KeyId = "r1", + Scope = "whoami", + Mission = new MissionClaim(Approver, S256), + }.Build(); + + var mission = PayloadOf(jwt)["mission"] as JsonObject; + Assert.NotNull(mission); + Assert.Equal(Approver, (string?)mission!["approver"]); + Assert.Equal(S256, (string?)mission["s256"]); + } + + [Fact(DisplayName = "§Auth Token Structure — mission omitted when not set")] + public void AuthToken_OmitsMission_WhenNotSet() + { + var jwt = new AuthTokenBuilder + { + Issuer = Aud, + Audience = Iss, + Agent = Agent, + AgentConfirmationKey = AAuthKey.Generate(), + Key = AAuthKey.Generate(), + KeyId = "p1", + Scope = "whoami", + }.Build(); + + Assert.False(PayloadOf(jwt).ContainsKey("mission")); + } + + [Fact(DisplayName = "§Auth Token Structure — mission emitted as {approver, s256} when set")] + public void AuthToken_EmitsMission_WhenSet() + { + var jwt = new AuthTokenBuilder + { + Issuer = Aud, + Audience = Iss, + Agent = Agent, + AgentConfirmationKey = AAuthKey.Generate(), + Key = AAuthKey.Generate(), + KeyId = "p1", + Scope = "whoami", + Mission = new MissionClaim(Approver, S256), + }.Build(); + + var mission = PayloadOf(jwt)["mission"] as JsonObject; + Assert.NotNull(mission); + Assert.Equal(Approver, (string?)mission!["approver"]); + Assert.Equal(S256, (string?)mission["s256"]); + } + + [Fact(DisplayName = "§Auth Token Verification — VerifiedToken.Mission surfaces the verified claim")] + public void VerifiedToken_SurfacesMission() + { + var issuerKey = AAuthKey.Generate(); + var agentKey = AAuthKey.Generate(); + var jwt = new AuthTokenBuilder + { + Issuer = Aud, + Audience = Iss, + Agent = Agent, + AgentConfirmationKey = agentKey, + Key = issuerKey, + KeyId = "p1", + Scope = "whoami", + Mission = new MissionClaim(Approver, S256), + }.Build(); + + var verified = new TokenVerifier().VerifyAuthToken( + jwt, issuerKey, Iss, agentKey, Agent); + + Assert.NotNull(verified.Mission); + Assert.Equal(Approver, verified.Mission!.Approver); + Assert.Equal(S256, verified.Mission.S256); + } + + [Fact(DisplayName = "§Auth Token Verification — VerifiedToken.Mission is null when absent")] + public void VerifiedToken_MissionNull_WhenAbsent() + { + var issuerKey = AAuthKey.Generate(); + var agentKey = AAuthKey.Generate(); + var jwt = new AuthTokenBuilder + { + Issuer = Aud, + Audience = Iss, + Agent = Agent, + AgentConfirmationKey = agentKey, + Key = issuerKey, + KeyId = "p1", + Scope = "whoami", + }.Build(); + + var verified = new TokenVerifier().VerifyAuthToken( + jwt, issuerKey, Iss, agentKey, Agent); + + Assert.Null(verified.Mission); + } +} From 475fdac569bb4485730a96a7c988c6404a9c1fae Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 17:22:58 +0000 Subject: [PATCH 03/24] feat(missions): add PS token-request params, clarification chat, and mission_terminated - TokenExchangeRequest: justification/login_hint/tenant/domain_hint/platform/device - ClarificationRequirement parsing + ClarificationExchange (respond/update/cancel) with default 5-round limit and OnClarificationRequired callback - TokenErrorCode.MissionTerminated + AAuthMissionTerminatedException, classified on token response and during polling Phase 3 of missions/PS governance. --- .../implementation-plan.md | 26 +- .../research.md | 53 ++++ src/AAuth/Agent/AAuthInteractionExceptions.cs | 29 ++ src/AAuth/Agent/ClarificationExchange.cs | 197 ++++++++++++++ src/AAuth/Agent/TokenExchangeClient.cs | 226 ++++++++++++---- src/AAuth/Agent/TokenExchangeRequest.cs | 54 ++++ .../Errors/AAuthMissionTerminatedException.cs | 39 +++ src/AAuth/Errors/TokenError.cs | 12 +- src/AAuth/Headers/ClarificationRequirement.cs | 96 +++++++ .../Missions/ClarificationChatTests.cs | 253 ++++++++++++++++++ .../Missions/MissionTerminatedTests.cs | 119 ++++++++ .../Missions/TokenRequestParamsTests.cs | 100 +++++++ 12 files changed, 1146 insertions(+), 58 deletions(-) create mode 100644 src/AAuth/Agent/ClarificationExchange.cs create mode 100644 src/AAuth/Errors/AAuthMissionTerminatedException.cs create mode 100644 src/AAuth/Headers/ClarificationRequirement.cs create mode 100644 tests/AAuth.Conformance/Missions/ClarificationChatTests.cs create mode 100644 tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs create mode 100644 tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index 3c3be34..9043328 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -172,26 +172,32 @@ clarification`; agent responses: `clarification_response` POST / updated | File | Action | |------|--------| -| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — add `Justification`, `LoginHint`, `Tenant`, `DomainHint`, `Platform`, `Device` | -| `src/AAuth/Agent/TokenExchangeClient.cs` | **Modify** — emit new params; detect `requirement=clarification` | -| `src/AAuth/Agent/DeferredPoller.cs` | **Modify** — allow POST/DELETE to pending URL | -| `src/AAuth/Agent/ClarificationExchange.cs` | **New** — respond / update / cancel actions + round tracking | +| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — add `Justification`, `LoginHint`, `Tenant`, `DomainHint`, `Platform`, `Device`, `OnClarificationRequired`, `MaxClarificationRounds` | +| `src/AAuth/Agent/TokenExchangeClient.cs` | **Modify** — emit new params; clarification loop; `mission_terminated` classification | +| `src/AAuth/Agent/ClarificationExchange.cs` | **New** — `ClarificationResponse` decision object + respond / update / cancel actions + round tracking | +| `src/AAuth/Agent/AAuthInteractionExceptions.cs` | **Modify** — add `AAuthClarificationCancelledException`, `AAuthClarificationLimitException` | | `src/AAuth/Headers/ClarificationRequirement.cs` | **New** — parse `{clarification, timeout?, options?}` | | `src/AAuth/Errors/TokenError.cs` | **Modify** — add `mission_terminated` | | `src/AAuth/Errors/AAuthMissionTerminatedException.cs` | **New** | | `tests/AAuth.Conformance/Missions/ClarificationChatTests.cs` | **New** | | `tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs` | **New** | +| `tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs` | **New** — covers the six token-request params | + +> **Deviation:** `DeferredPoller.cs` was **not** modified. POST/DELETE to the +> pending URL live in `ClarificationExchange` (its own `HttpClient`), and the +> clarification stop reuses the existing `DeferredPollerOptions.StopWhenAccepted` +> predicate (composed via `ComposePollerOptions`) — see research Part 5, Phase 3. ### Definition of Done -- [ ] All six token-request params serialized into the POST body (§Agent Token Request). -- [ ] `requirement=clarification` parsed into a typed model (question/timeout/options). -- [ ] Agent can `clarification_response` POST, updated-`resource_token` POST, and +- [x] All six token-request params serialized into the POST body (§Agent Token Request). +- [x] `requirement=clarification` parsed into a typed model (question/timeout/options). +- [x] Agent can `clarification_response` POST, updated-`resource_token` POST, and `DELETE`-cancel against the pending URL (§Agent Response to Clarification). -- [ ] Clarification round limit enforced (default 5) (§Clarification Limits). -- [ ] `403 mission_terminated` → `AAuthMissionTerminatedException` across PS calls +- [x] Clarification round limit enforced (default 5) (§Clarification Limits). +- [x] `403 mission_terminated` → `AAuthMissionTerminatedException` across PS calls (§Mission Status Errors). -- [ ] New tests pass; existing deferred/interaction tests unaffected. +- [x] New tests pass; existing deferred/interaction tests unaffected. --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index 616b5fc..b1d8bb3 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -334,3 +334,56 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a exists in the conformance project). - **Validation:** 375 conformance (+11) + 371 unit tests green; full solution builds 0/0. + +### Phase 3 — PS token-request params, clarification chat, mission errors (2026-06-05, complete) + +- **Token-request params (§Agent Token Request).** Added six optional `string?` + properties to `TokenExchangeRequest` — `Justification`, `LoginHint`, `Tenant`, + `DomainHint`, `Platform`, `Device` — serialized into the POST body as + `justification`, `login_hint`, `tenant`, `domain_hint`, `platform`, `device` + via a new `AddIfPresent` helper that omits unset/empty values. +- **Clarification model (§Clarification Chat).** `ClarificationRequirement` + (`src/AAuth/Headers/ClarificationRequirement.cs`) parses the `202` body + `{clarification, timeout?, options?}` for `requirement=clarification`, + modeled on the existing `ClaimsRequirement`. Throws `FormatException` when the + `clarification` string is missing. +- **Clarification API design (D7, user-approved).** The agent supplies a + callback `OnClarificationRequired` on `TokenExchangeRequest` (mirrors + `OnInteractionRequired`) that returns a `ClarificationResponse` *decision* + object. `ExchangeAsync`'s response handling was rewritten into a + `while (StatusCode == 202)` loop that resolves the requirement, dispatches + interaction vs. clarification, applies the decision, and re-polls. +- **ClarificationResponse + ClarificationExchange.** `ClarificationResponse` + (nested `Kind { Respond, Update, Cancel }`) carries the agent's choice; the + factories are `Respond(markdown)`, `Update(resourceToken, justification?)`, + `Cancel()`. `ClarificationExchange` performs the wire calls against the pending + URL: `clarification_response` POST, updated `resource_token` POST, and `DELETE` + cancel (which surfaces `AAuthClarificationCancelledException`). +- **Round limit (§Clarification Limits).** Default `MaxRounds = 5` (configurable + via `TokenExchangeRequest.MaxClarificationRounds`); `Respond`/`Update` consume a + round, `Cancel` does not. Exceeding the limit throws + `AAuthClarificationLimitException`. +- **DEVIATION FROM PLAN FILE LIST.** The plan listed `DeferredPoller.cs` as + **Modify — allow POST/DELETE to pending URL**. In implementation the POST/DELETE + calls live entirely in `ClarificationExchange` (using its own `HttpClient`), + and the clarification-stop during polling reuses the **existing** + `DeferredPollerOptions.StopWhenAccepted` predicate (composed via + `ComposePollerOptions`). `DeferredPoller.cs` was therefore **not** modified. +- **Mission-terminated (§Mission Status Errors).** Added `TokenErrorCode. + MissionTerminated` (`mission_terminated`, round-trips through `TokenErrorResponse`) + and `AAuthMissionTerminatedException` (with `MissionStatus`). `ExchangeAsync` + classifies a terminal `403` body `{error:"mission_terminated", mission_status}` + via `TryReadMissionTerminatedAsync` — both on the direct token response and on a + `403` surfaced during polling (the poller returns the unrecognized `403` rather + than throwing, so the client classifies it). A shared `BufferBodyAsync` lets the + `access_denied`, `mission_terminated`, and auth-token readers all re-read the body. +- **Files:** new `Headers/ClarificationRequirement.cs`, `Agent/ClarificationExchange.cs` + (holds `ClarificationResponse` + `ClarificationExchange`), + `Errors/AAuthMissionTerminatedException.cs`; modified `Agent/TokenExchangeRequest.cs`, + `Agent/TokenExchangeClient.cs`, `Agent/AAuthInteractionExceptions.cs` (added + `AAuthClarificationCancelledException`, `AAuthClarificationLimitException`), + `Errors/TokenError.cs`. **Tests:** `Missions/ClarificationChatTests.cs` (8), + `Missions/MissionTerminatedTests.cs` (3), and an **added** (not in original plan + list) `Missions/TokenRequestParamsTests.cs` (2) covering the six params. +- **Validation:** 388 conformance (+13) + 371 unit tests green; full solution + builds 0/0. diff --git a/src/AAuth/Agent/AAuthInteractionExceptions.cs b/src/AAuth/Agent/AAuthInteractionExceptions.cs index 33b1f5d..4277f7b 100644 --- a/src/AAuth/Agent/AAuthInteractionExceptions.cs +++ b/src/AAuth/Agent/AAuthInteractionExceptions.cs @@ -89,3 +89,32 @@ public AAuthInteractionChainedException(Interaction interaction, string message) Interaction = interaction; } } + +/// +/// Thrown when the agent cancels an in-flight token exchange in response to a +/// clarification by DELETE-ing the pending URL (AAuth protocol §Cancel +/// Request). The PS terminates the consent session; subsequent requests to the +/// pending URL return 410 Gone. +/// +public sealed class AAuthClarificationCancelledException : Exception +{ + public AAuthClarificationCancelledException(string message) + : base(message) { } +} + +/// +/// Thrown when a clarification chat exceeds the configured maximum number of +/// rounds (AAuth protocol §Clarification Limits, recommended 5). Guards the +/// agent against an unbounded back-and-forth with the PS. +/// +public sealed class AAuthClarificationLimitException : Exception +{ + /// The round limit that was exceeded. + public int MaxRounds { get; } + + public AAuthClarificationLimitException(int maxRounds) + : base($"Clarification chat exceeded the maximum of {maxRounds} round(s).") + { + MaxRounds = maxRounds; + } +} diff --git a/src/AAuth/Agent/ClarificationExchange.cs b/src/AAuth/Agent/ClarificationExchange.cs new file mode 100644 index 0000000..c647164 --- /dev/null +++ b/src/AAuth/Agent/ClarificationExchange.cs @@ -0,0 +1,197 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace AAuth.Agent; + +/// +/// The action an agent chooses in response to a clarification question +/// (AAuth protocol §Agent Response to Clarification). One of: a +/// (answer the question), an +/// (replace the request with a new resource token), or a +/// (withdraw the request). +/// +public sealed class ClarificationResponse +{ + /// The kind of response. + public enum Kind + { + /// Answer the question with a Markdown explanation. + Respond, + + /// Replace the request with an updated resource token. + Update, + + /// Withdraw the request entirely. + Cancel, + } + + /// Which action this response represents. + public Kind Action { get; } + + /// The Markdown answer (for ). + public string? Markdown { get; } + + /// The replacement resource token (for ). + public string? ResourceToken { get; } + + /// Optional justification for an updated request. + public string? Justification { get; } + + private ClarificationResponse(Kind action, string? markdown, string? resourceToken, string? justification) + { + Action = action; + Markdown = markdown; + ResourceToken = resourceToken; + Justification = justification; + } + + /// Answer the clarification with a Markdown explanation. + public static ClarificationResponse Respond(string markdown) + { + ArgumentException.ThrowIfNullOrEmpty(markdown); + return new ClarificationResponse(Kind.Respond, markdown, null, null); + } + + /// + /// Replace the original request with a new resource token (e.g. reduced + /// scope). A is optional but RECOMMENDED. + /// + public static ClarificationResponse Update(string resourceToken, string? justification = null) + { + ArgumentException.ThrowIfNullOrEmpty(resourceToken); + return new ClarificationResponse(Kind.Update, null, resourceToken, justification); + } + + /// Withdraw the request. + public static ClarificationResponse Cancel() + => new(Kind.Cancel, null, null, null); +} + +/// +/// Drives the agent side of a clarification chat against a deferred-response +/// pending URL (AAuth protocol §Agent Response to Clarification): posting a +/// clarification response, posting an updated request, or cancelling. Tracks +/// the number of rounds and enforces a maximum (§Clarification Limits). +/// +/// +/// The supplied is expected to be wired with the +/// agent's so each POST/DELETE to +/// the pending URL is signed — the PS rejects otherwise. +/// +public sealed class ClarificationExchange +{ + /// Spec-recommended default maximum clarification rounds. + public const int DefaultMaxRounds = 5; + + private readonly HttpClient _signedClient; + private readonly Uri _pendingUrl; + + /// The maximum number of clarification rounds permitted. + public int MaxRounds { get; } + + /// The number of clarification rounds consumed so far. + public int Rounds { get; private set; } + + /// Create a clarification exchange bound to a pending URL. + /// HttpClient pre-wired with the agent's signing handler. + /// Absolute pending URL (the deferred Location value). + /// Maximum clarification rounds (default 5). + public ClarificationExchange(HttpClient signedClient, Uri pendingUrl, int maxRounds = DefaultMaxRounds) + { + ArgumentNullException.ThrowIfNull(signedClient); + ArgumentNullException.ThrowIfNull(pendingUrl); + if (!pendingUrl.IsAbsoluteUri) + { + throw new ArgumentException("Pending URL must be absolute.", nameof(pendingUrl)); + } + if (maxRounds < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxRounds), "Max rounds must be at least 1."); + } + _signedClient = signedClient; + _pendingUrl = pendingUrl; + MaxRounds = maxRounds; + } + + /// + /// Apply to the pending URL. Returns + /// when the agent should resume polling (respond or + /// update), or throws -style + /// terminal exceptions where appropriate. A cancel throws + /// after withdrawing. + /// + public async Task ApplyAsync(ClarificationResponse response, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(response); + switch (response.Action) + { + case ClarificationResponse.Kind.Respond: + await RespondAsync(response.Markdown!, cancellationToken).ConfigureAwait(false); + break; + case ClarificationResponse.Kind.Update: + await UpdateRequestAsync(response.ResourceToken!, response.Justification, cancellationToken) + .ConfigureAwait(false); + break; + case ClarificationResponse.Kind.Cancel: + await CancelAsync(cancellationToken).ConfigureAwait(false); + throw new AAuthClarificationCancelledException( + "The agent withdrew its request during clarification."); + default: + throw new ArgumentOutOfRangeException(nameof(response)); + } + } + + /// POST a Markdown clarification response to the pending URL. + public async Task RespondAsync(string markdown, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(markdown); + EnterRound(); + var body = new JsonObject { ["clarification_response"] = markdown }; + await PostAsync(body, cancellationToken).ConfigureAwait(false); + } + + /// POST an updated resource token (and optional justification) to the pending URL. + public async Task UpdateRequestAsync( + string resourceToken, string? justification = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(resourceToken); + EnterRound(); + var body = new JsonObject { ["resource_token"] = resourceToken }; + if (!string.IsNullOrEmpty(justification)) + { + body["justification"] = justification; + } + await PostAsync(body, cancellationToken).ConfigureAwait(false); + } + + /// DELETE the pending URL to withdraw the request. + public async Task CancelAsync(CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, _pendingUrl); + using var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + private void EnterRound() + { + if (Rounds >= MaxRounds) + { + throw new AAuthClarificationLimitException(MaxRounds); + } + Rounds++; + } + + private async Task PostAsync(JsonObject body, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, _pendingUrl) + { + Content = JsonContent.Create(body), + }; + using var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/AAuth/Agent/TokenExchangeClient.cs b/src/AAuth/Agent/TokenExchangeClient.cs index 17a8429..4338629 100644 --- a/src/AAuth/Agent/TokenExchangeClient.cs +++ b/src/AAuth/Agent/TokenExchangeClient.cs @@ -121,7 +121,7 @@ public async Task ExchangeAsync( // handle a 202 + user-facing consent redirect). Spec §AAuth-Capabilities // plus -02 token endpoint parameter. null = infer from flow; an explicit // (possibly empty) list overrides. - var resolvedCapabilities = capabilities ?? InferCapabilities(onInteractionRequired); + var resolvedCapabilities = capabilities ?? InferCapabilities(onInteractionRequired, options.OnClarificationRequired); if (resolvedCapabilities.Count > 0) { var caps = new JsonArray(); @@ -137,6 +137,14 @@ public async Task ExchangeAsync( { body["prompt"] = prompt; } + // Optional consent/display parameters (§Agent Token Request). Each is + // emitted only when set. + AddIfPresent(body, "justification", options.Justification); + AddIfPresent(body, "login_hint", options.LoginHint); + AddIfPresent(body, "tenant", options.Tenant); + AddIfPresent(body, "domain_hint", options.DomainHint); + AddIfPresent(body, "platform", options.Platform); + AddIfPresent(body, "device", options.Device); using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpointUri) { Content = JsonContent.Create(body), @@ -150,46 +158,63 @@ public async Task ExchangeAsync( } var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + ClarificationExchange? clarificationExchange = null; try { - // Deferred response: the PS needs user interaction (consent / - // authentication) before it can issue an auth token. The - // Location header carries the pending URL the agent polls; - // the AAuth-Requirement header carries the user-facing URL+code. - if (response.StatusCode == HttpStatusCode.Accepted) + // Resolve any deferred (202) requirements — user interaction and/or + // clarification chat — looping until the PS returns a terminal + // response (§User Interaction, §Clarification Chat). + while (response.StatusCode == HttpStatusCode.Accepted) { + var pendingUrl = ResolveLocation(response, tokenEndpointUri); + var requirement = ExtractRequirement(response); + + // §Clarification Chat: the PS is asking the agent a question + // during consent. Surface it via the callback, apply the agent's + // chosen action against the pending URL, then resume polling. + if (requirement?.Requirement == Headers.ClarificationRequirement.RequirementType) + { + var clarificationBody = await ReadJsonBodyAsync(response, cancellationToken).ConfigureAwait(false); + var clarification = Headers.ClarificationRequirement.FromResponse(requirement, clarificationBody); + response.Dispose(); + + if (options.OnClarificationRequired is null) + { + throw new HttpRequestException( + "PS returned requirement=clarification but no OnClarificationRequired callback was provided."); + } + + clarificationExchange ??= new ClarificationExchange( + _signedClient, pendingUrl, options.MaxClarificationRounds); + var decision = await options.OnClarificationRequired(clarification!, cancellationToken) + .ConfigureAwait(false); + await clarificationExchange.ApplyAsync(decision, cancellationToken).ConfigureAwait(false); + + response = await PollPendingAsync(pendingUrl, pollerOptions, cancellationToken).ConfigureAwait(false); + continue; + } + + // §User Interaction: out-of-band consent. The agent relays the + // URL+code to the user and then polls for completion. if (onInteractionRequired is null) { throw new HttpRequestException( $"PS returned {(int)response.StatusCode} (deferred response) but no onInteractionRequired callback was provided."); } - var interaction = ExtractInteraction(response); + var interaction = requirement is null ? null : Interaction.FromRequirement(requirement); + response.Dispose(); if (interaction is not null) { await onInteractionRequired(interaction, cancellationToken).ConfigureAwait(false); } - var pendingUrl = ResolveLocation(response, tokenEndpointUri); - response.Dispose(); - try - { - using var pollActivity = AAuthDiagnostics.Source.StartActivity("AAuth.DeferredPoll"); - response = await new DeferredPoller(_signedClient, pollerOptions) - .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); - } - catch (TimeoutException ex) - { - throw new AAuthInteractionTimeoutException( - $"PS deferred interaction did not complete within the polling budget: {ex.Message}", - ex); - } + response = await PollPendingAsync(pendingUrl, pollerOptions, cancellationToken).ConfigureAwait(false); - // 403 access_denied → user explicitly denied. Surface a - // distinct typed exception so UIs / retry policies can - // treat denial differently from "unknown id" (404) or - // transport failure. + // 403 access_denied → user explicitly denied. Surface a distinct + // typed exception so UIs / retry policies can treat denial + // differently from "unknown id" (404) or transport failure. if (response.StatusCode == HttpStatusCode.Forbidden && await IsAccessDeniedAsync(response, cancellationToken).ConfigureAwait(false)) { @@ -199,6 +224,17 @@ public async Task ExchangeAsync( } } + // §Mission Status Errors: a 403 mission_terminated means the request + // referenced a mission that is no longer active. Terminal — the + // agent must stop acting on the mission. + if (response.StatusCode == HttpStatusCode.Forbidden + && await TryReadMissionTerminatedAsync(response, cancellationToken).ConfigureAwait(false) + is var (terminated, missionStatus) && terminated) + { + response.Dispose(); + throw new Errors.AAuthMissionTerminatedException(missionStatus); + } + return await ReadAuthTokenAsync(response, cancellationToken).ConfigureAwait(false); } finally @@ -207,22 +243,117 @@ public async Task ExchangeAsync( } } - // Default capability inference: declare "interaction" when the caller - // can handle a 202 + user-facing consent redirect. An explicit - // capabilities list passed to ExchangeAsync overrides this. + // Poll the pending URL, translating a poll-budget timeout into the typed + // interaction-timeout exception and stopping early on a clarification 202 so + // the exchange loop can handle it (composing with any caller predicate). + private async Task PollPendingAsync( + Uri pendingUrl, DeferredPollerOptions? pollerOptions, CancellationToken cancellationToken) + { + var composed = ComposePollerOptions(pollerOptions); + try + { + using var pollActivity = AAuthDiagnostics.Source.StartActivity("AAuth.DeferredPoll"); + return await new DeferredPoller(_signedClient, composed) + .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new AAuthInteractionTimeoutException( + $"PS deferred interaction did not complete within the polling budget: {ex.Message}", + ex); + } + } + + // Compose poller options so polling stops on a clarification 202 (returning + // it to the exchange loop), preserving any caller-supplied StopWhenAccepted. + private static DeferredPollerOptions ComposePollerOptions(DeferredPollerOptions? baseOptions) + { + var userStop = baseOptions?.StopWhenAccepted; + bool Stop(HttpResponseMessage resp) + { + if (userStop is not null && userStop(resp)) { return true; } + var requirement = ExtractRequirement(resp); + return requirement?.Requirement == Headers.ClarificationRequirement.RequirementType; + } + + return baseOptions is null + ? new DeferredPollerOptions { StopWhenAccepted = Stop } + : baseOptions with { StopWhenAccepted = Stop }; + } + + private static void AddIfPresent(JsonObject body, string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + body[name] = value; + } + } + + // Default capability inference: declare "interaction" when the caller can + // handle a 202 + user-facing consent redirect, and "clarification" when the + // caller can answer clarification questions. An explicit capabilities list + // passed to ExchangeAsync overrides this. private static IReadOnlyList InferCapabilities( - Func? onInteractionRequired) - => onInteractionRequired is not null - ? new[] { "interaction" } - : Array.Empty(); + Func? onInteractionRequired, + Delegate? onClarificationRequired) + { + var capabilities = new List(); + if (onInteractionRequired is not null) + { + capabilities.Add("interaction"); + } + if (onClarificationRequired is not null) + { + capabilities.Add("clarification"); + } + return capabilities; + } private static async Task IsAccessDeniedAsync( HttpResponseMessage response, CancellationToken cancellationToken) { // Buffer the body so the subsequent ReadAuthTokenAsync (if we - // decide it isn't access_denied) still sees it. Preserve the - // original Content-Type so downstream JSON parsers don't see a - // surprise text/plain media type. + // decide it isn't access_denied) still sees it. + var body = await BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + try + { + var json = JsonNode.Parse(body) as JsonObject; + return (string?)json?["error"] == "access_denied"; + } + catch (System.Text.Json.JsonException) + { + return false; + } + } + + // §Mission Status Errors: detect a 403 mission_terminated body. Buffers the + // body so a non-matching response still flows to ReadAuthTokenAsync. + // Returns (terminated, mission_status). + private static async Task<(bool Terminated, string? MissionStatus)> TryReadMissionTerminatedAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + try + { + var json = JsonNode.Parse(body) as JsonObject; + if ((string?)json?["error"] == Errors.AAuthMissionTerminatedException.ErrorCode) + { + return (true, (string?)json?["mission_status"]); + } + return (false, null); + } + catch (System.Text.Json.JsonException) + { + return (false, null); + } + } + + // Read a response body to a string and replace the Content with a buffered + // copy (preserving media type / charset) so it can be read again — e.g. by + // a subsequent error classifier or ReadAuthTokenAsync. + private static async Task BufferBodyAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var originalMediaType = response.Content.Headers.ContentType?.MediaType ?? "application/json"; var originalCharset = response.Content.Headers.ContentType?.CharSet; @@ -242,18 +373,22 @@ private static async Task IsAccessDeniedAsync( } response.Content.Dispose(); response.Content = new StringContent(body, encoding, originalMediaType); - try - { - var json = JsonNode.Parse(body) as JsonObject; - return (string?)json?["error"] == "access_denied"; - } - catch (System.Text.Json.JsonException) + return body; + } + + private static async Task ReadJsonBodyAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) { - return false; + return null; } + try { return JsonNode.Parse(body) as JsonObject; } + catch (System.Text.Json.JsonException) { return null; } } - private static Interaction? ExtractInteraction(HttpResponseMessage response) + private static AAuthRequirementHeader.ParsedRequirement? ExtractRequirement(HttpResponseMessage response) { if (!response.Headers.TryGetValues(AAuthRequirementHeader.Name, out var values)) { @@ -262,11 +397,8 @@ private static async Task IsAccessDeniedAsync( foreach (var raw in values) { if (string.IsNullOrWhiteSpace(raw)) { continue; } - AAuthRequirementHeader.ParsedRequirement parsed; - try { parsed = AAuthRequirementHeader.Parse(raw); } + try { return AAuthRequirementHeader.Parse(raw); } catch (FormatException) { continue; } - var interaction = Interaction.FromRequirement(parsed); - if (interaction is not null) { return interaction; } } return null; } diff --git a/src/AAuth/Agent/TokenExchangeRequest.cs b/src/AAuth/Agent/TokenExchangeRequest.cs index 3f3d048..16efd2b 100644 --- a/src/AAuth/Agent/TokenExchangeRequest.cs +++ b/src/AAuth/Agent/TokenExchangeRequest.cs @@ -47,4 +47,58 @@ public sealed class TokenExchangeRequest /// prompt is sent. /// public string? Prompt { get; init; } + + /// + /// Optional Markdown justification declaring why access is requested; + /// the PS SHOULD present it to the user during consent (§Agent Token Request). + /// + public string? Justification { get; init; } + + /// + /// Optional OIDC login_hint about who to authorize + /// ([@!OpenID.Core] §3.1.2.1, §Agent Token Request). + /// + public string? LoginHint { get; init; } + + /// + /// Optional tenant identifier (OpenID Connect Enterprise Extensions, + /// §Agent Token Request). + /// + public string? Tenant { get; init; } + + /// + /// Optional domain_hint (OpenID Connect Enterprise Extensions, + /// §Agent Token Request). + /// + public string? DomainHint { get; init; } + + /// + /// Optional platform identifier for the agent's runtime platform. + /// MUST be a value from the AAuth Platform Value Registry; used for display + /// at the PS consent screen / connected-agents dashboard (§Agent Token Request). + /// + public string? Platform { get; init; } + + /// + /// Optional device string identifying the device/browser for display + /// (e.g. "Chrome on macOS"). MUST be printable UTF-8, ≤ 64 characters, + /// no control characters or PII (§Agent Token Request). + /// + public string? Device { get; init; } + + /// + /// Invoked when the PS returns 202 with + /// requirement=clarification during consent. The callback receives + /// the parsed question and returns the agent's chosen + /// (respond / update / cancel), which + /// the exchange applies before resuming polling (§Clarification Chat). If + /// and the PS asks for clarification, the call throws. + /// + public Func>? OnClarificationRequired { get; init; } + + /// + /// Maximum number of clarification rounds the agent will engage in before + /// giving up (§Clarification Limits, default 5). + /// + public int MaxClarificationRounds { get; init; } = ClarificationExchange.DefaultMaxRounds; } diff --git a/src/AAuth/Errors/AAuthMissionTerminatedException.cs b/src/AAuth/Errors/AAuthMissionTerminatedException.cs new file mode 100644 index 0000000..a14872e --- /dev/null +++ b/src/AAuth/Errors/AAuthMissionTerminatedException.cs @@ -0,0 +1,39 @@ +using System; + +namespace AAuth.Errors; + +/// +/// Thrown when an agent makes a request to a PS endpoint carrying a +/// mission parameter that references a mission that is no longer +/// active. The PS responds with 403 Forbidden and a JSON body +/// { "error": "mission_terminated", "mission_status": "terminated" } +/// (AAuth protocol §Mission Status Errors). The agent MUST stop acting on +/// the mission. +/// +/// +/// Distinct from (token-endpoint +/// error bodies) and the agent-side interaction exceptions so callers can +/// branch specifically on a terminated mission and unwind the mission's work. +/// +public sealed class AAuthMissionTerminatedException : Exception +{ + /// The wire error code: mission_terminated. + public const string ErrorCode = "mission_terminated"; + + /// The mission_status value from the response body, when present. + public string? MissionStatus { get; } + + /// Create a mission-terminated exception. + public AAuthMissionTerminatedException(string? missionStatus = null) + : base("The mission is permanently terminated; the agent must stop acting on it.") + { + MissionStatus = missionStatus; + } + + /// Create a mission-terminated exception with a custom message. + public AAuthMissionTerminatedException(string message, string? missionStatus) + : base(message) + { + MissionStatus = missionStatus; + } +} diff --git a/src/AAuth/Errors/TokenError.cs b/src/AAuth/Errors/TokenError.cs index 6ca671c..2975039 100644 --- a/src/AAuth/Errors/TokenError.cs +++ b/src/AAuth/Errors/TokenError.cs @@ -31,6 +31,13 @@ public enum TokenErrorCode /// UserUnreachable, + /// + /// The request carried a mission parameter referencing a mission + /// that is no longer active. Terminal (HTTP 403) per §Mission Status + /// Errors; the agent MUST stop acting on the mission. + /// + MissionTerminated, + /// Internal error. ServerError, } @@ -52,6 +59,7 @@ public sealed record TokenErrorResponse(TokenErrorCode Error, string? ErrorDescr TokenErrorCode.ExpiredResourceToken => "expired_resource_token", TokenErrorCode.InteractionRequired => "interaction_required", TokenErrorCode.UserUnreachable => "user_unreachable", + TokenErrorCode.MissionTerminated => "mission_terminated", TokenErrorCode.ServerError => "server_error", _ => "server_error", }; @@ -68,11 +76,13 @@ public static bool TryParseCode(string? code, out TokenErrorCode result) "expired_resource_token" => TokenErrorCode.ExpiredResourceToken, "interaction_required" => TokenErrorCode.InteractionRequired, "user_unreachable" => TokenErrorCode.UserUnreachable, + "mission_terminated" => TokenErrorCode.MissionTerminated, "server_error" => TokenErrorCode.ServerError, _ => default, }; return code is "invalid_request" or "invalid_agent_token" or "expired_agent_token" or "invalid_resource_token" or "expired_resource_token" - or "interaction_required" or "user_unreachable" or "server_error"; + or "interaction_required" or "user_unreachable" or "mission_terminated" + or "server_error"; } } diff --git a/src/AAuth/Headers/ClarificationRequirement.cs b/src/AAuth/Headers/ClarificationRequirement.cs new file mode 100644 index 0000000..9cd23d0 --- /dev/null +++ b/src/AAuth/Headers/ClarificationRequirement.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; + +namespace AAuth.Headers; + +/// +/// Typed projection of an AAuth-Requirement: requirement=clarification +/// response (AAuth protocol §Clarification Required). Carries the question the +/// server needs answered before it can proceed, plus optional timeout and +/// discrete-choice metadata. See +/// draft-hardt-oauth-aauth-protocol §Clarification Chat. +/// +/// +/// Per the spec the question is carried in the response body's +/// clarification field (a Markdown string), with optional +/// timeout (seconds) and options (discrete string choices) +/// fields. The AAuth-Requirement header carries only the requirement +/// type. The clarification value is untrusted input and MUST be +/// sanitized before display to a user. +/// +/// The Markdown question posed to the recipient. +/// Optional deadline (seconds) to respond by. +/// Optional discrete choices when the question is closed. +public sealed record ClarificationRequirement( + string Clarification, + int? TimeoutSeconds = null, + IReadOnlyList? Options = null) +{ + /// Requirement type: clarification. + public const string RequirementType = "clarification"; + + /// The response-body field carrying the question. + public const string ClarificationField = "clarification"; + + /// The response-body field carrying the optional timeout (seconds). + public const string TimeoutField = "timeout"; + + /// The response-body field carrying the optional discrete choices. + public const string OptionsField = "options"; + + /// + /// Project a clarification requirement from a parsed + /// AAuth-Requirement header and the (already-read) JSON response + /// body. Returns when the requirement is something + /// else. Throws when the requirement is + /// clarification but the body does not carry a non-empty + /// clarification string (§Clarification Required). + /// + public static ClarificationRequirement? FromResponse( + AAuthRequirementHeader.ParsedRequirement requirement, + JsonObject? body) + { + ArgumentNullException.ThrowIfNull(requirement); + if (requirement.Requirement != RequirementType) + { + return null; + } + + var question = (string?)body?[ClarificationField]; + if (string.IsNullOrWhiteSpace(question)) + { + throw new FormatException( + $"AAuth-Requirement requirement=clarification did not carry a question " + + $"(expected a '{ClarificationField}' string in the response body)."); + } + + int? timeout = null; + if (body?[TimeoutField] is JsonValue timeoutValue + && timeoutValue.TryGetValue(out int seconds)) + { + timeout = seconds; + } + + IReadOnlyList? options = null; + if (body?[OptionsField] is JsonArray array) + { + var values = new List(); + foreach (var node in array) + { + var value = (string?)node; + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value); + } + } + if (values.Count > 0) + { + options = values.ToArray(); + } + } + + return new ClarificationRequirement(question, timeout, options); + } +} diff --git a/tests/AAuth.Conformance/Missions/ClarificationChatTests.cs b/tests/AAuth.Conformance/Missions/ClarificationChatTests.cs new file mode 100644 index 0000000..2982afb --- /dev/null +++ b/tests/AAuth.Conformance/Missions/ClarificationChatTests.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Discovery; +using AAuth.Headers; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the agent side of the clarification chat (AAuth protocol +/// §Clarification Chat, §Agent Response to Clarification, §Clarification Limits). +/// +public class ClarificationChatTests +{ + private const string Ps = "http://localhost:5555"; + + private static TokenExchangeClient BuildClient(HttpMessageHandler handler) + { + var http = new HttpClient(handler) { BaseAddress = new Uri(Ps) }; + var metadata = new MetadataClient(new HttpClient(handler)); + return new TokenExchangeClient(http, metadata); + } + + [Fact(DisplayName = "§Clarification Required — requirement=clarification parsed into a typed model")] + public void ClarificationRequirement_ParsedFromResponse() + { + var parsed = AAuthRequirementHeader.Parse("requirement=clarification"); + var body = new JsonObject + { + ["status"] = "pending", + ["clarification"] = "Why do you need write access to my calendar?", + ["timeout"] = 120, + ["options"] = new JsonArray { "read-only", "read-write" }, + }; + + var clarification = ClarificationRequirement.FromResponse(parsed, body); + + Assert.NotNull(clarification); + Assert.Equal("Why do you need write access to my calendar?", clarification!.Clarification); + Assert.Equal(120, clarification.TimeoutSeconds); + Assert.Equal(new[] { "read-only", "read-write" }, clarification.Options); + } + + [Fact(DisplayName = "§Clarification Required — missing clarification field throws")] + public void ClarificationRequirement_MissingQuestion_Throws() + { + var parsed = AAuthRequirementHeader.Parse("requirement=clarification"); + Assert.Throws(() => + ClarificationRequirement.FromResponse(parsed, new JsonObject { ["status"] = "pending" })); + } + + [Fact(DisplayName = "§Agent Response to Clarification — clarification_response POST then resume polling yields auth token")] + public async Task ClarificationResponse_PostThenPoll_ReturnsAuthToken() + { + var handler = new ClarificationHandler(); + var client = BuildClient(handler); + + ClarificationRequirement? seen = null; + var token = await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + OnClarificationRequired = (clarification, _) => + { + seen = clarification; + return Task.FromResult(ClarificationResponse.Respond("I need to create a calendar invite.")); + }, + }); + + Assert.Equal("fake-auth-token", token); + Assert.NotNull(seen); + Assert.Equal("Why do you need write access?", seen!.Clarification); + Assert.Equal("I need to create a calendar invite.", handler.LastClarificationResponse); + } + + [Fact(DisplayName = "§Agent Response to Clarification — updated resource_token POST replaces the request")] + public async Task ClarificationResponse_UpdatedRequest_PostsNewResourceToken() + { + var handler = new ClarificationHandler(); + var client = BuildClient(handler); + + var token = await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + OnClarificationRequired = (_, _) => + Task.FromResult(ClarificationResponse.Update("reduced-resource-token", "Reduced to read-only.")), + }); + + Assert.Equal("fake-auth-token", token); + Assert.Equal("reduced-resource-token", handler.LastUpdatedResourceToken); + Assert.Equal("Reduced to read-only.", handler.LastUpdatedJustification); + } + + [Fact(DisplayName = "§Cancel Request — DELETE withdraws and surfaces a cancelled exception")] + public async Task ClarificationResponse_Cancel_DeletesPendingUrl() + { + var handler = new ClarificationHandler(); + var client = BuildClient(handler); + + await Assert.ThrowsAsync(() => + client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + OnClarificationRequired = (_, _) => Task.FromResult(ClarificationResponse.Cancel()), + })); + + Assert.True(handler.DeleteCalled); + } + + [Fact(DisplayName = "§Clarification — missing callback throws when PS asks for clarification")] + public async Task Clarification_NoCallback_Throws() + { + var handler = new ClarificationHandler(); + var client = BuildClient(handler); + + await Assert.ThrowsAsync(() => + client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest())); + } + + [Fact(DisplayName = "§Clarification Limits — exceeding the round limit throws")] + public async Task Clarification_RoundLimit_Enforced() + { + // PS keeps asking for clarification forever. + var handler = new ClarificationHandler { AlwaysClarify = true }; + var client = BuildClient(handler); + + await Assert.ThrowsAsync(() => + client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + MaxClarificationRounds = 2, + OnClarificationRequired = (_, _) => + Task.FromResult(ClarificationResponse.Respond("Still need it.")), + })); + } + + [Fact(DisplayName = "§Clarification — agent declares the clarification capability")] + public async Task Clarification_CapabilityDeclared() + { + var handler = new ClarificationHandler(); + var client = BuildClient(handler); + + await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + OnClarificationRequired = (_, _) => + Task.FromResult(ClarificationResponse.Respond("ok")), + }); + + Assert.Contains("clarification", handler.DeclaredCapabilities); + } + + /// + /// Stateful PS mock: first POST /token returns a clarification 202; after the + /// agent answers on the pending URL, a GET returns the auth token. With + /// , every poll returns a new clarification 202. + /// + private sealed class ClarificationHandler : HttpMessageHandler + { + public bool AlwaysClarify { get; init; } + public string? LastClarificationResponse { get; private set; } + public string? LastUpdatedResourceToken { get; private set; } + public string? LastUpdatedJustification { get; private set; } + public bool DeleteCalled { get; private set; } + public List DeclaredCapabilities { get; } = new(); + + private bool _answered; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + + if (path == "/.well-known/aauth-person.json") + { + return Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = Ps, + ["token_endpoint"] = Ps + "/token", + }); + } + + if (request.Method == HttpMethod.Delete) + { + DeleteCalled = true; + return new HttpResponseMessage(HttpStatusCode.OK); + } + + if (path == "/token" && request.Method == HttpMethod.Post) + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + if (body?["capabilities"] is JsonArray caps) + { + foreach (var c in caps) + { + var v = (string?)c; + if (v is not null) { DeclaredCapabilities.Add(v); } + } + } + return Clarify(); + } + + if (path == "/pending/abc" && request.Method == HttpMethod.Post) + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + if (body?["clarification_response"] is { } cr) + { + LastClarificationResponse = (string?)cr; + } + if (body?["resource_token"] is { } rt) + { + LastUpdatedResourceToken = (string?)rt; + LastUpdatedJustification = (string?)body["justification"]; + } + _answered = true; + return new HttpResponseMessage(HttpStatusCode.OK); + } + + if (path == "/pending/abc" && request.Method == HttpMethod.Get) + { + if (AlwaysClarify) + { + return Clarify(); + } + return _answered + ? Json(HttpStatusCode.OK, new JsonObject { ["auth_token"] = "fake-auth-token" }) + : Clarify(); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private static HttpResponseMessage Clarify() + { + var response = Json(HttpStatusCode.Accepted, new JsonObject + { + ["status"] = "pending", + ["clarification"] = "Why do you need write access?", + ["timeout"] = 120, + }); + response.Headers.Location = new Uri(Ps + "/pending/abc"); + response.Headers.TryAddWithoutValidation(AAuthRequirementHeader.Name, "requirement=clarification"); + return response; + } + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) + => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs b/tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs new file mode 100644 index 0000000..55f4c57 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Discovery; +using AAuth.Errors; +using AAuth.Headers; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the 403 mission_terminated response (AAuth protocol +/// §Mission Status Errors). The PS rejects any request referencing a mission +/// that is no longer active; the agent surfaces a typed exception. +/// +public class MissionTerminatedTests +{ + private const string Ps = "http://localhost:5555"; + + private static TokenExchangeClient BuildClient(HttpMessageHandler handler) + { + var http = new HttpClient(handler) { BaseAddress = new Uri(Ps) }; + var metadata = new MetadataClient(new HttpClient(handler)); + return new TokenExchangeClient(http, metadata); + } + + [Fact(DisplayName = "§Mission Status Errors — 403 mission_terminated on token request throws typed exception")] + public async Task MissionTerminated_OnTokenRequest_Throws() + { + var client = BuildClient(new TerminatedHandler(deferUntilPoll: false)); + + var ex = await Assert.ThrowsAsync(() => + client.ExchangeAsync(Ps, "fake-resource-token")); + + Assert.Equal("terminated", ex.MissionStatus); + } + + [Fact(DisplayName = "§Mission Status Errors — 403 mission_terminated surfaced during polling")] + public async Task MissionTerminated_DuringPolling_Throws() + { + var client = BuildClient(new TerminatedHandler(deferUntilPoll: true)); + + var ex = await Assert.ThrowsAsync(() => + client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + OnInteractionRequired = (_, _) => Task.CompletedTask, + })); + + Assert.Equal("terminated", ex.MissionStatus); + } + + [Fact(DisplayName = "§Mission Status Errors — error/mission_status codes round-trip via TokenErrorCode")] + public void MissionTerminated_TokenErrorCode_RoundTrips() + { + Assert.True(TokenErrorResponse.TryParseCode("mission_terminated", out var code)); + Assert.Equal(TokenErrorCode.MissionTerminated, code); + Assert.Equal("mission_terminated", new TokenErrorResponse(code).ErrorCode); + } + + /// + /// PS mock that returns 403 mission_terminated — either immediately on + /// the token request, or after an interaction deferral on the polled pending URL. + /// + private sealed class TerminatedHandler : HttpMessageHandler + { + private readonly bool _deferUntilPoll; + public TerminatedHandler(bool deferUntilPoll) => _deferUntilPoll = deferUntilPoll; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + + if (path == "/.well-known/aauth-person.json") + { + return Task.FromResult(Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = Ps, + ["token_endpoint"] = Ps + "/token", + })); + } + + if (path == "/token") + { + if (_deferUntilPoll) + { + var pending = Json(HttpStatusCode.Accepted, new JsonObject { ["status"] = "pending" }); + pending.Headers.Location = new Uri(Ps + "/pending/abc"); + pending.Headers.TryAddWithoutValidation( + AAuthRequirementHeader.Name, + "requirement=interaction; url=\"" + Ps + "/i\"; code=\"ABCD1234\""); + return Task.FromResult(pending); + } + return Task.FromResult(Terminated()); + } + + // Pending URL poll → mission terminated. + return Task.FromResult(Terminated()); + } + + private static HttpResponseMessage Terminated() + => Json(HttpStatusCode.Forbidden, new JsonObject + { + ["error"] = "mission_terminated", + ["mission_status"] = "terminated", + }); + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) + => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs new file mode 100644 index 0000000..698c4d0 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Discovery; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the optional agent token-request parameters (AAuth protocol +/// §Agent Token Request): justification, login_hint, tenant, +/// domain_hint, platform, and device. +/// +public class TokenRequestParamsTests +{ + private const string Ps = "http://localhost:5555"; + + private static TokenExchangeClient BuildClient(HttpMessageHandler handler) + { + var http = new HttpClient(handler) { BaseAddress = new Uri(Ps) }; + var metadata = new MetadataClient(new HttpClient(handler)); + return new TokenExchangeClient(http, metadata); + } + + [Fact(DisplayName = "§Agent Token Request — all optional params are serialized into the POST body")] + public async Task OptionalParams_SerializedIntoBody() + { + JsonObject? captured = null; + var client = BuildClient(new CaptureHandler(body => captured = body)); + + await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest + { + Justification = "Booking a flight on your behalf.", + LoginHint = "alice@example.com", + Tenant = "contoso", + DomainHint = "example.com", + Platform = "ios", + Device = "iphone-15", + }); + + Assert.NotNull(captured); + Assert.Equal("Booking a flight on your behalf.", (string?)captured!["justification"]); + Assert.Equal("alice@example.com", (string?)captured["login_hint"]); + Assert.Equal("contoso", (string?)captured["tenant"]); + Assert.Equal("example.com", (string?)captured["domain_hint"]); + Assert.Equal("ios", (string?)captured["platform"]); + Assert.Equal("iphone-15", (string?)captured["device"]); + } + + [Fact(DisplayName = "§Agent Token Request — unset optional params are omitted from the POST body")] + public async Task OptionalParams_OmittedWhenUnset() + { + JsonObject? captured = null; + var client = BuildClient(new CaptureHandler(body => captured = body)); + + await client.ExchangeAsync(Ps, "fake-resource-token"); + + Assert.NotNull(captured); + Assert.False(captured!.ContainsKey("justification")); + Assert.False(captured.ContainsKey("login_hint")); + Assert.False(captured.ContainsKey("tenant")); + Assert.False(captured.ContainsKey("domain_hint")); + Assert.False(captured.ContainsKey("platform")); + Assert.False(captured.ContainsKey("device")); + } + + private sealed class CaptureHandler : HttpMessageHandler + { + private readonly Action _onBody; + public CaptureHandler(Action onBody) => _onBody = onBody; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + if (request.RequestUri!.AbsolutePath == "/.well-known/aauth-person.json") + { + return Json(new JsonObject + { + ["issuer"] = Ps, + ["token_endpoint"] = Ps + "/token", + }); + } + + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))!.AsObject(); + _onBody(body); + return Json(new JsonObject { ["auth_token"] = "fake-auth-token" }); + } + + private static HttpResponseMessage Json(JsonObject body) + => new(HttpStatusCode.OK) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} From b9c69ea9ddb19eb423d6af5667e75f5c8b8ad11b Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 18:57:02 +0000 Subject: [PATCH 04/24] feat(missions): add PS governance clients, server seams, and mission log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — agent-side PS governance clients + metadata discovery: - Parse permission_endpoint and audit_endpoint in ServerMetadata (all four governance endpoints now surfaced; §Person Server Metadata). - MissionClient.ProposeAsync: POST {description, tools?}, handle 202 review/clarification, verify AAuth-Mission s256 vs blob (§Mission Creation/Approval). - PermissionClient: {action, description?, parameters?, mission?} -> granted/denied with approved_tools short-circuit (§Permission Endpoint). - AuditClient: fire-and-forget 201, mission required (§Audit Endpoint). - InteractionClient: interaction/payment/question/completion (§Interaction Endpoint). - GovernanceExchange: shared signed-POST + deferred-202 loop + origin-pin + mission_terminated; GovernanceOptions. Phase 5 — PS server-side governance seams + mission log: - GovernanceEndpoints request parsers + spec 403 mission_terminated helper (§PS Governance Endpoints, §Mission Status Errors). - IMissionStore/InMemoryMissionStore: verbatim blob bytes + active/terminated state. - IMissionLog/InMemoryMissionLog: ordered append + prior-consent lookup keyed by (s256, resource, scope) (§Mission Log). - IPermissionDecider (typed decision + reason), IAuditSink, IInteractionRelay. - AddAAuthGovernance DI registration for storage seams. Tests: GovernanceClientTests (12), GovernanceServerTests (17). 417 conformance + 371 unit green; SDK + full solution build 0/0. Phase 4 and Phase 5 of missions/PS governance. --- .../implementation-plan.md | 128 +++++- .../research.md | 108 +++++ src/AAuth/Agent/Governance/AuditClient.cs | 64 +++ src/AAuth/Agent/Governance/AuditRecord.cs | 49 +++ .../Agent/Governance/GovernanceExchange.cs | 313 +++++++++++++++ .../Agent/Governance/InteractionClient.cs | 144 +++++++ .../Agent/Governance/InteractionRequest.cs | 70 ++++ .../Agent/Governance/InteractionResult.cs | 29 ++ src/AAuth/Agent/Governance/MissionClient.cs | 102 +++++ src/AAuth/Agent/Governance/MissionProposal.cs | 42 ++ .../Agent/Governance/PermissionClient.cs | 110 +++++ .../Agent/Governance/PermissionRequest.cs | 46 +++ .../Agent/Governance/PermissionResult.cs | 42 ++ ...thGovernanceServiceCollectionExtensions.cs | 29 ++ src/AAuth/Discovery/ServerMetadata.cs | 18 +- .../Server/Governance/GovernanceEndpoints.cs | 146 +++++++ src/AAuth/Server/Governance/IAuditSink.cs | 17 + .../Server/Governance/IInteractionRelay.cs | 40 ++ src/AAuth/Server/Governance/IMissionLog.cs | 73 ++++ src/AAuth/Server/Governance/IMissionStore.cs | 45 +++ .../Server/Governance/IPermissionDecider.cs | 74 ++++ .../Server/Governance/InMemoryMissionLog.cs | 71 ++++ .../Server/Governance/InMemoryMissionStore.cs | 43 ++ .../Missions/GovernanceClientTests.cs | 376 ++++++++++++++++++ .../Missions/GovernanceServerTests.cs | 229 +++++++++++ 25 files changed, 2384 insertions(+), 24 deletions(-) create mode 100644 src/AAuth/Agent/Governance/AuditClient.cs create mode 100644 src/AAuth/Agent/Governance/AuditRecord.cs create mode 100644 src/AAuth/Agent/Governance/GovernanceExchange.cs create mode 100644 src/AAuth/Agent/Governance/InteractionClient.cs create mode 100644 src/AAuth/Agent/Governance/InteractionRequest.cs create mode 100644 src/AAuth/Agent/Governance/InteractionResult.cs create mode 100644 src/AAuth/Agent/Governance/MissionClient.cs create mode 100644 src/AAuth/Agent/Governance/MissionProposal.cs create mode 100644 src/AAuth/Agent/Governance/PermissionClient.cs create mode 100644 src/AAuth/Agent/Governance/PermissionRequest.cs create mode 100644 src/AAuth/Agent/Governance/PermissionResult.cs create mode 100644 src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs create mode 100644 src/AAuth/Server/Governance/GovernanceEndpoints.cs create mode 100644 src/AAuth/Server/Governance/IAuditSink.cs create mode 100644 src/AAuth/Server/Governance/IInteractionRelay.cs create mode 100644 src/AAuth/Server/Governance/IMissionLog.cs create mode 100644 src/AAuth/Server/Governance/IMissionStore.cs create mode 100644 src/AAuth/Server/Governance/IPermissionDecider.cs create mode 100644 src/AAuth/Server/Governance/InMemoryMissionLog.cs create mode 100644 src/AAuth/Server/Governance/InMemoryMissionStore.cs create mode 100644 tests/AAuth.Conformance/Missions/GovernanceClientTests.cs create mode 100644 tests/AAuth.Conformance/Missions/GovernanceServerTests.cs diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index 9043328..f042722 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -220,22 +220,23 @@ permission/interaction optional. Reuse the deferred/`202` loop from Phase 3. | `src/AAuth/Agent/Governance/PermissionClient.cs` | **New** — `{action,description?,parameters?,mission?}` → granted/denied | | `src/AAuth/Agent/Governance/AuditClient.cs` | **New** — fire-and-forget `201` (requires mission) | | `src/AAuth/Agent/Governance/InteractionClient.cs` | **New** — interaction/payment/question/completion | -| `src/AAuth/Agent/Governance/*Request.cs` / `*Response.cs` | **New** — DTOs | -| `tests/AAuth.Conformance/Missions/GovernanceClientTests.cs` | **New** | +| `src/AAuth/Agent/Governance/*Request.cs` / `*Response.cs` | **New** — DTOs (MissionProposal, PermissionRequest/Result, AuditRecord, InteractionRequest/Result) | +| `src/AAuth/Agent/Governance/GovernanceExchange.cs` | **New (deviation)** — shared signed-POST + deferred-`202` loop + endpoint origin-pinning; `GovernanceOptions` | +| `tests/AAuth.Conformance/Missions/GovernanceClientTests.cs` | **New** (12 tests) | ### Definition of Done -- [ ] `ServerMetadata` parses all four governance endpoints (§Person Server Metadata). -- [ ] `MissionClient.ProposeAsync` returns an approved `Mission` (verifies `s256`, +- [x] `ServerMetadata` parses all four governance endpoints (§Person Server Metadata). +- [x] `MissionClient.ProposeAsync` returns an approved `Mission` (verifies `s256`, handles `202` review/clarification) (§Mission Creation). -- [ ] `PermissionClient.RequestAsync` returns granted/denied, honoring +- [x] `PermissionClient.RequestAsync` returns granted/denied, honoring `approved_tools` short-circuit and deferred responses (§Permission Endpoint). -- [ ] `AuditClient.RecordAsync` is fire-and-forget, requires a mission, expects +- [x] `AuditClient.RecordAsync` is fire-and-forget, requires a mission, expects `201` (§Audit Endpoint). -- [ ] `InteractionClient` supports all four `type` values incl. `completion` +- [x] `InteractionClient` supports all four `type` values incl. `completion` terminate/continue (§Interaction Endpoint). -- [ ] `mission_terminated` surfaces from each client (Phase 3 exception). -- [ ] Client tests pass against a stub PS. +- [x] `mission_terminated` surfaces from each client (Phase 3 exception). +- [x] Client tests pass against a stub PS. --- @@ -253,14 +254,16 @@ response helpers, store/relay interfaces, and a mission-log seam. Fixes G18, G20 | File | Action | |------|--------| -| `src/AAuth/Server/Governance/IMissionStore.cs` | **New** — persist blob bytes + state | -| `src/AAuth/Server/Governance/IPermissionDecider.cs` | **New** | +| `src/AAuth/Server/Governance/IMissionStore.cs` | **New** — persist blob bytes + state; `StoredMission` record | +| `src/AAuth/Server/Governance/InMemoryMissionStore.cs` | **New (deviation)** — default in-memory store (mirrors `InMemoryJtiStore`) | +| `src/AAuth/Server/Governance/IPermissionDecider.cs` | **New** — `PermissionDecision` + reason enum + context | | `src/AAuth/Server/Governance/IAuditSink.cs` | **New** | | `src/AAuth/Server/Governance/IInteractionRelay.cs` | **New** | -| `src/AAuth/Server/Governance/IMissionLog.cs` | **New** — ordered append; read | -| `src/AAuth/Server/Governance/GovernanceEndpoints.cs` | **New** — minimal request parse + `mission_terminated` helper (optional minimal-API mappers) | -| `src/AAuth/DependencyInjection/*` | **Modify** — register governance seams | -| `tests/AAuth.Conformance/Missions/GovernanceServerTests.cs` | **New** | +| `src/AAuth/Server/Governance/IMissionLog.cs` | **New** — ordered append; read; prior-consent lookup | +| `src/AAuth/Server/Governance/InMemoryMissionLog.cs` | **New (deviation)** — default in-memory log | +| `src/AAuth/Server/Governance/GovernanceEndpoints.cs` | **New** — request parsers + `mission_terminated` helper | +| `src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs` | **New** — `AddAAuthGovernance` registers storage seams | +| `tests/AAuth.Conformance/Missions/GovernanceServerTests.cs` | **New** (17 tests) | ### Implementation Decisions @@ -272,16 +275,97 @@ response helpers, store/relay interfaces, and a mission-log seam. Fixes G18, G20 ### Definition of Done -- [ ] Request parsers for permission/audit/interaction/mission-create map to DTOs. -- [ ] `mission_terminated` helper emits spec `403` body (§Mission Status Errors). -- [ ] `IMissionStore` stores verbatim blob bytes + `active`/`terminated` state. -- [ ] `IMissionLog` appends token/permission/audit/interaction/clarification +- [x] Request parsers for permission/audit/interaction/mission-create map to DTOs. +- [x] `mission_terminated` helper emits spec `403` body (§Mission Status Errors). +- [x] `IMissionStore` stores verbatim blob bytes + `active`/`terminated` state. +- [x] `IMissionLog` appends token/permission/audit/interaction/clarification entries in order, and supports a prior-consent read keyed by `(s256, resource, scope)` (§Mission Log, §Agent Token Request L784). -- [ ] `IPermissionDecider` is invoked with mission + log context for the consent +- [x] `IPermissionDecider` is invoked with mission + log context for the consent decision (§Person Server L385). -- [ ] DI registration wires the seams. -- [ ] Server-seam tests pass. +- [x] DI registration wires the seams. +- [x] Server-seam tests pass. + +--- + +## Phase 5.5 — Shared deferred transport + governance facade + +**Goal:** Remove the duplication between `TokenExchangeClient` and the Phase 4 +`GovernanceExchange` by extracting a single internal deferred-HTTP transport +(T1 + T2), and hide governance-client construction behind a public facade and a +builder factory so callers don't hand-wire the signed `HttpClient` + four +clients (A + B). No behavioural change — the existing 417 conformance + 371 unit +tests are the regression gate. + +**Spec:** §User Interaction; §Clarification Chat; §Mission Status Errors; +§PS Governance Endpoints; §Person Server Metadata. (Pure refactor — same wire +behaviour, same spec citations as Phases 3–5.) + +**Rationale (from feasibility review):** `GovernanceExchange` and +`TokenExchangeClient` share ~120 lines of identical logic — endpoint origin-pin, +the `202` deferred loop (interaction + clarification), `ComposePollerOptions`, +`BufferBodyAsync` / `ReadJsonBodyAsync` / `ExtractRequirement` / `ResolveLocation`, +the `mission_terminated` reader, and `AddIfPresent`. `TokenExchangeClient` is +never exposed — `AAuthClientBuilder` constructs it internally and runs it behind +a `ChallengeHandler` (`AAuthClientBuilder.cs` ~L495). Governance operations are +*deliberate* (not challenge-driven) so they can't hide behind a handler, but +their construction can be hidden the same way the builder already hides the +token client's `(signedClient, metadata)` pair. + +### Implementation Decisions + +- **D8 (T1+T2 — single transport):** Introduce `internal sealed class + DeferredExchange` (in `AAuth.Agent`) as the one transport: `ResolveEndpointAsync` + + `PostAsync(endpoint, body, DeferredExchangeOptions, ct)` returning the + terminal `HttpResponseMessage` (caller parses + disposes), throwing + `AAuthMissionTerminatedException` on a terminal `403 mission_terminated`. It owns + all the shared helpers and the `AAuth.DeferredPoll` activity. `GovernanceExchange` + is **deleted**; governance clients use `DeferredExchange` directly, adapting + `GovernanceOptions` → `DeferredExchangeOptions`. `TokenExchangeClient.ExchangeAsync` + keeps its token-specific concerns (body builder + capability inference + the + `AAuth.TokenExchange` activity + `access_denied` classification + `auth_token` / + token-error reading) and delegates the transport to `DeferredExchange.PostAsync`. + `access_denied` moves from inside the poll loop to a post-`PostAsync` 403 + classifier (a `403 access_denied` body is not `mission_terminated`, so + `PostAsync` returns it unthrown — order preserved). `TokenExchangeRequest` and + the public `TokenExchangeClient` API are **unchanged**. +- **D9 (A+B — facade + factory):** Add public `AAuthGovernanceClient` bundling + `Mission` / `Permission` / `Audit` / `Interaction` over one signed `HttpClient` + + `MetadataClient` (ctor `(HttpClient signedClient, MetadataClient metadata)`; + the four sub-clients stay public for advanced use). Add + `AAuthClientBuilder.BuildGovernance()` returning an `AAuthGovernanceClient` + built from the **same** exchange pipeline the builder already constructs + (`AAuthClientBuilder.cs` ~L480–L495); factor that `(HttpClient signed, + MetadataClient metadata)` construction into a private helper shared by + `BuildHandler` and `BuildGovernance`. DI (`AddAAuthAgentGovernance`) is + **out of scope** here — deferred until a sample needs it (Phase 6). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Agent/DeferredExchange.cs` | **New** — shared transport + `DeferredExchangeOptions` + all shared helpers (absorbs `GovernanceExchange`) | +| `src/AAuth/Agent/Governance/GovernanceExchange.cs` | **Delete** — replaced by `DeferredExchange`; `GovernanceOptions` moves to its own file | +| `src/AAuth/Agent/Governance/GovernanceOptions.cs` | **New** — public `GovernanceOptions` (moved out of the deleted file) | +| `src/AAuth/Agent/TokenExchangeClient.cs` | **Modify** — delegate transport to `DeferredExchange`; keep token-specific body/error/diagnostics | +| `src/AAuth/Agent/Governance/{Mission,Permission,Audit,Interaction}Client.cs` | **Modify** — use `DeferredExchange`; adapt `GovernanceOptions` → `DeferredExchangeOptions` | +| `src/AAuth/Agent/Governance/AAuthGovernanceClient.cs` | **New** — public facade bundling the four clients | +| `src/AAuth/AAuthClientBuilder.cs` | **Modify** — extract shared `(signed HttpClient, MetadataClient)` build helper; add `BuildGovernance()` | +| `tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs` | **New** — facade construction + `BuildGovernance()` wiring | + +### Definition of Done + +- [ ] Single `DeferredExchange` transport; `GovernanceExchange.cs` deleted; no + duplicated deferred-loop / buffer / requirement helpers remain. +- [ ] `TokenExchangeClient` delegates transport to `DeferredExchange`; its public + API and wire behaviour are unchanged (`access_denied`, `mission_terminated`, + token-error codes, diagnostics activities all preserved). +- [ ] `AAuthGovernanceClient` facade exposes mission/permission/audit/interaction + over one signed client; sub-clients remain public. +- [ ] `AAuthClientBuilder.BuildGovernance()` returns a facade wired from the same + signed exchange pipeline as `BuildHandler()` (shared private helper). +- [ ] Full conformance (417) + unit (371) suites pass unchanged; new facade tests + pass; SDK + full solution build 0/0. --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index b1d8bb3..5596fe2 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -387,3 +387,111 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a list) `Missions/TokenRequestParamsTests.cs` (2) covering the six params. - **Validation:** 388 conformance (+13) + 371 unit tests green; full solution builds 0/0. + +### Phase 4 — PS governance clients + metadata discovery (2026-06-05, complete) + +- **Metadata (§Person Server Metadata, ~L2199).** `ServerMetadata.FromJson` + already parsed `mission_endpoint` + `interaction_endpoint`; added + `permission_endpoint` and `audit_endpoint`, so all four governance endpoints + (all OPTIONAL in spec) are now surfaced. The clients resolve an endpoint by + fetching the PS `aauth-person.json`, validating https-or-loopback, and + origin-pinning the returned URL to the PS authority. +- **Shared exchange (DEVIATION — added beyond plan file list).** A new + `Agent/Governance/GovernanceExchange.cs` holds the common signed-POST + + deferred-`202` loop + `mission_terminated` classification + endpoint + origin-pinning, plus a public `GovernanceOptions` + (`OnInteractionRequired`, `OnClarificationRequired`, `MaxClarificationRounds`, + `PollerOptions`). This mirrors `TokenExchangeClient`'s deferred/clarification + loop. **Design note for user:** `GovernanceExchange` duplicates some + `TokenExchangeClient` logic; `TokenExchangeClient` was deliberately left + untouched (zero regression risk). A future shared-helper refactor is possible + if desired. +- **MissionClient (§Mission Creation ~L399/L1228, §Mission Approval ~L1265).** + `ProposeAsync(personServer, MissionProposal, options?, ct)` posts + `{description, tools?}` to `mission_endpoint`, handles the `202` review/ + clarification loop, then reads the approval body **verbatim** and calls + `Mission.FromApprovalBytes`. It parses the `AAuth-Mission` header's `s256` and + verifies it against the recomputed blob hash (throws on mismatch/missing). +- **PermissionClient (§Permission Endpoint ~L1013).** `RequestAsync` posts + `{action, description?, parameters?, mission?}` → `200 {permission, reason?}`. + An overload taking a `Mission` short-circuits to `Granted` + ("Pre-approved tool on the active mission.") when the action matches + `mission.ApprovedTools`, **without** calling the PS (spec `approved_tools`). +- **AuditClient (§Audit Endpoint ~L1077).** `RecordAsync` posts + `{mission, action, description?, parameters?, result?}` (mission REQUIRED), + returns on `201`/`200`/`204` (fire-and-forget); `mission_terminated` surfaces + via `GovernanceExchange`. +- **InteractionClient (§Interaction Endpoint ~L1131).** `SendAsync` posts + `{type, ...}` for all four `type` values; `question` → `Answer` from + `body["answer"]`, `completion` → `Terminated` when `mission_status != "active"`. + Convenience helpers: `RelayInteractionAsync`, `RelayPaymentAsync`, + `AskQuestionAsync`, `ProposeCompletionAsync`. (DEVIATION — added + `InteractionResult.cs` DTO not explicitly in plan list.) +- **DTOs.** `MissionProposal`, `PermissionRequest`/`PermissionResult` + (`PermissionGrant` enum), `AuditRecord`, `InteractionRequest` + (`InteractionType` enum)/`InteractionResult`. Each request DTO has an + `internal ToJsonObject()`; `PermissionResult.FromJson` maps granted/denied. +- **Layering decision.** Agent DTOs own serialization (`ToJsonObject`); the + server side owns parsing (Phase 5 `GovernanceEndpoints.Parse*`). Agent + governance clients are constructed directly (like `TokenExchangeClient`) and + are **not** DI-registered. +- **Files:** modified `Discovery/ServerMetadata.cs`; new + `Agent/Governance/{GovernanceExchange,MissionProposal,MissionClient, + PermissionRequest,PermissionResult,PermissionClient,AuditRecord,AuditClient, + InteractionRequest,InteractionResult,InteractionClient}.cs`. **Tests:** + `Missions/GovernanceClientTests.cs` (12) against a stub PS. +- **Validation:** 399 conformance (+11) + 371 unit green; SDK + full solution + build 0/0. + +### Phase 5 — PS server-side governance seams + mission log (2026-06-05, complete) + +- **Decision boundary (D3).** The SDK ships thin server-side seams — request + parsers, a `mission_terminated` helper, storage interfaces + in-memory + defaults, and the policy/relay interfaces — so a PS can serve the governance + endpoints without hand-rolling parsing. Policy and UI live in the PS + (MockPersonServer, Phase 6). +- **Request parsers (§PS Governance Endpoints ~L463).** + `GovernanceEndpoints.Parse{Permission,Audit,Interaction,MissionProposal}` + map a `JsonObject` to the Phase 4 DTOs, throwing `FormatException` on missing + required fields (`action`; `mission`+`action`; `type`; `description`) and on + unknown interaction `type`. Mission objects are read via + `MissionClaim.FromPayload`. This keeps parsing **server-side** rather than + adding `FromJson` to the agent DTOs (clean agent-serializes / server-parses + split). +- **Mission-terminated helper (§Mission Status Errors ~L1331).** + `MissionTerminatedStatus = 403`; `MissionTerminatedBody(missionStatus = + "terminated")` emits `{error:"mission_terminated", mission_status}` (error code + from `AAuthMissionTerminatedException.ErrorCode`); `MissionTerminated(...)` + returns an `IResult` via `Results.Json(..., statusCode: 403)`. +- **Mission store (§Mission Approval — verbatim bytes).** `IMissionStore` + + `StoredMission(S256, Approver, Agent, Blob)` with `MissionState State` + (default `Active`). `InMemoryMissionStore` (DEVIATION — added in-memory default, + mirrors `InMemoryJtiStore`) stores the blob bytes verbatim and transitions + state via `existing with { State = state }`. +- **Mission log (§Mission Log ~L1310, §Agent Token Request ~L784).** `IMissionLog` + + `MissionLogEntry(S256, Kind, Timestamp)` with `MissionLogEntryKind` + {Token, Permission, Audit, Interaction, Clarification} and optional + Resource/Scope/Action/Granted/Detail. `InMemoryMissionLog` (DEVIATION — added) + appends in order, `ReadAsync` preserves order, and `HasPriorConsentAsync(s256, + resource, scope)` returns true only for `Token` entries with `Granted == true` + matching `(s256, resource, scope)` — the prior-consent context a PS uses to + skip re-prompting. +- **Decider seam (§Person Server ~L385, §Permission ~L1017).** + `IPermissionDecider.DecideAsync(PermissionDecisionContext, ct)` is invoked with + the request + resolved `StoredMission` + ordered log, and returns a + `PermissionDecision(Outcome, Reason, Message?)` where `PermissionOutcome` + {Granted, Denied, Prompt} and `PermissionDecisionReason` {InScope, PriorConsent, + ApprovedTool, OutOfScope}. The SDK supplies the inputs + reason taxonomy; the + PS owns the policy. `IAuditSink` and `IInteractionRelay` are the audit/relay + seams. +- **DI.** `AddAAuthGovernance` (`Microsoft.Extensions.DependencyInjection` + namespace) `TryAddSingleton`s `IMissionStore`→`InMemoryMissionStore` and + `IMissionLog`→`InMemoryMissionLog`; the policy seams (decider/sink/relay) are + left for the PS to register. +- **Files:** new `Server/Governance/{IMissionStore,InMemoryMissionStore, + IMissionLog,InMemoryMissionLog,IPermissionDecider,IAuditSink,IInteractionRelay, + GovernanceEndpoints}.cs` and + `DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs`. **Tests:** + `Missions/GovernanceServerTests.cs` (17). +- **Validation:** 417 conformance (+18 across Phases 4+5) + 371 unit green; SDK + + full solution build 0/0. diff --git a/src/AAuth/Agent/Governance/AuditClient.cs b/src/AAuth/Agent/Governance/AuditClient.cs new file mode 100644 index 0000000..8ef3bfc --- /dev/null +++ b/src/AAuth/Agent/Governance/AuditClient.cs @@ -0,0 +1,64 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Discovery; + +namespace AAuth.Agent.Governance; + +/// +/// Logs actions the agent has performed at the PS's audit_endpoint +/// (§Audit Endpoint). The audit endpoint requires a mission and is +/// fire-and-forget — the PS returns 201 Created to acknowledge. +/// +/// +/// The supplied MUST be wired with an +/// . +/// +public sealed class AuditClient +{ + private readonly GovernanceExchange _exchange; + + /// Create the audit client. + public AuditClient(HttpClient signedClient, MetadataClient metadata) + => _exchange = new GovernanceExchange(signedClient, metadata); + + /// + /// Record at the PS at . + /// Returns once the PS acknowledges with 201 Created. Surfaces + /// mission_terminated as . + /// + public async Task RecordAsync( + string personServer, + AuditRecord record, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + ArgumentNullException.ThrowIfNull(record); + + var endpoint = await _exchange.ResolveEndpointAsync( + personServer, "audit_endpoint", cancellationToken).ConfigureAwait(false); + + // Audit is fire-and-forget; no deferral handling is expected. + var response = await _exchange.PostAsync( + endpoint, record.ToJsonObject(), options: null, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode == HttpStatusCode.Created + || response.StatusCode == HttpStatusCode.OK + || response.StatusCode == HttpStatusCode.NoContent) + { + return; + } + + var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + throw new HttpRequestException( + $"Audit record failed with {(int)response.StatusCode}: {error}"); + } + finally + { + response.Dispose(); + } + } +} diff --git a/src/AAuth/Agent/Governance/AuditRecord.cs b/src/AAuth/Agent/Governance/AuditRecord.cs new file mode 100644 index 0000000..ff69cf3 --- /dev/null +++ b/src/AAuth/Agent/Governance/AuditRecord.cs @@ -0,0 +1,49 @@ +using System; +using System.Text.Json.Nodes; +using AAuth.Tokens; + +namespace AAuth.Agent.Governance; + +/// +/// An audit record the agent sends to the PS's audit_endpoint +/// (§Audit Request) after performing an action. The audit endpoint requires a +/// mission — there is no audit outside a mission context. +/// +/// Mission binding (approver + s256). REQUIRED. +/// String identifying the action that was performed. REQUIRED. +public sealed record AuditRecord(MissionClaim Mission, string Action) +{ + /// Markdown description of what was done and the outcome. Optional. + public string? Description { get; init; } + + /// The parameters that were used. Optional. + public JsonObject? Parameters { get; init; } + + /// The result or outcome of the action. Optional. + public JsonObject? Result { get; init; } + + /// Render the record as the JSON request body. + internal JsonObject ToJsonObject() + { + ArgumentNullException.ThrowIfNull(Mission); + ArgumentException.ThrowIfNullOrEmpty(Action); + var body = new JsonObject + { + ["mission"] = Mission.ToJsonObject(), + ["action"] = Action, + }; + if (!string.IsNullOrEmpty(Description)) + { + body["description"] = Description; + } + if (Parameters is not null) + { + body["parameters"] = Parameters.DeepClone(); + } + if (Result is not null) + { + body["result"] = Result.DeepClone(); + } + return body; + } +} diff --git a/src/AAuth/Agent/Governance/GovernanceExchange.cs b/src/AAuth/Agent/Governance/GovernanceExchange.cs new file mode 100644 index 0000000..29cc62e --- /dev/null +++ b/src/AAuth/Agent/Governance/GovernanceExchange.cs @@ -0,0 +1,313 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Discovery; +using AAuth.Errors; +using AAuth.Headers; + +namespace AAuth.Agent.Governance; + +/// +/// Optional deferred-handling callbacks shared by the PS governance clients +/// (mission, permission, interaction). A governance request may trigger a +/// 202 Accepted while the PS reaches the user; these callbacks let the +/// agent participate in the clarification chat (#clarification-chat) or relay an +/// interaction it cannot satisfy directly (#user-interaction). +/// +public sealed class GovernanceOptions +{ + /// + /// Invoked when the PS returns requirement=interaction — the agent + /// must relay the URL/code to the user. When and the + /// PS defers with an interaction requirement, the request fails. + /// + public Func? OnInteractionRequired { get; init; } + + /// + /// Invoked when the PS returns requirement=clarification during review + /// (§Clarification Chat). Returns the agent's decision (respond / update / + /// cancel). When and the PS asks for clarification, + /// the request fails. + /// + public Func>? OnClarificationRequired { get; init; } + + /// Maximum clarification rounds before the exchange aborts (default 5). + public int MaxClarificationRounds { get; init; } = ClarificationExchange.DefaultMaxRounds; + + /// Optional polling tuning for deferred responses. + public DeferredPollerOptions? PollerOptions { get; init; } +} + +/// +/// Shared transport for the PS governance endpoints (#ps-governance-endpoints): +/// resolves an endpoint from PS metadata (origin-pinned), POSTs a signed JSON +/// body, drives the deferred 202 loop (interaction + clarification), and +/// surfaces 403 mission_terminated (#mission-status-errors) as a typed +/// exception. +/// +internal sealed class GovernanceExchange +{ + private readonly HttpClient _signedClient; + private readonly MetadataClient _metadata; + + internal GovernanceExchange(HttpClient signedClient, MetadataClient metadata) + { + ArgumentNullException.ThrowIfNull(signedClient); + ArgumentNullException.ThrowIfNull(metadata); + _signedClient = signedClient; + _metadata = metadata; + } + + /// + /// Fetch PS metadata and resolve the governance endpoint named + /// , pinned to the same origin as + /// and required to be https-or-loopback. + /// + internal async Task ResolveEndpointAsync( + string personServer, string field, CancellationToken cancellationToken) + { + var metadataUrl = MetadataClient.BuildUrl(personServer, AAuthConstants.DwkFiles.Person); + var doc = await _metadata.FetchAsync(metadataUrl, cancellationToken).ConfigureAwait(false); + var endpoint = (string?)doc[field] + ?? throw new InvalidOperationException( + $"Person Server metadata at {metadataUrl} is missing '{field}'."); + + // Pin the endpoint to the configured PS origin and require https (or + // loopback) so a compromised metadata document can't divert the signed + // governance request off-host (SSRF) or downgrade it to plain http. + if (!AAuthUrl.IsHttpsOrLoopback(endpoint) + || !Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri)) + { + throw new InvalidOperationException( + $"Person Server '{field}' must be an absolute https:// URL (or http://localhost): {endpoint}"); + } + if (!Uri.TryCreate(personServer, UriKind.Absolute, out var psUri) + || !string.Equals( + endpointUri.GetLeftPart(UriPartial.Authority), + psUri.GetLeftPart(UriPartial.Authority), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Person Server '{field}' must share an origin with {personServer}: {endpoint}"); + } + + return endpointUri; + } + + /// + /// POST to and resolve any + /// deferred (202) responses to a terminal response. The caller owns + /// parsing the terminal response and MUST dispose it. + /// + internal async Task PostAsync( + Uri endpoint, JsonObject body, GovernanceOptions? options, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonContent.Create(body), + }; + if (options?.PollerOptions?.PreferWaitSeconds is { } preferWait) + { + request.Headers.TryAddWithoutValidation("Prefer", $"wait={preferWait}"); + } + + var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + ClarificationExchange? clarificationExchange = null; + var ownsResponse = true; + try + { + while (response.StatusCode == HttpStatusCode.Accepted) + { + var pendingUrl = ResolveLocation(response, endpoint); + var requirement = ExtractRequirement(response); + + // §Clarification Chat: the PS is asking the agent a question + // during review. Surface it, apply the decision, resume polling. + if (requirement?.Requirement == ClarificationRequirement.RequirementType) + { + var clarificationBody = await ReadJsonBodyAsync(response, cancellationToken).ConfigureAwait(false); + var clarification = ClarificationRequirement.FromResponse(requirement, clarificationBody); + response.Dispose(); + + if (options?.OnClarificationRequired is null) + { + throw new HttpRequestException( + "PS returned requirement=clarification but no OnClarificationRequired callback was provided."); + } + + clarificationExchange ??= new ClarificationExchange( + _signedClient, pendingUrl, options.MaxClarificationRounds); + var decision = await options.OnClarificationRequired(clarification!, cancellationToken) + .ConfigureAwait(false); + await clarificationExchange.ApplyAsync(decision, cancellationToken).ConfigureAwait(false); + + response = await PollAsync(pendingUrl, options?.PollerOptions, cancellationToken).ConfigureAwait(false); + continue; + } + + // §User Interaction: the PS could not reach the user and handed + // the interaction back. Relay it (if the agent can) then poll. + var interaction = requirement is null ? null : Interaction.FromRequirement(requirement); + response.Dispose(); + if (interaction is not null) + { + if (options?.OnInteractionRequired is null) + { + throw new HttpRequestException( + "PS returned requirement=interaction but no OnInteractionRequired callback was provided."); + } + await options.OnInteractionRequired(interaction, cancellationToken).ConfigureAwait(false); + } + + response = await PollAsync(pendingUrl, options?.PollerOptions, cancellationToken).ConfigureAwait(false); + } + + // §Mission Status Errors: a 403 mission_terminated is terminal — the + // mission referenced by the request is no longer active. + if (response.StatusCode == HttpStatusCode.Forbidden + && await TryReadMissionTerminatedAsync(response, cancellationToken).ConfigureAwait(false) + is var (terminated, missionStatus) && terminated) + { + response.Dispose(); + throw new AAuthMissionTerminatedException(missionStatus); + } + + ownsResponse = false; + return response; + } + finally + { + if (ownsResponse) + { + response.Dispose(); + } + } + } + + private async Task PollAsync( + Uri pendingUrl, DeferredPollerOptions? pollerOptions, CancellationToken cancellationToken) + { + var composed = ComposePollerOptions(pollerOptions); + try + { + return await new DeferredPoller(_signedClient, composed) + .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new AAuthInteractionTimeoutException( + $"PS deferred governance request did not complete within the polling budget: {ex.Message}", + ex); + } + } + + // Stop polling on a clarification 202 so the exchange loop can handle it, + // preserving any caller-supplied StopWhenAccepted predicate. + private static DeferredPollerOptions ComposePollerOptions(DeferredPollerOptions? baseOptions) + { + var userStop = baseOptions?.StopWhenAccepted; + bool Stop(HttpResponseMessage resp) + { + if (userStop is not null && userStop(resp)) { return true; } + var requirement = ExtractRequirement(resp); + return requirement?.Requirement == ClarificationRequirement.RequirementType; + } + + return baseOptions is null + ? new DeferredPollerOptions { StopWhenAccepted = Stop } + : baseOptions with { StopWhenAccepted = Stop }; + } + + private static (bool Terminated, string? MissionStatus) ReadMissionTerminated(string body) + { + try + { + var json = JsonNode.Parse(body) as JsonObject; + if ((string?)json?["error"] == AAuthMissionTerminatedException.ErrorCode) + { + return (true, (string?)json?["mission_status"]); + } + } + catch (JsonException) + { + // Not a mission-terminated body. + } + return (false, null); + } + + private static async Task<(bool Terminated, string? MissionStatus)> TryReadMissionTerminatedAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + return ReadMissionTerminated(body); + } + + // Read the body to a string and replace Content with a buffered copy so a + // non-matching response still flows to the caller's parser. + internal static async Task BufferBodyAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var mediaType = response.Content.Headers.ContentType?.MediaType ?? "application/json"; + var charset = response.Content.Headers.ContentType?.CharSet; + Encoding encoding; + if (string.IsNullOrEmpty(charset)) + { + encoding = Encoding.UTF8; + } + else + { + try { encoding = Encoding.GetEncoding(charset); } + catch (ArgumentException) { encoding = Encoding.UTF8; } + } + response.Content.Dispose(); + response.Content = new StringContent(body, encoding, mediaType); + return body; + } + + private static async Task ReadJsonBodyAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) { return null; } + try { return JsonNode.Parse(body) as JsonObject; } + catch (JsonException) { return null; } + } + + private static AAuthRequirementHeader.ParsedRequirement? ExtractRequirement(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues(AAuthRequirementHeader.Name, out var values)) + { + return null; + } + foreach (var raw in values) + { + if (string.IsNullOrWhiteSpace(raw)) { continue; } + try { return AAuthRequirementHeader.Parse(raw); } + catch (FormatException) { continue; } + } + return null; + } + + private static Uri ResolveLocation(HttpResponseMessage response, Uri @base) + { + var location = response.Headers.Location + ?? throw new HttpRequestException( + "Deferred PS response is missing the Location header — cannot poll."); + return location.IsAbsoluteUri ? location : new Uri(@base, location); + } + + internal static void AddIfPresent(JsonObject body, string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + body[name] = value; + } + } +} diff --git a/src/AAuth/Agent/Governance/InteractionClient.cs b/src/AAuth/Agent/Governance/InteractionClient.cs new file mode 100644 index 0000000..0de7e40 --- /dev/null +++ b/src/AAuth/Agent/Governance/InteractionClient.cs @@ -0,0 +1,144 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Discovery; +using AAuth.Tokens; + +namespace AAuth.Agent.Governance; + +/// +/// Reaches the user through the PS's interaction_endpoint (§Interaction +/// Endpoint): relay resource interactions, forward payments, ask questions, or +/// propose mission completion. May be used with or without a mission. +/// +/// +/// The supplied MUST be wired with an +/// . +/// +public sealed class InteractionClient +{ + private readonly GovernanceExchange _exchange; + + /// Create the interaction client. + public InteractionClient(HttpClient signedClient, MetadataClient metadata) + => _exchange = new GovernanceExchange(signedClient, metadata); + + /// + /// Send to the PS at + /// and return the terminal result, polling through any deferred response. + /// + public async Task SendAsync( + string personServer, + InteractionRequest request, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + ArgumentNullException.ThrowIfNull(request); + + var endpoint = await _exchange.ResolveEndpointAsync( + personServer, "interaction_endpoint", cancellationToken).ConfigureAwait(false); + + var response = await _exchange.PostAsync( + endpoint, request.ToJsonObject(), options, cancellationToken).ConfigureAwait(false); + try + { + if (!response.IsSuccessStatusCode) + { + var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + throw new HttpRequestException( + $"Interaction request failed with {(int)response.StatusCode}: {error}"); + } + + var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + JsonObject? body = null; + if (!string.IsNullOrWhiteSpace(raw)) + { + try { body = JsonNode.Parse(raw) as JsonObject; } + catch (JsonException) { body = null; } + } + + return request.Type switch + { + InteractionType.Question => new InteractionResult(request.Type) + { + Answer = (string?)body?["answer"], + Body = body, + }, + InteractionType.Completion => new InteractionResult(request.Type) + { + // The PS terminates the mission on acceptance and returns 200. + // A body may carry mission_status=active when the user kept it open. + Terminated = (string?)body?["mission_status"] != "active", + Body = body, + }, + _ => new InteractionResult(request.Type) { Body = body }, + }; + } + finally + { + response.Dispose(); + } + } + + /// Relay a resource interaction (URL + code) to the user. + public Task RelayInteractionAsync( + string personServer, string url, string code, + string? description = null, MissionClaim? mission = null, + GovernanceOptions? options = null, CancellationToken cancellationToken = default) + => SendAsync(personServer, new InteractionRequest(InteractionType.Interaction) + { + Url = url, + Code = code, + Description = description, + Mission = mission, + }, options, cancellationToken); + + /// Forward a payment approval (URL + code) to the user. + public Task RelayPaymentAsync( + string personServer, string url, string code, + string? description = null, MissionClaim? mission = null, + GovernanceOptions? options = null, CancellationToken cancellationToken = default) + => SendAsync(personServer, new InteractionRequest(InteractionType.Payment) + { + Url = url, + Code = code, + Description = description, + Mission = mission, + }, options, cancellationToken); + + /// Ask the user a question and return the answer. + public async Task AskQuestionAsync( + string personServer, string question, + string? description = null, MissionClaim? mission = null, + GovernanceOptions? options = null, CancellationToken cancellationToken = default) + { + var result = await SendAsync(personServer, new InteractionRequest(InteractionType.Question) + { + Question = question, + Description = description, + Mission = mission, + }, options, cancellationToken).ConfigureAwait(false); + return result.Answer; + } + + /// + /// Propose mission completion with a summary. Returns + /// when the user accepted and the PS terminated the mission. + /// + public async Task ProposeCompletionAsync( + string personServer, string summary, MissionClaim mission, + GovernanceOptions? options = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mission); + var result = await SendAsync(personServer, new InteractionRequest(InteractionType.Completion) + { + Summary = summary, + Mission = mission, + }, options, cancellationToken).ConfigureAwait(false); + return result.Terminated; + } +} diff --git a/src/AAuth/Agent/Governance/InteractionRequest.cs b/src/AAuth/Agent/Governance/InteractionRequest.cs new file mode 100644 index 0000000..2fbf8cd --- /dev/null +++ b/src/AAuth/Agent/Governance/InteractionRequest.cs @@ -0,0 +1,70 @@ +using System; +using System.Text.Json.Nodes; +using AAuth.Tokens; + +namespace AAuth.Agent.Governance; + +/// The type of interaction relayed through the PS (§Interaction Request). +public enum InteractionType +{ + /// Relay a resource interaction requirement to the user. + Interaction, + + /// Forward a payment approval to the user. + Payment, + + /// Ask the user a question and receive an answer. + Question, + + /// Propose mission completion with a summary. + Completion, +} + +/// +/// An interaction request the agent sends to the PS's interaction_endpoint +/// (§Interaction Request) to reach the user through the PS. +/// +/// The interaction type. REQUIRED. +public sealed record InteractionRequest(InteractionType Type) +{ + /// Markdown context for the user. Optional. + public string? Description { get; init; } + + /// Interaction URL to relay (for interaction/payment). Optional. + public string? Url { get; init; } + + /// Interaction code associated with the URL. Optional. + public string? Code { get; init; } + + /// Markdown question for the user (for question). Optional. + public string? Question { get; init; } + + /// Markdown summary of what was accomplished (for completion). Optional. + public string? Summary { get; init; } + + /// Mission binding (approver + s256). Optional. + public MissionClaim? Mission { get; init; } + + /// The wire value for . + internal string TypeValue => Type switch + { + InteractionType.Interaction => "interaction", + InteractionType.Payment => "payment", + InteractionType.Question => "question", + InteractionType.Completion => "completion", + _ => throw new ArgumentOutOfRangeException(nameof(Type)), + }; + + /// Render the request as the JSON request body. + internal JsonObject ToJsonObject() + { + var body = new JsonObject { ["type"] = TypeValue }; + if (!string.IsNullOrEmpty(Description)) { body["description"] = Description; } + if (!string.IsNullOrEmpty(Url)) { body["url"] = Url; } + if (!string.IsNullOrEmpty(Code)) { body["code"] = Code; } + if (!string.IsNullOrEmpty(Question)) { body["question"] = Question; } + if (!string.IsNullOrEmpty(Summary)) { body["summary"] = Summary; } + if (Mission is not null) { body["mission"] = Mission.ToJsonObject(); } + return body; + } +} diff --git a/src/AAuth/Agent/Governance/InteractionResult.cs b/src/AAuth/Agent/Governance/InteractionResult.cs new file mode 100644 index 0000000..b2a05bf --- /dev/null +++ b/src/AAuth/Agent/Governance/InteractionResult.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; + +namespace AAuth.Agent.Governance; + +/// +/// The terminal result of an interaction request (§Interaction Response). The +/// populated fields depend on the request : +/// +/// question populates . +/// completion populates . +/// interaction/payment resolve once the user completes. +/// +/// +/// The interaction type this result is for. +public sealed record InteractionResult(InteractionType Type) +{ + /// The user's answer (for question). + public string? Answer { get; init; } + + /// + /// Whether the mission was terminated (for completion — the user + /// accepted the summary). means the mission remains + /// active (the user had follow-ups). + /// + public bool Terminated { get; init; } + + /// The raw terminal response body, if any. + public JsonObject? Body { get; init; } +} diff --git a/src/AAuth/Agent/Governance/MissionClient.cs b/src/AAuth/Agent/Governance/MissionClient.cs new file mode 100644 index 0000000..788a501 --- /dev/null +++ b/src/AAuth/Agent/Governance/MissionClient.cs @@ -0,0 +1,102 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Discovery; +using AAuth.Headers; + +namespace AAuth.Agent.Governance; + +/// +/// Proposes missions at the PS's mission_endpoint (§Mission Creation, +/// §Mission Approval). The PS may engage in a clarification chat to refine the +/// scope before approving; on approval the PS returns the mission blob plus an +/// AAuth-Mission header carrying the approver and s256. +/// +/// +/// The supplied MUST be wired with an +/// so each request is signed and +/// carries the agent token via Signature-Key. +/// +public sealed class MissionClient +{ + private readonly GovernanceExchange _exchange; + + /// Create the mission client. + /// HttpClient wired with an . + /// Metadata client for resolving the PS mission_endpoint. + public MissionClient(HttpClient signedClient, MetadataClient metadata) + => _exchange = new GovernanceExchange(signedClient, metadata); + + /// + /// Propose a mission to the PS at and return + /// the approved . Handles the 202 review / + /// clarification path via . + /// + /// + /// The PS did not return an AAuth-Mission header, or the returned + /// s256 does not match the hash of the approval body. + /// + public async Task ProposeAsync( + string personServer, + MissionProposal proposal, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + ArgumentNullException.ThrowIfNull(proposal); + + var endpoint = await _exchange.ResolveEndpointAsync( + personServer, "mission_endpoint", cancellationToken).ConfigureAwait(false); + + var response = await _exchange.PostAsync( + endpoint, proposal.ToJsonObject(), options, cancellationToken).ConfigureAwait(false); + try + { + if (!response.IsSuccessStatusCode) + { + var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + throw new HttpRequestException( + $"Mission proposal failed with {(int)response.StatusCode}: {error}"); + } + + // The agent MUST store the approval body bytes exactly as received — + // no re-serialization — so the s256 can be verified (§Mission Approval). + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + var mission = Mission.FromApprovalBytes(bytes); + + // Verify the s256 in the AAuth-Mission header matches the computed hash. + if (!response.Headers.TryGetValues(AAuthMissionHeader.Name, out var values)) + { + throw new InvalidOperationException( + "Mission approval response is missing the AAuth-Mission header."); + } + var headerS256 = ParseHeaderS256(string.Join(",", values)); + if (string.IsNullOrEmpty(headerS256) || !mission.VerifyS256(headerS256)) + { + throw new InvalidOperationException( + "AAuth-Mission header 's256' does not match the hash of the approval body."); + } + + return mission; + } + finally + { + response.Dispose(); + } + } + + // Extract the s256 value from an AAuth-Mission header (approver="..."; s256="..."). + private static string? ParseHeaderS256(string headerValue) + { + foreach (var part in headerValue.Split(';')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("s256=", StringComparison.OrdinalIgnoreCase)) + { + return trimmed["s256=".Length..].Trim().Trim('"'); + } + } + return null; + } +} diff --git a/src/AAuth/Agent/Governance/MissionProposal.cs b/src/AAuth/Agent/Governance/MissionProposal.cs new file mode 100644 index 0000000..a16d267 --- /dev/null +++ b/src/AAuth/Agent/Governance/MissionProposal.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace AAuth.Agent.Governance; + +/// +/// A mission proposal the agent sends to the PS's mission_endpoint +/// (§Mission Creation). Carries a Markdown description of what the agent intends +/// to accomplish and, optionally, the tools it wants to use. +/// +/// Markdown description of the intended mission. +public sealed record MissionProposal(string Description) +{ + /// + /// Tools the agent wants to use. The approved mission MAY grant a subset + /// (§Mission Approval). Optional. + /// + public IReadOnlyList Tools { get; init; } = Array.Empty(); + + /// Render the proposal as the JSON request body. + internal JsonObject ToJsonObject() + { + ArgumentException.ThrowIfNullOrEmpty(Description); + var body = new JsonObject { ["description"] = Description }; + if (Tools.Count > 0) + { + var tools = new JsonArray(); + foreach (var tool in Tools) + { + var obj = new JsonObject { ["name"] = tool.Name }; + if (!string.IsNullOrEmpty(tool.Description)) + { + obj["description"] = tool.Description; + } + tools.Add(obj); + } + body["tools"] = tools; + } + return body; + } +} diff --git a/src/AAuth/Agent/Governance/PermissionClient.cs b/src/AAuth/Agent/Governance/PermissionClient.cs new file mode 100644 index 0000000..7675b6a --- /dev/null +++ b/src/AAuth/Agent/Governance/PermissionClient.cs @@ -0,0 +1,110 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Discovery; + +namespace AAuth.Agent.Governance; + +/// +/// Requests permission for actions not governed by a remote resource at the PS's +/// permission_endpoint (§Permission Endpoint) — tool calls, file writes, +/// sending messages. May be used with or without a mission. +/// +/// +/// The supplied MUST be wired with an +/// . +/// +public sealed class PermissionClient +{ + private readonly GovernanceExchange _exchange; + + /// Create the permission client. + public PermissionClient(HttpClient signedClient, MetadataClient metadata) + => _exchange = new GovernanceExchange(signedClient, metadata); + + /// + /// Request permission for from the PS at + /// . Handles deferred (user-input) responses. + /// + public async Task RequestAsync( + string personServer, + PermissionRequest request, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + ArgumentNullException.ThrowIfNull(request); + + var endpoint = await _exchange.ResolveEndpointAsync( + personServer, "permission_endpoint", cancellationToken).ConfigureAwait(false); + + var response = await _exchange.PostAsync( + endpoint, request.ToJsonObject(), options, cancellationToken).ConfigureAwait(false); + try + { + if (!response.IsSuccessStatusCode) + { + var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + throw new HttpRequestException( + $"Permission request failed with {(int)response.StatusCode}: {error}"); + } + + var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + JsonObject? json; + try { json = JsonNode.Parse(raw) as JsonObject; } + catch (JsonException ex) + { + throw new HttpRequestException("Permission response body is not valid JSON.", ex); + } + if (json is null) + { + throw new HttpRequestException("Permission response body is not a JSON object."); + } + return PermissionResult.FromJson(json); + } + finally + { + response.Dispose(); + } + } + + /// + /// Request permission for within + /// , short-circuiting to + /// when the action is a pre-approved tool on the mission (§Permission Endpoint — + /// "the agent calls the permission endpoint only for actions not covered by + /// pre-approved tools"). Otherwise calls the PS. + /// + public Task RequestAsync( + string personServer, + string action, + Mission mission, + string? description = null, + JsonObject? parameters = null, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(action); + ArgumentNullException.ThrowIfNull(mission); + + foreach (var tool in mission.ApprovedTools) + { + if (string.Equals(tool.Name, action, StringComparison.Ordinal)) + { + return Task.FromResult(new PermissionResult( + PermissionGrant.Granted, "Pre-approved tool on the active mission.")); + } + } + + var request = new PermissionRequest(action) + { + Description = description, + Parameters = parameters, + Mission = new Tokens.MissionClaim(mission.Approver, mission.S256), + }; + return RequestAsync(personServer, request, options, cancellationToken); + } +} diff --git a/src/AAuth/Agent/Governance/PermissionRequest.cs b/src/AAuth/Agent/Governance/PermissionRequest.cs new file mode 100644 index 0000000..30d2d0b --- /dev/null +++ b/src/AAuth/Agent/Governance/PermissionRequest.cs @@ -0,0 +1,46 @@ +using System; +using System.Text.Json.Nodes; +using AAuth.Tokens; + +namespace AAuth.Agent.Governance; + +/// +/// A permission request the agent sends to the PS's permission_endpoint +/// (§Permission Request) for an action not governed by a remote resource — a +/// tool call, file write, or message send. +/// +/// String identifying the action (e.g. a tool name). REQUIRED. +public sealed record PermissionRequest(string Action) +{ + /// Markdown description of what the action will do and why. Optional. + public string? Description { get; init; } + + /// The parameters the agent intends to pass to the action. Optional. + public JsonObject? Parameters { get; init; } + + /// + /// Mission binding (approver + s256). When present the PS + /// evaluates the request against the mission context and log. Optional. + /// + public MissionClaim? Mission { get; init; } + + /// Render the request as the JSON request body. + internal JsonObject ToJsonObject() + { + ArgumentException.ThrowIfNullOrEmpty(Action); + var body = new JsonObject { ["action"] = Action }; + if (!string.IsNullOrEmpty(Description)) + { + body["description"] = Description; + } + if (Parameters is not null) + { + body["parameters"] = Parameters.DeepClone(); + } + if (Mission is not null) + { + body["mission"] = Mission.ToJsonObject(); + } + return body; + } +} diff --git a/src/AAuth/Agent/Governance/PermissionResult.cs b/src/AAuth/Agent/Governance/PermissionResult.cs new file mode 100644 index 0000000..f0d03a2 --- /dev/null +++ b/src/AAuth/Agent/Governance/PermissionResult.cs @@ -0,0 +1,42 @@ +using System; +using System.Text.Json.Nodes; + +namespace AAuth.Agent.Governance; + +/// The PS's decision on a permission request (§Permission Response). +public enum PermissionGrant +{ + /// The agent MAY proceed with the action. + Granted, + + /// The agent MUST NOT proceed. + Denied, +} + +/// +/// The result of a permission request (§Permission Response). When +/// is the +/// MAY carry a Markdown explanation. +/// +/// Whether the action is granted or denied. +/// Optional Markdown reason (typically present on denial). +public sealed record PermissionResult(PermissionGrant Grant, string? Reason = null) +{ + /// Whether the action was granted. + public bool IsGranted => Grant == PermissionGrant.Granted; + + /// Parse a {permission, reason?} response body. + internal static PermissionResult FromJson(JsonObject body) + { + ArgumentNullException.ThrowIfNull(body); + var permission = (string?)body["permission"]; + var grant = permission switch + { + "granted" => PermissionGrant.Granted, + "denied" => PermissionGrant.Denied, + _ => throw new InvalidOperationException( + $"Permission response has an unexpected 'permission' value: {permission ?? "(null)"}"), + }; + return new PermissionResult(grant, (string?)body["reason"]); + } +} diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs new file mode 100644 index 0000000..1fcb2a4 --- /dev/null +++ b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using System; +using AAuth.Server.Governance; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Registers the PS-side governance seams (§PS Governance Endpoints, §Mission +/// Log). The storage seams default to in-memory implementations; the +/// policy/user-channel seams (, +/// , +/// ) are supplied by the PS. +/// +public static class AAuthGovernanceServiceCollectionExtensions +{ + /// + /// Register the default mission storage seams — + /// and + /// — as singletons. + /// Uses TryAdd so a PS can register durable implementations first. + /// + public static IServiceCollection AddAAuthGovernance(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } +} diff --git a/src/AAuth/Discovery/ServerMetadata.cs b/src/AAuth/Discovery/ServerMetadata.cs index 3559773..6bc0d71 100644 --- a/src/AAuth/Discovery/ServerMetadata.cs +++ b/src/AAuth/Discovery/ServerMetadata.cs @@ -24,10 +24,22 @@ public sealed class ServerMetadata /// Revocation endpoint (optional). public string? RevocationEndpoint { get; init; } - /// Mission endpoint (optional, PS only). + /// Mission endpoint (optional, PS only) — §Person Server Metadata. public string? MissionEndpoint { get; init; } - /// Interaction endpoint (optional, PS only). + /// + /// Permission endpoint (optional, PS only). Where agents request permission + /// for actions not governed by a remote resource (§Permission Endpoint). + /// + public string? PermissionEndpoint { get; init; } + + /// + /// Audit endpoint (optional, PS only). Where agents log actions performed + /// within a mission context (§Audit Endpoint). + /// + public string? AuditEndpoint { get; init; } + + /// Interaction endpoint (optional, PS only) — §Interaction Endpoint. public string? InteractionEndpoint { get; init; } /// Parse a metadata JSON document into a . @@ -41,6 +53,8 @@ public static ServerMetadata FromJson(JsonObject doc) TokenEndpoint = (string?)doc["token_endpoint"], RevocationEndpoint = (string?)doc["revocation_endpoint"], MissionEndpoint = (string?)doc["mission_endpoint"], + PermissionEndpoint = (string?)doc["permission_endpoint"], + AuditEndpoint = (string?)doc["audit_endpoint"], InteractionEndpoint = (string?)doc["interaction_endpoint"], }; } diff --git a/src/AAuth/Server/Governance/GovernanceEndpoints.cs b/src/AAuth/Server/Governance/GovernanceEndpoints.cs new file mode 100644 index 0000000..6fffa5c --- /dev/null +++ b/src/AAuth/Server/Governance/GovernanceEndpoints.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Errors; +using AAuth.Tokens; +using Microsoft.AspNetCore.Http; + +namespace AAuth.Server.Governance; + +/// +/// Minimal server-side helpers for the PS governance endpoints +/// (§PS Governance Endpoints): request-body parsers that map JSON to the shared +/// DTOs, and the canonical mission_terminated response +/// (§Mission Status Errors). Policy and the user channel stay in the PS; this +/// type only removes hand-rolled parsing. +/// +public static class GovernanceEndpoints +{ + /// HTTP status for a terminated mission (§Mission Status Errors). + public const int MissionTerminatedStatus = StatusCodes.Status403Forbidden; + + /// + /// Parse a permission request body (§Permission Request) into a + /// . + /// + /// The required action is missing. + public static PermissionRequest ParsePermission(JsonObject body) + { + ArgumentNullException.ThrowIfNull(body); + var action = (string?)body["action"] + ?? throw new FormatException("Permission request is missing the required 'action'."); + return new PermissionRequest(action) + { + Description = (string?)body["description"], + Parameters = body["parameters"] as JsonObject, + Mission = MissionClaim.FromPayload(body), + }; + } + + /// + /// Parse an audit request body (§Audit Request) into an . + /// + /// The required mission or action is missing. + public static AuditRecord ParseAudit(JsonObject body) + { + ArgumentNullException.ThrowIfNull(body); + var mission = MissionClaim.FromPayload(body) + ?? throw new FormatException("Audit request is missing the required 'mission'."); + var action = (string?)body["action"] + ?? throw new FormatException("Audit request is missing the required 'action'."); + return new AuditRecord(mission, action) + { + Description = (string?)body["description"], + Parameters = body["parameters"] as JsonObject, + Result = body["result"] as JsonObject, + }; + } + + /// + /// Parse an interaction request body (§Interaction Request) into an + /// . + /// + /// The required type is missing or unknown. + public static InteractionRequest ParseInteraction(JsonObject body) + { + ArgumentNullException.ThrowIfNull(body); + var typeValue = (string?)body["type"] + ?? throw new FormatException("Interaction request is missing the required 'type'."); + var type = typeValue switch + { + "interaction" => InteractionType.Interaction, + "payment" => InteractionType.Payment, + "question" => InteractionType.Question, + "completion" => InteractionType.Completion, + _ => throw new FormatException($"Interaction request has an unknown 'type': {typeValue}"), + }; + return new InteractionRequest(type) + { + Description = (string?)body["description"], + Url = (string?)body["url"], + Code = (string?)body["code"], + Question = (string?)body["question"], + Summary = (string?)body["summary"], + Mission = MissionClaim.FromPayload(body), + }; + } + + /// + /// Parse a mission proposal body (§Mission Creation) into a + /// . + /// + /// The required description is missing. + public static MissionProposal ParseMissionProposal(JsonObject body) + { + ArgumentNullException.ThrowIfNull(body); + var description = (string?)body["description"] + ?? throw new FormatException("Mission proposal is missing the required 'description'."); + return new MissionProposal(description) + { + Tools = ParseTools(body["tools"] as JsonArray), + }; + } + + /// + /// The canonical mission_terminated response body (§Mission Status + /// Errors): { "error": "mission_terminated", "mission_status": "..." }. + /// + public static JsonObject MissionTerminatedBody(string missionStatus = "terminated") + => new() + { + ["error"] = AAuthMissionTerminatedException.ErrorCode, + ["mission_status"] = missionStatus, + }; + + /// + /// An ASP.NET Core emitting the spec + /// 403 mission_terminated response (§Mission Status Errors). + /// + public static IResult MissionTerminated(string missionStatus = "terminated") + => Results.Json(MissionTerminatedBody(missionStatus), statusCode: MissionTerminatedStatus); + + private static IReadOnlyList ParseTools(JsonArray? tools) + { + if (tools is null || tools.Count == 0) + { + return Array.Empty(); + } + var result = new List(tools.Count); + foreach (var node in tools) + { + if (node is not JsonObject tool) + { + continue; + } + var name = (string?)tool["name"]; + if (string.IsNullOrEmpty(name)) + { + continue; + } + result.Add(new MissionTool(name, (string?)tool["description"])); + } + return result; + } +} diff --git a/src/AAuth/Server/Governance/IAuditSink.cs b/src/AAuth/Server/Governance/IAuditSink.cs new file mode 100644 index 0000000..42a212e --- /dev/null +++ b/src/AAuth/Server/Governance/IAuditSink.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// PS-side sink for audit records (§Audit Endpoint). The PS records the entry in +/// the mission log and MAY use it to detect anomalous behavior, alert the user, +/// or revoke the mission. The SDK supplies the contract; the PS implements +/// storage/alerting policy. +/// +public interface IAuditSink +{ + /// Record an audit entry the agent reported within a mission context. + Task RecordAsync(AuditRecord record, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/IInteractionRelay.cs b/src/AAuth/Server/Governance/IInteractionRelay.cs new file mode 100644 index 0000000..0178b77 --- /dev/null +++ b/src/AAuth/Server/Governance/IInteractionRelay.cs @@ -0,0 +1,40 @@ +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// The result of relaying an interaction to the user through the PS (§Interaction +/// Response). Populated fields depend on the request . +/// +public sealed record InteractionRelayResult +{ + /// The user's answer (for question). + public string? Answer { get; init; } + + /// + /// Whether the user accepted mission completion (for completion). When + /// the PS terminates the mission; when + /// the mission remains active. + /// + public bool? Accepted { get; init; } + + /// + /// Whether the relay is still pending — the PS should return a deferred + /// response and let the agent poll (for interaction / payment). + /// + public bool Pending { get; init; } +} + +/// +/// PS-side relay seam for the interaction endpoint (§Interaction Endpoint): reach +/// the user to relay an interaction/payment, ask a question, or present a +/// completion summary. The SDK supplies the contract; the PS implements the +/// user channel. +/// +public interface IInteractionRelay +{ + /// Relay to the user and return the outcome. + Task RelayAsync(InteractionRequest request, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/IMissionLog.cs b/src/AAuth/Server/Governance/IMissionLog.cs new file mode 100644 index 0000000..e8bab67 --- /dev/null +++ b/src/AAuth/Server/Governance/IMissionLog.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AAuth.Server.Governance; + +/// The kind of action recorded in a mission log entry (§Mission Log). +public enum MissionLogEntryKind +{ + /// A token request (with justification) the agent made under the mission. + Token, + + /// A permission request and its decision. + Permission, + + /// An audit record the agent reported. + Audit, + + /// An interaction relayed through the PS. + Interaction, + + /// A clarification chat exchange during review. + Clarification, +} + +/// +/// A single entry in the mission log (§Mission Log) — the ordered record of an +/// agent's actions and the governance decisions made within a mission context. +/// +/// The mission this entry belongs to. +/// The kind of action. +/// When the entry was recorded. +public sealed record MissionLogEntry(string S256, MissionLogEntryKind Kind, DateTimeOffset Timestamp) +{ + /// The resource involved (for token entries) — used for prior-consent lookups. + public string? Resource { get; init; } + + /// The scope involved (for token entries) — used for prior-consent lookups. + public string? Scope { get; init; } + + /// The action name (for permission / audit entries). + public string? Action { get; init; } + + /// Whether the governance decision granted the request (for token / permission entries). + public bool? Granted { get; init; } + + /// Free-form detail (e.g. a token-request justification or clarification text). + public string? Detail { get; init; } +} + +/// +/// PS-side mission log seam (§Mission Log). Entries are appended in order; the +/// PS reads the log to evaluate whether each new request is consistent with the +/// mission's intent. The SDK provides the contract and an in-memory default +/// (). +/// +public interface IMissionLog +{ + /// Append to the mission's ordered log. + Task AppendAsync(MissionLogEntry entry, CancellationToken ct = default); + + /// Read the mission's entries in append order. + Task> ReadAsync(string s256, CancellationToken ct = default); + + /// + /// Whether the mission already has a granted token entry for + /// and — the prior-consent + /// signal the PS uses to resolve a repeat request silently (§Agent Token + /// Request — prior-consent gate). + /// + Task HasPriorConsentAsync(string s256, string resource, string scope, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/IMissionStore.cs b/src/AAuth/Server/Governance/IMissionStore.cs new file mode 100644 index 0000000..bf7bbab --- /dev/null +++ b/src/AAuth/Server/Governance/IMissionStore.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; + +namespace AAuth.Server.Governance; + +/// +/// A mission as persisted by the PS: the verbatim approval blob bytes (so the +/// s256 remains verifiable) plus its lifecycle state (§Mission Approval, +/// §Mission Management). +/// +/// The mission identity — base64url(SHA-256(blob)). +/// HTTPS URL of the approver (the PS). +/// The agent identifier the mission was approved for. +/// The exact approval response body bytes, stored verbatim. +public sealed record StoredMission( + string S256, + string Approver, + string Agent, + ReadOnlyMemory Blob) +{ + /// The mission lifecycle state (§Mission Management). Defaults to active. + public MissionState State { get; init; } = MissionState.Active; +} + +/// +/// PS-side persistence seam for missions (§Mission Approval, §Mission +/// Management). The SDK provides the contract and an in-memory default +/// (); a production PS swaps in durable storage. +/// +public interface IMissionStore +{ + /// Persist (or replace) a mission keyed by its s256. + Task SaveAsync(StoredMission mission, CancellationToken ct = default); + + /// Look up a mission by its s256. Returns when absent. + Task GetAsync(string s256, CancellationToken ct = default); + + /// + /// Transition a mission to (e.g. on completion or + /// revocation). No-op when the mission is absent. + /// + Task SetStateAsync(string s256, MissionState state, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/IPermissionDecider.cs b/src/AAuth/Server/Governance/IPermissionDecider.cs new file mode 100644 index 0000000..99cb569 --- /dev/null +++ b/src/AAuth/Server/Governance/IPermissionDecider.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// The PS's outcome for a permission request (§Permission Response). +public enum PermissionOutcome +{ + /// Grant without prompting the user. + Granted, + + /// Deny without prompting the user. + Denied, + + /// Defer — the PS must prompt the user before deciding. + Prompt, +} + +/// +/// Why the PS reached a permission decision. The SDK supplies the reason enum so +/// a PS can surface it to UIs and the mission log; the PS owns the policy +/// (§Agent Token Request, §Permission Endpoint). +/// +public enum PermissionDecisionReason +{ + /// The action is within the mission's approved scope. + InScope, + + /// The user previously consented to an equivalent action. + PriorConsent, + + /// The action is a pre-approved tool on the mission (approved_tools). + ApprovedTool, + + /// The action is outside known scope — the user must be prompted. + OutOfScope, +} + +/// +/// The inputs a PS evaluates when deciding a permission request: the request +/// itself, the mission it is bound to (if any), and the mission log history. +/// +/// The parsed permission request. +/// The bound mission, or when missionless. +/// The mission log entries (empty when missionless). +public sealed record PermissionDecisionContext( + PermissionRequest Request, + StoredMission? Mission, + IReadOnlyList Log); + +/// +/// A typed permission decision carrying both the outcome and the reason, so a PS +/// can act on it and display it (§Permission Endpoint). +/// +/// Grant, deny, or prompt. +/// Why the decision was reached. +/// Optional Markdown message for the user (e.g. a denial reason). +public sealed record PermissionDecision( + PermissionOutcome Outcome, + PermissionDecisionReason Reason, + string? Message = null); + +/// +/// PS-side policy seam for the permission endpoint (§Permission Endpoint). The +/// SDK supplies the inputs () and the +/// reason enum; the PS implements the policy. +/// +public interface IPermissionDecider +{ + /// Decide a permission request given its mission + log context. + Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/InMemoryMissionLog.cs b/src/AAuth/Server/Governance/InMemoryMissionLog.cs new file mode 100644 index 0000000..c3105e1 --- /dev/null +++ b/src/AAuth/Server/Governance/InMemoryMissionLog.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AAuth.Server.Governance; + +/// +/// In-memory for development and testing. Preserves +/// append order per mission. NOT production-grade — state is lost on restart. +/// +public sealed class InMemoryMissionLog : IMissionLog +{ + private readonly object _gate = new(); + private readonly Dictionary> _entries = new(); + + /// + public Task AppendAsync(MissionLogEntry entry, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(entry); + lock (_gate) + { + if (!_entries.TryGetValue(entry.S256, out var list)) + { + list = new List(); + _entries[entry.S256] = list; + } + list.Add(entry); + } + return Task.CompletedTask; + } + + /// + public Task> ReadAsync(string s256, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(s256); + lock (_gate) + { + IReadOnlyList snapshot = _entries.TryGetValue(s256, out var list) + ? list.ToArray() + : Array.Empty(); + return Task.FromResult(snapshot); + } + } + + /// + public Task HasPriorConsentAsync(string s256, string resource, string scope, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(s256); + ArgumentException.ThrowIfNullOrEmpty(resource); + ArgumentException.ThrowIfNullOrEmpty(scope); + lock (_gate) + { + if (!_entries.TryGetValue(s256, out var list)) + { + return Task.FromResult(false); + } + foreach (var entry in list) + { + if (entry.Kind == MissionLogEntryKind.Token + && entry.Granted == true + && string.Equals(entry.Resource, resource, StringComparison.Ordinal) + && string.Equals(entry.Scope, scope, StringComparison.Ordinal)) + { + return Task.FromResult(true); + } + } + return Task.FromResult(false); + } + } +} diff --git a/src/AAuth/Server/Governance/InMemoryMissionStore.cs b/src/AAuth/Server/Governance/InMemoryMissionStore.cs new file mode 100644 index 0000000..a44cd0d --- /dev/null +++ b/src/AAuth/Server/Governance/InMemoryMissionStore.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; + +namespace AAuth.Server.Governance; + +/// +/// In-memory for development and testing. NOT +/// production-grade — state is lost on restart and there is no distributed +/// support. +/// +public sealed class InMemoryMissionStore : IMissionStore +{ + private readonly ConcurrentDictionary _missions = new(); + + /// + public Task SaveAsync(StoredMission mission, CancellationToken ct = default) + { + System.ArgumentNullException.ThrowIfNull(mission); + _missions[mission.S256] = mission; + return Task.CompletedTask; + } + + /// + public Task GetAsync(string s256, CancellationToken ct = default) + { + System.ArgumentException.ThrowIfNullOrEmpty(s256); + _missions.TryGetValue(s256, out var mission); + return Task.FromResult(mission); + } + + /// + public Task SetStateAsync(string s256, MissionState state, CancellationToken ct = default) + { + System.ArgumentException.ThrowIfNullOrEmpty(s256); + if (_missions.TryGetValue(s256, out var existing)) + { + _missions[s256] = existing with { State = state }; + } + return Task.CompletedTask; + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs new file mode 100644 index 0000000..1659d72 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Discovery; +using AAuth.Errors; +using AAuth.Headers; +using AAuth.Tokens; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the agent-side PS governance clients (AAuth protocol +/// §PS Governance Endpoints, §Mission Creation, §Permission Endpoint, +/// §Audit Endpoint, §Interaction Endpoint, §Person Server Metadata). +/// +public class GovernanceClientTests +{ + private const string Ps = "http://localhost:5555"; + + private static readonly MissionClaim TestMission = + new("http://localhost:5555", "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); + + private static (HttpClient signed, MetadataClient metadata) Build(HttpMessageHandler handler) + => (new HttpClient(handler) { BaseAddress = new Uri(Ps) }, + new MetadataClient(new HttpClient(handler))); + + // ---- §Person Server Metadata ---- + + [Fact(DisplayName = "§Person Server Metadata — all four governance endpoints are parsed")] + public void ServerMetadata_ParsesGovernanceEndpoints() + { + var doc = new JsonObject + { + ["issuer"] = Ps, + ["jwks_uri"] = Ps + "/jwks", + ["token_endpoint"] = Ps + "/token", + ["mission_endpoint"] = Ps + "/mission", + ["permission_endpoint"] = Ps + "/permission", + ["audit_endpoint"] = Ps + "/audit", + ["interaction_endpoint"] = Ps + "/interaction", + }; + + var metadata = ServerMetadata.FromJson(doc); + + Assert.Equal(Ps + "/mission", metadata.MissionEndpoint); + Assert.Equal(Ps + "/permission", metadata.PermissionEndpoint); + Assert.Equal(Ps + "/audit", metadata.AuditEndpoint); + Assert.Equal(Ps + "/interaction", metadata.InteractionEndpoint); + } + + // ---- §Mission Creation / §Mission Approval ---- + + [Fact(DisplayName = "§Mission Approval — ProposeAsync returns an approved mission and verifies s256")] + public async Task MissionClient_Propose_ReturnsApprovedMission() + { + var handler = new GovernanceHandler(); + var (signed, metadata) = Build(handler); + var client = new MissionClient(signed, metadata); + + var mission = await client.ProposeAsync(Ps, new MissionProposal("# Plan a trip") + { + Tools = new[] { new MissionTool("WebSearch", "Search the web") }, + }); + + Assert.Equal("aauth:assistant@agent.example", mission.Agent); + Assert.Single(mission.ApprovedTools); + Assert.Equal("WebSearch", mission.ApprovedTools[0].Name); + Assert.True(mission.VerifyS256(handler.MissionS256)); + } + + [Fact(DisplayName = "§Mission Approval — s256 header mismatch throws")] + public async Task MissionClient_S256Mismatch_Throws() + { + var handler = new GovernanceHandler { TamperMissionHeaderS256 = true }; + var (signed, metadata) = Build(handler); + var client = new MissionClient(signed, metadata); + + await Assert.ThrowsAsync(() => + client.ProposeAsync(Ps, new MissionProposal("# Plan a trip"))); + } + + [Fact(DisplayName = "§Mission Creation — 202 clarification review resolves to an approved mission")] + public async Task MissionClient_ClarificationReview_ResolvesToMission() + { + var handler = new GovernanceHandler { MissionNeedsClarification = true }; + var (signed, metadata) = Build(handler); + var client = new MissionClient(signed, metadata); + + ClarificationRequirement? seen = null; + var mission = await client.ProposeAsync(Ps, new MissionProposal("# Plan a trip"), + new GovernanceOptions + { + OnClarificationRequired = (clarification, _) => + { + seen = clarification; + return Task.FromResult(ClarificationResponse.Respond("2 adults, $5k budget.")); + }, + }); + + Assert.NotNull(seen); + Assert.Equal("aauth:assistant@agent.example", mission.Agent); + Assert.Equal("2 adults, $5k budget.", handler.LastClarificationResponse); + } + + // ---- §Permission Endpoint ---- + + [Fact(DisplayName = "§Permission Response — granted is parsed")] + public async Task PermissionClient_Granted() + { + var handler = new GovernanceHandler(); + var (signed, metadata) = Build(handler); + var client = new PermissionClient(signed, metadata); + + var result = await client.RequestAsync(Ps, new PermissionRequest("SendEmail") + { + Description = "Send the itinerary", + Mission = TestMission, + }); + + Assert.True(result.IsGranted); + } + + [Fact(DisplayName = "§Permission Response — denied carries a reason")] + public async Task PermissionClient_Denied() + { + var handler = new GovernanceHandler { PermissionDenied = true }; + var (signed, metadata) = Build(handler); + var client = new PermissionClient(signed, metadata); + + var result = await client.RequestAsync(Ps, new PermissionRequest("DeleteAll")); + + Assert.Equal(PermissionGrant.Denied, result.Grant); + Assert.Equal("Out of scope.", result.Reason); + } + + [Fact(DisplayName = "§Permission Endpoint — approved_tools short-circuit avoids the PS call")] + public async Task PermissionClient_ApprovedTool_ShortCircuits() + { + var handler = new GovernanceHandler(); + var (signed, metadata) = Build(handler); + var client = new PermissionClient(signed, metadata); + + var mission = new Mission + { + Approver = Ps, + Agent = "aauth:assistant@agent.example", + ApprovedAt = DateTimeOffset.UtcNow, + Description = "x", + S256 = "abc", + ApprovedTools = new[] { new MissionTool("WebSearch") }, + }; + + var result = await client.RequestAsync(Ps, "WebSearch", mission); + + Assert.True(result.IsGranted); + Assert.False(handler.PermissionCalled); + } + + [Fact(DisplayName = "§Mission Status Errors — permission 403 mission_terminated throws")] + public async Task PermissionClient_MissionTerminated_Throws() + { + var handler = new GovernanceHandler { MissionTerminated = true }; + var (signed, metadata) = Build(handler); + var client = new PermissionClient(signed, metadata); + + var ex = await Assert.ThrowsAsync(() => + client.RequestAsync(Ps, new PermissionRequest("SendEmail") { Mission = TestMission })); + + Assert.Equal("terminated", ex.MissionStatus); + } + + // ---- §Audit Endpoint ---- + + [Fact(DisplayName = "§Audit Response — 201 acknowledges the record")] + public async Task AuditClient_Records() + { + var handler = new GovernanceHandler(); + var (signed, metadata) = Build(handler); + var client = new AuditClient(signed, metadata); + + await client.RecordAsync(Ps, new AuditRecord(TestMission, "WebSearch") + { + Description = "Searched for flights", + }); + + Assert.True(handler.AuditCalled); + } + + [Fact(DisplayName = "§Mission Status Errors — audit 403 mission_terminated throws")] + public async Task AuditClient_MissionTerminated_Throws() + { + var handler = new GovernanceHandler { MissionTerminated = true }; + var (signed, metadata) = Build(handler); + var client = new AuditClient(signed, metadata); + + await Assert.ThrowsAsync(() => + client.RecordAsync(Ps, new AuditRecord(TestMission, "WebSearch"))); + } + + // ---- §Interaction Endpoint ---- + + [Fact(DisplayName = "§Interaction Response — question returns the user's answer")] + public async Task InteractionClient_Question_ReturnsAnswer() + { + var handler = new GovernanceHandler(); + var (signed, metadata) = Build(handler); + var client = new InteractionClient(signed, metadata); + + var answer = await client.AskQuestionAsync(Ps, "Refundable option?"); + + Assert.Equal("Yes, go ahead.", answer); + } + + [Fact(DisplayName = "§Interaction Response — completion terminates the mission")] + public async Task InteractionClient_Completion_Terminates() + { + var handler = new GovernanceHandler(); + var (signed, metadata) = Build(handler); + var client = new InteractionClient(signed, metadata); + + var terminated = await client.ProposeCompletionAsync(Ps, "# Done", TestMission); + + Assert.True(terminated); + Assert.Equal("completion", handler.LastInteractionType); + } + + /// Configurable PS mock for the governance endpoints. + private sealed class GovernanceHandler : HttpMessageHandler + { + public bool PermissionDenied { get; init; } + public bool MissionTerminated { get; init; } + public bool TamperMissionHeaderS256 { get; init; } + public bool MissionNeedsClarification { get; init; } + + public bool PermissionCalled { get; private set; } + public bool AuditCalled { get; private set; } + public string? LastInteractionType { get; private set; } + public string? LastClarificationResponse { get; private set; } + public string MissionS256 { get; private set; } = ""; + + private bool _missionClarified; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + + if (path == "/.well-known/aauth-person.json") + { + return Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = Ps, + ["jwks_uri"] = Ps + "/jwks", + ["token_endpoint"] = Ps + "/token", + ["mission_endpoint"] = Ps + "/mission", + ["permission_endpoint"] = Ps + "/permission", + ["audit_endpoint"] = Ps + "/audit", + ["interaction_endpoint"] = Ps + "/interaction", + }); + } + + if (MissionTerminated) + { + return Json(HttpStatusCode.Forbidden, new JsonObject + { + ["error"] = "mission_terminated", + ["mission_status"] = "terminated", + }); + } + + // §Clarification Chat during mission review. + if (path == "/pending/m" && request.Method == HttpMethod.Post) + { + var crBody = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + LastClarificationResponse = (string?)crBody?["clarification_response"]; + _missionClarified = true; + return new HttpResponseMessage(HttpStatusCode.OK); + } + + switch (path) + { + case "/mission" when MissionNeedsClarification && !_missionClarified: + case "/pending/m" when MissionNeedsClarification && !_missionClarified: + { + var clarify = Json(HttpStatusCode.Accepted, new JsonObject + { + ["status"] = "pending", + ["clarification"] = "How many travelers and what budget?", + }); + clarify.Headers.Location = new Uri(Ps + "/pending/m"); + clarify.Headers.TryAddWithoutValidation( + AAuth.Headers.AAuthRequirementHeader.Name, "requirement=clarification"); + return clarify; + } + + case "/mission": + case "/pending/m": + { + // Return the mission blob verbatim and the AAuth-Mission header + // whose s256 is SHA-256 over the exact body bytes (§Mission Approval). + var blob = new JsonObject + { + ["approver"] = Ps, + ["agent"] = "aauth:assistant@agent.example", + ["approved_at"] = "2026-04-07T14:30:00Z", + ["description"] = "# Plan a trip", + ["approved_tools"] = new JsonArray + { + new JsonObject { ["name"] = "WebSearch", ["description"] = "Search the web" }, + }, + }; + var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); + MissionS256 = Base64UrlEncoder.Encode(SHA256.HashData(bytes)); + var headerS256 = TamperMissionHeaderS256 ? "tampered-value" : MissionS256; + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes), + }; + resp.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + resp.Headers.TryAddWithoutValidation( + "AAuth-Mission", $"approver=\"{Ps}\"; s256=\"{headerS256}\""); + return resp; + } + + case "/permission": + { + PermissionCalled = true; + return PermissionDenied + ? Json(HttpStatusCode.OK, new JsonObject + { + ["permission"] = "denied", + ["reason"] = "Out of scope.", + }) + : Json(HttpStatusCode.OK, new JsonObject { ["permission"] = "granted" }); + } + + case "/audit": + AuditCalled = true; + return new HttpResponseMessage(HttpStatusCode.Created); + + case "/interaction": + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + LastInteractionType = (string?)body?["type"]; + return LastInteractionType switch + { + "question" => Json(HttpStatusCode.OK, new JsonObject { ["answer"] = "Yes, go ahead." }), + "completion" => new HttpResponseMessage(HttpStatusCode.OK), + _ => new HttpResponseMessage(HttpStatusCode.OK), + }; + } + + default: + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) + => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs new file mode 100644 index 0000000..1f83e2f --- /dev/null +++ b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Server.Governance; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the PS-side governance seams (AAuth protocol §PS Governance +/// Endpoints, §Mission Log, §Mission Status Errors): request parsers, the +/// mission store, the ordered mission log with prior-consent lookup, and the +/// mission_terminated response helper. +/// +public class GovernanceServerTests +{ + private const string S256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + // ---- §Request parsers ---- + + [Fact(DisplayName = "§Permission Request — parser maps JSON to PermissionRequest")] + public void ParsePermission_MapsFields() + { + var body = new JsonObject + { + ["action"] = "SendEmail", + ["description"] = "Send the itinerary", + ["parameters"] = new JsonObject { ["to"] = "user@example.com" }, + ["mission"] = new JsonObject { ["approver"] = "https://ps.example", ["s256"] = S256 }, + }; + + var request = GovernanceEndpoints.ParsePermission(body); + + Assert.Equal("SendEmail", request.Action); + Assert.Equal("Send the itinerary", request.Description); + Assert.Equal("user@example.com", (string?)request.Parameters!["to"]); + Assert.Equal(S256, request.Mission!.S256); + } + + [Fact(DisplayName = "§Permission Request — missing action throws")] + public void ParsePermission_MissingAction_Throws() + => Assert.Throws(() => + GovernanceEndpoints.ParsePermission(new JsonObject { ["description"] = "x" })); + + [Fact(DisplayName = "§Audit Request — parser requires mission and action")] + public void ParseAudit_MapsFields() + { + var body = new JsonObject + { + ["mission"] = new JsonObject { ["approver"] = "https://ps.example", ["s256"] = S256 }, + ["action"] = "WebSearch", + ["result"] = new JsonObject { ["status"] = "completed" }, + }; + + var record = GovernanceEndpoints.ParseAudit(body); + + Assert.Equal(S256, record.Mission.S256); + Assert.Equal("WebSearch", record.Action); + Assert.Equal("completed", (string?)record.Result!["status"]); + } + + [Fact(DisplayName = "§Audit Request — missing mission throws")] + public void ParseAudit_MissingMission_Throws() + => Assert.Throws(() => + GovernanceEndpoints.ParseAudit(new JsonObject { ["action"] = "WebSearch" })); + + [Theory(DisplayName = "§Interaction Request — parser maps each type")] + [InlineData("interaction", InteractionType.Interaction)] + [InlineData("payment", InteractionType.Payment)] + [InlineData("question", InteractionType.Question)] + [InlineData("completion", InteractionType.Completion)] + public void ParseInteraction_MapsType(string wire, InteractionType expected) + { + var request = GovernanceEndpoints.ParseInteraction(new JsonObject { ["type"] = wire }); + Assert.Equal(expected, request.Type); + } + + [Fact(DisplayName = "§Interaction Request — unknown type throws")] + public void ParseInteraction_UnknownType_Throws() + => Assert.Throws(() => + GovernanceEndpoints.ParseInteraction(new JsonObject { ["type"] = "bogus" })); + + [Fact(DisplayName = "§Mission Creation — proposal parser maps description and tools")] + public void ParseMissionProposal_MapsFields() + { + var body = new JsonObject + { + ["description"] = "# Plan a trip", + ["tools"] = new JsonArray + { + new JsonObject { ["name"] = "WebSearch", ["description"] = "Search" }, + }, + }; + + var proposal = GovernanceEndpoints.ParseMissionProposal(body); + + Assert.Equal("# Plan a trip", proposal.Description); + Assert.Single(proposal.Tools); + Assert.Equal("WebSearch", proposal.Tools[0].Name); + } + + // ---- §Mission Status Errors ---- + + [Fact(DisplayName = "§Mission Status Errors — helper emits the spec 403 body")] + public void MissionTerminatedBody_MatchesSpec() + { + var body = GovernanceEndpoints.MissionTerminatedBody(); + Assert.Equal(403, GovernanceEndpoints.MissionTerminatedStatus); + Assert.Equal("mission_terminated", (string?)body["error"]); + Assert.Equal("terminated", (string?)body["mission_status"]); + } + + // ---- §Mission Approval / §Mission Management (store) ---- + + [Fact(DisplayName = "§Mission store — stores verbatim blob bytes and state transitions")] + public async Task MissionStore_StoresBlobAndState() + { + var store = new InMemoryMissionStore(); + var blob = System.Text.Encoding.UTF8.GetBytes("{\"approver\":\"https://ps.example\"}"); + await store.SaveAsync(new StoredMission(S256, "https://ps.example", "aauth:a@x.example", blob)); + + var loaded = await store.GetAsync(S256); + Assert.NotNull(loaded); + Assert.Equal(MissionState.Active, loaded!.State); + Assert.True(blob.AsSpan().SequenceEqual(loaded.Blob.Span)); + + await store.SetStateAsync(S256, MissionState.Terminated); + var terminated = await store.GetAsync(S256); + Assert.Equal(MissionState.Terminated, terminated!.State); + } + + [Fact(DisplayName = "§Mission store — absent mission returns null")] + public async Task MissionStore_Absent_ReturnsNull() + => Assert.Null(await new InMemoryMissionStore().GetAsync("nope")); + + // ---- §Mission Log ---- + + [Fact(DisplayName = "§Mission Log — entries are appended and read in order")] + public async Task MissionLog_PreservesOrder() + { + var log = new InMemoryMissionLog(); + var now = DateTimeOffset.UtcNow; + await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, now) { Detail = "first" }); + await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Permission, now) { Detail = "second" }); + await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Audit, now) { Detail = "third" }); + + var entries = await log.ReadAsync(S256); + + Assert.Equal(new[] { "first", "second", "third" }, entries.Select(e => e.Detail)); + } + + [Fact(DisplayName = "§Mission Log — prior consent keyed by (s256, resource, scope)")] + public async Task MissionLog_PriorConsent() + { + var log = new InMemoryMissionLog(); + await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow) + { + Resource = "https://calendar.example", + Scope = "read", + Granted = true, + }); + + Assert.True(await log.HasPriorConsentAsync(S256, "https://calendar.example", "read")); + Assert.False(await log.HasPriorConsentAsync(S256, "https://calendar.example", "write")); + Assert.False(await log.HasPriorConsentAsync(S256, "https://mail.example", "read")); + } + + [Fact(DisplayName = "§Mission Log — a denied token entry does not count as prior consent")] + public async Task MissionLog_DeniedNotConsent() + { + var log = new InMemoryMissionLog(); + await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow) + { + Resource = "https://calendar.example", + Scope = "read", + Granted = false, + }); + + Assert.False(await log.HasPriorConsentAsync(S256, "https://calendar.example", "read")); + } + + // ---- §Permission Endpoint (decider seam) ---- + + [Fact(DisplayName = "§Permission Endpoint — decider is invoked with mission + log context")] + public async Task PermissionDecider_ReceivesContext() + { + var store = new InMemoryMissionStore(); + var log = new InMemoryMissionLog(); + var blob = System.Text.Encoding.UTF8.GetBytes("{}"); + await store.SaveAsync(new StoredMission(S256, "https://ps.example", "aauth:a@x.example", blob)); + await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow) + { + Resource = "https://calendar.example", + Scope = "read", + Granted = true, + }); + + var decider = new StubDecider(); + var request = new PermissionRequest("SendEmail") + { + Mission = new AAuth.Tokens.MissionClaim("https://ps.example", S256), + }; + var mission = await store.GetAsync(S256); + var entries = await log.ReadAsync(S256); + + var decision = await decider.DecideAsync(new PermissionDecisionContext(request, mission, entries)); + + Assert.Equal(PermissionOutcome.Prompt, decision.Outcome); + Assert.Equal(PermissionDecisionReason.OutOfScope, decision.Reason); + Assert.Same(request, decider.LastContext!.Request); + Assert.Equal(S256, decider.LastContext.Mission!.S256); + Assert.Single(decider.LastContext.Log); + } + + private sealed class StubDecider : IPermissionDecider + { + public PermissionDecisionContext? LastContext { get; private set; } + + public Task DecideAsync(PermissionDecisionContext context, System.Threading.CancellationToken ct = default) + { + LastContext = context; + return Task.FromResult(new PermissionDecision( + PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope)); + } + } +} From e2a9749283d7c5c321a36d28fbff409a95fd88d5 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 19:16:14 +0000 Subject: [PATCH 05/24] feat(missions): unify deferred transport and add governance facade - Extract DeferredExchange as the single deferred-HTTP transport; delete GovernanceExchange; TokenExchangeClient delegates transport, preserving access_denied / mission_terminated / token-error behaviour via option seams. - Add public AAuthGovernanceClient facade + AAuthClientBuilder.BuildGovernance() from a shared signed-channel helper. - Add GovernanceFacadeTests (5). Conformance 422, unit 371, builds 0/0. Phase 5.5 of missions/PS governance. --- .../implementation-plan.md | 36 ++- .../research.md | 61 ++++ src/AAuth/AAuthClientBuilder.cs | 53 +++- ...ernanceExchange.cs => DeferredExchange.cs} | 102 +++--- .../Agent/Governance/AAuthGovernanceClient.cs | 45 +++ src/AAuth/Agent/Governance/AuditClient.cs | 8 +- .../Agent/Governance/GovernanceOptions.cs | 48 +++ .../Agent/Governance/InteractionClient.cs | 9 +- src/AAuth/Agent/Governance/MissionClient.cs | 9 +- .../Agent/Governance/PermissionClient.cs | 9 +- src/AAuth/Agent/TokenExchangeClient.cs | 291 ++---------------- .../Missions/GovernanceFacadeTests.cs | 181 +++++++++++ 12 files changed, 520 insertions(+), 332 deletions(-) rename src/AAuth/Agent/{Governance/GovernanceExchange.cs => DeferredExchange.cs} (74%) create mode 100644 src/AAuth/Agent/Governance/AAuthGovernanceClient.cs create mode 100644 src/AAuth/Agent/Governance/GovernanceOptions.cs create mode 100644 tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index f042722..b66bdf1 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -355,17 +355,41 @@ token client's `(signedClient, metadata)` pair. ### Definition of Done -- [ ] Single `DeferredExchange` transport; `GovernanceExchange.cs` deleted; no +- [x] Single `DeferredExchange` transport; `GovernanceExchange.cs` deleted; no duplicated deferred-loop / buffer / requirement helpers remain. -- [ ] `TokenExchangeClient` delegates transport to `DeferredExchange`; its public +- [x] `TokenExchangeClient` delegates transport to `DeferredExchange`; its public API and wire behaviour are unchanged (`access_denied`, `mission_terminated`, token-error codes, diagnostics activities all preserved). -- [ ] `AAuthGovernanceClient` facade exposes mission/permission/audit/interaction +- [x] `AAuthGovernanceClient` facade exposes mission/permission/audit/interaction over one signed client; sub-clients remain public. -- [ ] `AAuthClientBuilder.BuildGovernance()` returns a facade wired from the same +- [x] `AAuthClientBuilder.BuildGovernance()` returns a facade wired from the same signed exchange pipeline as `BuildHandler()` (shared private helper). -- [ ] Full conformance (417) + unit (371) suites pass unchanged; new facade tests - pass; SDK + full solution build 0/0. +- [x] Full conformance (417 → **422** with new facade tests) + unit (371) suites + pass unchanged; new facade tests pass; SDK + full solution build 0/0. + +**Deviations (as built):** + +- The token-only `access_denied` classification and the token-only + fail-fast-without-callback behaviour are preserved through two + `DeferredExchangeOptions` seams rather than living in `TokenExchangeClient`: + `RequireInteractionCallback` (token = `true`, governance = `false`) reproduces + the token-exact "no onInteractionRequired callback" message, and + `OnPolledResponse` (token only) runs the `403 access_denied` classifier *after* + an interaction-branch poll (not after a clarification poll or the + initial/direct response), matching the original placement. +- `ResolveEndpointAsync(personServer, field, ct)` emits generic `'{field}'` + error text that is byte-identical to the original `'token_endpoint'` messages + when `field == "token_endpoint"`. +- The shared signed-channel helper is `BuildSignedChannel(provider, innerHandler)`. + `BuildHandler` passes `new HttpClientHandler()` (preserving the prior exchange + signer's exact inner handler); `BuildGovernance` passes + `_innerHandler ?? new HttpClientHandler()` so tests can inject a stub. +- `BuildGovernance()` requires an explicit signing mode (`_provider`); it does + **not** reconstruct the lazy-refresh token-holder pipeline (that path stays + exclusive to `BuildHandler`). Throws `InvalidOperationException` otherwise. +- `AAuth.DeferredPoll` now also fires for governance polls (additive + observability via the shared `DeferredExchange`, not a wire change). +- DI (`AddAAuthAgentGovernance`) remains out of scope — deferred to Phase 6. --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index 5596fe2..cf323c4 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -495,3 +495,64 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a `Missions/GovernanceServerTests.cs` (17). - **Validation:** 417 conformance (+18 across Phases 4+5) + 371 unit green; SDK + full solution build 0/0. + +### Phase 5.5 — Shared deferred transport + governance facade (2026-06-05, complete) + +- **Why (duplication).** `GovernanceExchange` (Phase 4) and + `TokenExchangeClient` (Phase 3) shared ~120 lines: endpoint origin-pin, the + `202` deferred loop (interaction + clarification), `ComposePollerOptions`, + `BufferBodyAsync` / `ReadJsonBodyAsync` / `ExtractRequirement` / + `ResolveLocation`, the `mission_terminated` reader, and `AddIfPresent`. Pure + refactor — same spec citations (§User Interaction, §Clarification Chat, + §Mission Status Errors, §PS Governance Endpoints, §Person Server Metadata). +- **Single transport (D8).** `internal sealed class DeferredExchange` + (`AAuth.Agent`) now owns the transport: `ResolveEndpointAsync(personServer, + field, ct)` (metadata fetch + https-or-loopback + same-origin pin, generic + `'{field}'` errors byte-identical to the old `'token_endpoint'` ones) and + `PostAsync(endpoint, body, DeferredExchangeOptions, ct)` (the `202` deferred + loop, returning the terminal `HttpResponseMessage` for the caller to parse + + dispose, throwing `AAuthMissionTerminatedException` on terminal `403 + mission_terminated`). It owns the `AAuth.DeferredPoll` activity and every + shared helper. `GovernanceExchange.cs` is **deleted**; `GovernanceOptions` + moved to its own file with `internal DeferredExchangeOptions ToExchangeOptions()` + (`RequireInteractionCallback = false`, no `OnPolledResponse`). The four + governance clients now hold a `DeferredExchange`. +- **Preserving token-only behaviour through two option seams.** Rather than + leaving token-specific branches inside `TokenExchangeClient`, two + `DeferredExchangeOptions` knobs reproduce them exactly: (1) + `RequireInteractionCallback` (token = `true`) throws the token-exact + "no onInteractionRequired callback" message on **any** non-clarification `202` + with no callback, whereas governance (`false`) only throws when an interaction + requirement is actually present; (2) `OnPolledResponse` (token only) runs the + `403 access_denied` → `AAuthInteractionDeniedException` classifier **after** an + interaction-branch poll (not after a clarification poll, not on the + initial/direct response), matching the original call-site placement. The + initial/direct/clarification `403`s still flow to `ReadAuthTokenAsync` as token + errors. `TokenExchangeRequest` and the public `TokenExchangeClient` API are + unchanged. +- **Facade + factory (D9).** Public `AAuthGovernanceClient` bundles + `Mission` / `Permission` / `Audit` / `Interaction` over one signed `HttpClient` + + `MetadataClient` (ctor `(HttpClient signedClient, MetadataClient metadata)`, + `ArgumentNullException` guards; sub-clients stay public). + `AAuthClientBuilder.BuildGovernance()` builds it from a shared private + `BuildSignedChannel(provider, innerHandler)` helper that also backs + `BuildHandler`'s exchange channel. `BuildHandler` passes `new + HttpClientHandler()` (preserving the prior exchange signer's inner handler); + `BuildGovernance` passes `_innerHandler ?? new HttpClientHandler()` for + testability. `BuildGovernance` **requires an explicit signing mode** + (`UseHwk`/`UseJwt`/`UseJwksUri`/`UseJktJwt`/`UseProvider`) and throws + `InvalidOperationException` otherwise — it does not reconstruct the lazy-refresh + token-holder pipeline (that stays exclusive to `BuildHandler`). +- **Observability note.** `AAuth.DeferredPoll` now also fires for governance + polls (additive, via the shared transport) — not a wire change; the token + diagnostics tests still observe `AAuth.TokenExchange` + `AAuth.DeferredPoll`. +- **DI deferred.** `AddAAuthAgentGovernance` is still out of scope; it will land + in Phase 6 only if a sample needs DI-resolved governance. +- **Files:** new `Agent/DeferredExchange.cs`, + `Agent/Governance/{GovernanceOptions,AAuthGovernanceClient}.cs`; deleted + `Agent/Governance/GovernanceExchange.cs`; modified `Agent/TokenExchangeClient.cs`, + the four governance clients, and `AAuthClientBuilder.cs`. **Tests:** + `Missions/GovernanceFacadeTests.cs` (5). +- **Validation:** 422 conformance (417 + 5 facade) + 371 unit green, both + unchanged from before the refactor; SDK + full solution build 0/0 — the + existing suites were the regression gate and caught nothing. diff --git a/src/AAuth/AAuthClientBuilder.cs b/src/AAuth/AAuthClientBuilder.cs index eb6d38d..05c77c5 100644 --- a/src/AAuth/AAuthClientBuilder.cs +++ b/src/AAuth/AAuthClientBuilder.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using AAuth.Agent; +using AAuth.Agent.Governance; using AAuth.Crypto; using AAuth.Discovery; using AAuth.HttpSig; @@ -388,6 +389,43 @@ public AAuthClientBuilder WithInteractionHandling() /// No signing mode was configured. public HttpClient Build() => new HttpClient(BuildHandler()); + /// + /// Build a governance client (mission / permission / audit / interaction) that + /// signs every request with the configured agent identity. Requires an explicit + /// signing mode (, , + /// , , or ). + /// The client is wired from the same signed exchange channel as the token-exchange + /// pipeline in . + /// + /// No signing mode was configured. + public AAuthGovernanceClient BuildGovernance() + { + var provider = _provider + ?? throw new InvalidOperationException( + "BuildGovernance requires an explicit signing mode (UseHwk, UseJwt, UseJwksUri, UseJktJwt, or UseProvider)."); + var (signed, metadata) = BuildSignedChannel(provider, _innerHandler ?? new HttpClientHandler()); + return new AAuthGovernanceClient(signed, metadata); + } + + // Build a signed HttpClient (pinned to the agent identity) plus a metadata + // client — the channel used for token exchange and governance calls. The long + // (infinite) timeout lets deferred long-polling (Prefer: wait=N) run past the + // default 100s; DeferredPollerOptions.MaxTotalWait enforces the real budget. + private (HttpClient Signed, MetadataClient Metadata) BuildSignedChannel( + ISignatureKeyProvider provider, HttpMessageHandler innerHandler) + { + var signer = new AAuthSigningHandler(_key, provider) + { + InnerHandler = innerHandler, + }; + var signed = new HttpClient(signer) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + var metadata = new MetadataClient(new HttpClient()); + return (signed, metadata); + } + /// /// Build the configured handler pipeline without wrapping it in an . /// Useful for DI registration via ConfigurePrimaryHttpMessageHandler. @@ -478,20 +516,7 @@ public HttpMessageHandler BuildHandler() // Exchange pipeline: separate signing handler pinned to the agent token. var exchangeProvider = _provider ?? holderProvider; - var exchangeSigner = new AAuthSigningHandler(_key, exchangeProvider) - { - InnerHandler = new HttpClientHandler(), - }; - var exchangeHttpClient = new HttpClient(exchangeSigner) - { - // Token exchange and deferred polling can legitimately take minutes - // (long-poll via Prefer: wait=N). The default 100s HttpClient.Timeout - // would abort mid-poll. The DeferredPoller enforces the real budget - // via DeferredPollerOptions.MaxTotalWait. - Timeout = Timeout.InfiniteTimeSpan, - }; - var metadataHttp = new HttpClient(); - var metadata = new MetadataClient(metadataHttp); + var (exchangeHttpClient, metadata) = BuildSignedChannel(exchangeProvider, new HttpClientHandler()); var exchangeClient = new TokenExchangeClient(exchangeHttpClient, metadata); var pollerOptions = new DeferredPollerOptions diff --git a/src/AAuth/Agent/Governance/GovernanceExchange.cs b/src/AAuth/Agent/DeferredExchange.cs similarity index 74% rename from src/AAuth/Agent/Governance/GovernanceExchange.cs rename to src/AAuth/Agent/DeferredExchange.cs index 29cc62e..bd50fd6 100644 --- a/src/AAuth/Agent/Governance/GovernanceExchange.cs +++ b/src/AAuth/Agent/DeferredExchange.cs @@ -11,30 +11,20 @@ using AAuth.Errors; using AAuth.Headers; -namespace AAuth.Agent.Governance; +namespace AAuth.Agent; /// -/// Optional deferred-handling callbacks shared by the PS governance clients -/// (mission, permission, interaction). A governance request may trigger a -/// 202 Accepted while the PS reaches the user; these callbacks let the -/// agent participate in the clarification chat (#clarification-chat) or relay an -/// interaction it cannot satisfy directly (#user-interaction). +/// Transport-level options for : the deferred +/// (202) callbacks shared by token exchange and the PS governance clients, +/// plus two seams that let the token-exchange path preserve its specific +/// behaviour. /// -public sealed class GovernanceOptions +internal sealed class DeferredExchangeOptions { - /// - /// Invoked when the PS returns requirement=interaction — the agent - /// must relay the URL/code to the user. When and the - /// PS defers with an interaction requirement, the request fails. - /// + /// Relay an interaction (URL/code) to the user (§User Interaction). public Func? OnInteractionRequired { get; init; } - /// - /// Invoked when the PS returns requirement=clarification during review - /// (§Clarification Chat). Returns the agent's decision (respond / update / - /// cancel). When and the PS asks for clarification, - /// the request fails. - /// + /// Answer a clarification question during review (§Clarification Chat). public Func>? OnClarificationRequired { get; init; } /// Maximum clarification rounds before the exchange aborts (default 5). @@ -42,21 +32,38 @@ public sealed class GovernanceOptions /// Optional polling tuning for deferred responses. public DeferredPollerOptions? PollerOptions { get; init; } + + /// + /// When , any non-clarification 202 requires an + /// interaction callback (token exchange cannot complete consent without one). + /// When , the callback is only required if the PS + /// returns an explicit interaction requirement (governance default). + /// + public bool RequireInteractionCallback { get; init; } + + /// + /// Invoked after each poll in the interaction branch, before the loop + /// re-checks for a 202. Token exchange uses this to classify a polled + /// 403 access_denied; the callback may throw. = + /// no-op. + /// + public Func? OnPolledResponse { get; init; } } /// -/// Shared transport for the PS governance endpoints (#ps-governance-endpoints): -/// resolves an endpoint from PS metadata (origin-pinned), POSTs a signed JSON -/// body, drives the deferred 202 loop (interaction + clarification), and -/// surfaces 403 mission_terminated (#mission-status-errors) as a typed -/// exception. +/// Shared transport for signed AAuth POSTs that may defer (token exchange and the +/// PS governance endpoints): resolves an endpoint from PS metadata (origin-pinned), +/// POSTs a signed JSON body, drives the deferred 202 loop (interaction + +/// clarification), and surfaces 403 mission_terminated +/// (#mission-status-errors) as a typed exception. The caller owns parsing the +/// terminal response and MUST dispose it. /// -internal sealed class GovernanceExchange +internal sealed class DeferredExchange { private readonly HttpClient _signedClient; private readonly MetadataClient _metadata; - internal GovernanceExchange(HttpClient signedClient, MetadataClient metadata) + internal DeferredExchange(HttpClient signedClient, MetadataClient metadata) { ArgumentNullException.ThrowIfNull(signedClient); ArgumentNullException.ThrowIfNull(metadata); @@ -65,9 +72,9 @@ internal GovernanceExchange(HttpClient signedClient, MetadataClient metadata) } /// - /// Fetch PS metadata and resolve the governance endpoint named - /// , pinned to the same origin as - /// and required to be https-or-loopback. + /// Fetch PS metadata and resolve the endpoint named , + /// pinned to the same origin as and required + /// to be https-or-loopback. /// internal async Task ResolveEndpointAsync( string personServer, string field, CancellationToken cancellationToken) @@ -80,7 +87,7 @@ internal async Task ResolveEndpointAsync( // Pin the endpoint to the configured PS origin and require https (or // loopback) so a compromised metadata document can't divert the signed - // governance request off-host (SSRF) or downgrade it to plain http. + // request off-host (SSRF) or downgrade it to plain http. if (!AAuthUrl.IsHttpsOrLoopback(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri)) { @@ -106,13 +113,15 @@ internal async Task ResolveEndpointAsync( /// parsing the terminal response and MUST dispose it. /// internal async Task PostAsync( - Uri endpoint, JsonObject body, GovernanceOptions? options, CancellationToken cancellationToken) + Uri endpoint, JsonObject body, DeferredExchangeOptions options, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(options); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { Content = JsonContent.Create(body), }; - if (options?.PollerOptions?.PreferWaitSeconds is { } preferWait) + if (options.PollerOptions?.PreferWaitSeconds is { } preferWait) { request.Headers.TryAddWithoutValidation("Prefer", $"wait={preferWait}"); } @@ -135,7 +144,7 @@ internal async Task PostAsync( var clarification = ClarificationRequirement.FromResponse(requirement, clarificationBody); response.Dispose(); - if (options?.OnClarificationRequired is null) + if (options.OnClarificationRequired is null) { throw new HttpRequestException( "PS returned requirement=clarification but no OnClarificationRequired callback was provided."); @@ -147,17 +156,26 @@ internal async Task PostAsync( .ConfigureAwait(false); await clarificationExchange.ApplyAsync(decision, cancellationToken).ConfigureAwait(false); - response = await PollAsync(pendingUrl, options?.PollerOptions, cancellationToken).ConfigureAwait(false); + response = await PollAsync(pendingUrl, options.PollerOptions, cancellationToken).ConfigureAwait(false); continue; } - // §User Interaction: the PS could not reach the user and handed - // the interaction back. Relay it (if the agent can) then poll. + // §User Interaction: token exchange requires an interaction + // callback for any deferred response; governance only when an + // interaction requirement is present. + if (options.RequireInteractionCallback && options.OnInteractionRequired is null) + { + var status = (int)response.StatusCode; + response.Dispose(); + throw new HttpRequestException( + $"PS returned {status} (deferred response) but no onInteractionRequired callback was provided."); + } + var interaction = requirement is null ? null : Interaction.FromRequirement(requirement); response.Dispose(); if (interaction is not null) { - if (options?.OnInteractionRequired is null) + if (options.OnInteractionRequired is null) { throw new HttpRequestException( "PS returned requirement=interaction but no OnInteractionRequired callback was provided."); @@ -165,7 +183,14 @@ internal async Task PostAsync( await options.OnInteractionRequired(interaction, cancellationToken).ConfigureAwait(false); } - response = await PollAsync(pendingUrl, options?.PollerOptions, cancellationToken).ConfigureAwait(false); + response = await PollAsync(pendingUrl, options.PollerOptions, cancellationToken).ConfigureAwait(false); + + // Token exchange classifies a polled 403 access_denied here (only + // after an interaction poll, matching the original placement). + if (options.OnPolledResponse is not null) + { + await options.OnPolledResponse(response, cancellationToken).ConfigureAwait(false); + } } // §Mission Status Errors: a 403 mission_terminated is terminal — the @@ -196,13 +221,14 @@ private async Task PollAsync( var composed = ComposePollerOptions(pollerOptions); try { + using var pollActivity = AAuthDiagnostics.Source.StartActivity("AAuth.DeferredPoll"); return await new DeferredPoller(_signedClient, composed) .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); } catch (TimeoutException ex) { throw new AAuthInteractionTimeoutException( - $"PS deferred governance request did not complete within the polling budget: {ex.Message}", + $"PS deferred request did not complete within the polling budget: {ex.Message}", ex); } } diff --git a/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs b/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs new file mode 100644 index 0000000..2b99739 --- /dev/null +++ b/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs @@ -0,0 +1,45 @@ +using System; +using System.Net.Http; +using AAuth.Discovery; + +namespace AAuth.Agent.Governance; + +/// +/// Bundles the PS governance clients (mission, permission, audit, interaction) +/// over a single signed and a shared +/// , so callers don't have to wire each client +/// individually. Build one with +/// or construct it directly. +/// +/// +/// The supplied MUST be wired with an +/// configured with the agent token, so +/// every governance request is signed. +/// +public sealed class AAuthGovernanceClient +{ + /// Propose and approve missions at the PS mission_endpoint. + public MissionClient Mission { get; } + + /// Request permission for actions at the PS permission_endpoint. + public PermissionClient Permission { get; } + + /// Record actions at the PS audit_endpoint. + public AuditClient Audit { get; } + + /// Reach the user via the PS interaction_endpoint. + public InteractionClient Interaction { get; } + + /// Create the facade over a signed client and metadata client. + /// HttpClient wired with an . + /// Metadata client for resolving the PS governance endpoints. + public AAuthGovernanceClient(HttpClient signedClient, MetadataClient metadata) + { + ArgumentNullException.ThrowIfNull(signedClient); + ArgumentNullException.ThrowIfNull(metadata); + Mission = new MissionClient(signedClient, metadata); + Permission = new PermissionClient(signedClient, metadata); + Audit = new AuditClient(signedClient, metadata); + Interaction = new InteractionClient(signedClient, metadata); + } +} diff --git a/src/AAuth/Agent/Governance/AuditClient.cs b/src/AAuth/Agent/Governance/AuditClient.cs index 8ef3bfc..f60b290 100644 --- a/src/AAuth/Agent/Governance/AuditClient.cs +++ b/src/AAuth/Agent/Governance/AuditClient.cs @@ -18,11 +18,11 @@ namespace AAuth.Agent.Governance; /// public sealed class AuditClient { - private readonly GovernanceExchange _exchange; + private readonly DeferredExchange _exchange; /// Create the audit client. public AuditClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new GovernanceExchange(signedClient, metadata); + => _exchange = new DeferredExchange(signedClient, metadata); /// /// Record at the PS at . @@ -42,7 +42,7 @@ public async Task RecordAsync( // Audit is fire-and-forget; no deferral handling is expected. var response = await _exchange.PostAsync( - endpoint, record.ToJsonObject(), options: null, cancellationToken).ConfigureAwait(false); + endpoint, record.ToJsonObject(), new DeferredExchangeOptions(), cancellationToken).ConfigureAwait(false); try { if (response.StatusCode == HttpStatusCode.Created @@ -52,7 +52,7 @@ public async Task RecordAsync( return; } - var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + var error = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException( $"Audit record failed with {(int)response.StatusCode}: {error}"); } diff --git a/src/AAuth/Agent/Governance/GovernanceOptions.cs b/src/AAuth/Agent/Governance/GovernanceOptions.cs new file mode 100644 index 0000000..751c544 --- /dev/null +++ b/src/AAuth/Agent/Governance/GovernanceOptions.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Headers; + +namespace AAuth.Agent.Governance; + +/// +/// Optional deferred-handling callbacks shared by the PS governance clients +/// (mission, permission, interaction). A governance request may trigger a +/// 202 Accepted while the PS reaches the user; these callbacks let the +/// agent participate in the clarification chat (#clarification-chat) or relay an +/// interaction it cannot satisfy directly (#user-interaction). +/// +public sealed class GovernanceOptions +{ + /// + /// Invoked when the PS returns requirement=interaction — the agent + /// must relay the URL/code to the user. When and the + /// PS defers with an interaction requirement, the request fails. + /// + public Func? OnInteractionRequired { get; init; } + + /// + /// Invoked when the PS returns requirement=clarification during review + /// (§Clarification Chat). Returns the agent's decision (respond / update / + /// cancel). When and the PS asks for clarification, + /// the request fails. + /// + public Func>? OnClarificationRequired { get; init; } + + /// Maximum clarification rounds before the exchange aborts (default 5). + public int MaxClarificationRounds { get; init; } = ClarificationExchange.DefaultMaxRounds; + + /// Optional polling tuning for deferred responses. + public DeferredPollerOptions? PollerOptions { get; init; } + + // Adapt the public governance options to the shared transport options. + // Governance never forces an interaction callback and has no post-poll hook. + internal DeferredExchangeOptions ToExchangeOptions() + => new() + { + OnInteractionRequired = OnInteractionRequired, + OnClarificationRequired = OnClarificationRequired, + MaxClarificationRounds = MaxClarificationRounds, + PollerOptions = PollerOptions, + }; +} diff --git a/src/AAuth/Agent/Governance/InteractionClient.cs b/src/AAuth/Agent/Governance/InteractionClient.cs index 0de7e40..0398abf 100644 --- a/src/AAuth/Agent/Governance/InteractionClient.cs +++ b/src/AAuth/Agent/Governance/InteractionClient.cs @@ -20,11 +20,11 @@ namespace AAuth.Agent.Governance; /// public sealed class InteractionClient { - private readonly GovernanceExchange _exchange; + private readonly DeferredExchange _exchange; /// Create the interaction client. public InteractionClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new GovernanceExchange(signedClient, metadata); + => _exchange = new DeferredExchange(signedClient, metadata); /// /// Send to the PS at @@ -43,12 +43,13 @@ public async Task SendAsync( personServer, "interaction_endpoint", cancellationToken).ConfigureAwait(false); var response = await _exchange.PostAsync( - endpoint, request.ToJsonObject(), options, cancellationToken).ConfigureAwait(false); + endpoint, request.ToJsonObject(), + options?.ToExchangeOptions() ?? new DeferredExchangeOptions(), cancellationToken).ConfigureAwait(false); try { if (!response.IsSuccessStatusCode) { - var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + var error = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException( $"Interaction request failed with {(int)response.StatusCode}: {error}"); } diff --git a/src/AAuth/Agent/Governance/MissionClient.cs b/src/AAuth/Agent/Governance/MissionClient.cs index 788a501..53bcc27 100644 --- a/src/AAuth/Agent/Governance/MissionClient.cs +++ b/src/AAuth/Agent/Governance/MissionClient.cs @@ -20,13 +20,13 @@ namespace AAuth.Agent.Governance; /// public sealed class MissionClient { - private readonly GovernanceExchange _exchange; + private readonly DeferredExchange _exchange; /// Create the mission client. /// HttpClient wired with an . /// Metadata client for resolving the PS mission_endpoint. public MissionClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new GovernanceExchange(signedClient, metadata); + => _exchange = new DeferredExchange(signedClient, metadata); /// /// Propose a mission to the PS at and return @@ -50,12 +50,13 @@ public async Task ProposeAsync( personServer, "mission_endpoint", cancellationToken).ConfigureAwait(false); var response = await _exchange.PostAsync( - endpoint, proposal.ToJsonObject(), options, cancellationToken).ConfigureAwait(false); + endpoint, proposal.ToJsonObject(), + options?.ToExchangeOptions() ?? new DeferredExchangeOptions(), cancellationToken).ConfigureAwait(false); try { if (!response.IsSuccessStatusCode) { - var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + var error = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException( $"Mission proposal failed with {(int)response.StatusCode}: {error}"); } diff --git a/src/AAuth/Agent/Governance/PermissionClient.cs b/src/AAuth/Agent/Governance/PermissionClient.cs index 7675b6a..5b32ca0 100644 --- a/src/AAuth/Agent/Governance/PermissionClient.cs +++ b/src/AAuth/Agent/Governance/PermissionClient.cs @@ -19,11 +19,11 @@ namespace AAuth.Agent.Governance; /// public sealed class PermissionClient { - private readonly GovernanceExchange _exchange; + private readonly DeferredExchange _exchange; /// Create the permission client. public PermissionClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new GovernanceExchange(signedClient, metadata); + => _exchange = new DeferredExchange(signedClient, metadata); /// /// Request permission for from the PS at @@ -42,12 +42,13 @@ public async Task RequestAsync( personServer, "permission_endpoint", cancellationToken).ConfigureAwait(false); var response = await _exchange.PostAsync( - endpoint, request.ToJsonObject(), options, cancellationToken).ConfigureAwait(false); + endpoint, request.ToJsonObject(), + options?.ToExchangeOptions() ?? new DeferredExchangeOptions(), cancellationToken).ConfigureAwait(false); try { if (!response.IsSuccessStatusCode) { - var error = await GovernanceExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + var error = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException( $"Permission request failed with {(int)response.StatusCode}: {error}"); } diff --git a/src/AAuth/Agent/TokenExchangeClient.cs b/src/AAuth/Agent/TokenExchangeClient.cs index 4338629..8bec9d7 100644 --- a/src/AAuth/Agent/TokenExchangeClient.cs +++ b/src/AAuth/Agent/TokenExchangeClient.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; -using System.Net.Http.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -26,19 +25,13 @@ namespace AAuth.Agent; /// public sealed class TokenExchangeClient { - private readonly HttpClient _signedClient; - private readonly MetadataClient _metadata; + private readonly DeferredExchange _exchange; /// Create the exchange client. /// HttpClient already wired with an . /// Metadata client for resolving the PS token_endpoint. public TokenExchangeClient(HttpClient signedClient, MetadataClient metadata) - { - ArgumentNullException.ThrowIfNull(signedClient); - ArgumentNullException.ThrowIfNull(metadata); - _signedClient = signedClient; - _metadata = metadata; - } + => _exchange = new DeferredExchange(signedClient, metadata); /// /// Submit to the PS at @@ -84,33 +77,8 @@ public async Task ExchangeAsync( using var activity = AAuthDiagnostics.Source.StartActivity("AAuth.TokenExchange"); - var metadataUrl = MetadataClient.BuildUrl(personServer, "aauth-person.json"); - var doc = await _metadata.FetchAsync(metadataUrl, cancellationToken).ConfigureAwait(false); - var tokenEndpoint = (string?)doc["token_endpoint"] - ?? throw new InvalidOperationException( - $"Person Server metadata at {metadataUrl} is missing 'token_endpoint'."); - - // A malicious or compromised PS metadata document could otherwise - // redirect the signed exchange request to an arbitrary URL (SSRF - // pivot for the agent) or downgrade it to plain http. Require the - // same https-or-loopback policy used elsewhere, and pin the - // endpoint to the same origin as the configured PS so a metadata - // compromise can't divert the signed exchange off-host. - if (!AAuthUrl.IsHttpsOrLoopback(tokenEndpoint) - || !Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var tokenEndpointUri)) - { - throw new InvalidOperationException( - $"Person Server 'token_endpoint' must be an absolute https:// URL (or http://localhost): {tokenEndpoint}"); - } - if (!Uri.TryCreate(personServer, UriKind.Absolute, out var psUri) - || !string.Equals( - tokenEndpointUri.GetLeftPart(UriPartial.Authority), - psUri.GetLeftPart(UriPartial.Authority), - StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Person Server 'token_endpoint' must share an origin with {personServer}: {tokenEndpoint}"); - } + var tokenEndpointUri = await _exchange.ResolveEndpointAsync( + personServer, "token_endpoint", cancellationToken).ConfigureAwait(false); var body = new JsonObject { ["resource_token"] = resourceToken }; if (!string.IsNullOrEmpty(upstreamToken)) @@ -139,102 +107,40 @@ public async Task ExchangeAsync( } // Optional consent/display parameters (§Agent Token Request). Each is // emitted only when set. - AddIfPresent(body, "justification", options.Justification); - AddIfPresent(body, "login_hint", options.LoginHint); - AddIfPresent(body, "tenant", options.Tenant); - AddIfPresent(body, "domain_hint", options.DomainHint); - AddIfPresent(body, "platform", options.Platform); - AddIfPresent(body, "device", options.Device); - using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpointUri) - { - Content = JsonContent.Create(body), - }; - - // Signal willingness to long-poll on the initial exchange request - // per spec: "agent signals its willingness to wait using the Prefer header". - if (pollerOptions?.PreferWaitSeconds is { } preferWait) - { - request.Headers.TryAddWithoutValidation("Prefer", $"wait={preferWait}"); - } - - var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - ClarificationExchange? clarificationExchange = null; - - try - { - // Resolve any deferred (202) requirements — user interaction and/or - // clarification chat — looping until the PS returns a terminal - // response (§User Interaction, §Clarification Chat). - while (response.StatusCode == HttpStatusCode.Accepted) + DeferredExchange.AddIfPresent(body, "justification", options.Justification); + DeferredExchange.AddIfPresent(body, "login_hint", options.LoginHint); + DeferredExchange.AddIfPresent(body, "tenant", options.Tenant); + DeferredExchange.AddIfPresent(body, "domain_hint", options.DomainHint); + DeferredExchange.AddIfPresent(body, "platform", options.Platform); + DeferredExchange.AddIfPresent(body, "device", options.Device); + + var exchangeOptions = new DeferredExchangeOptions + { + OnInteractionRequired = onInteractionRequired, + OnClarificationRequired = options.OnClarificationRequired, + MaxClarificationRounds = options.MaxClarificationRounds, + PollerOptions = pollerOptions, + // Token exchange cannot complete consent without an interaction + // callback, so any deferred 202 with no callback fails fast. + RequireInteractionCallback = true, + // §User Interaction: a user denial surfaces as 403 access_denied on + // the poll. Classify it only after an interaction poll (matching the + // original placement) so a direct/clarification 403 stays a token error. + OnPolledResponse = async (resp, ct) => { - var pendingUrl = ResolveLocation(response, tokenEndpointUri); - var requirement = ExtractRequirement(response); - - // §Clarification Chat: the PS is asking the agent a question - // during consent. Surface it via the callback, apply the agent's - // chosen action against the pending URL, then resume polling. - if (requirement?.Requirement == Headers.ClarificationRequirement.RequirementType) - { - var clarificationBody = await ReadJsonBodyAsync(response, cancellationToken).ConfigureAwait(false); - var clarification = Headers.ClarificationRequirement.FromResponse(requirement, clarificationBody); - response.Dispose(); - - if (options.OnClarificationRequired is null) - { - throw new HttpRequestException( - "PS returned requirement=clarification but no OnClarificationRequired callback was provided."); - } - - clarificationExchange ??= new ClarificationExchange( - _signedClient, pendingUrl, options.MaxClarificationRounds); - var decision = await options.OnClarificationRequired(clarification!, cancellationToken) - .ConfigureAwait(false); - await clarificationExchange.ApplyAsync(decision, cancellationToken).ConfigureAwait(false); - - response = await PollPendingAsync(pendingUrl, pollerOptions, cancellationToken).ConfigureAwait(false); - continue; - } - - // §User Interaction: out-of-band consent. The agent relays the - // URL+code to the user and then polls for completion. - if (onInteractionRequired is null) + if (resp.StatusCode == HttpStatusCode.Forbidden + && await IsAccessDeniedAsync(resp, ct).ConfigureAwait(false)) { - throw new HttpRequestException( - $"PS returned {(int)response.StatusCode} (deferred response) but no onInteractionRequired callback was provided."); - } - - var interaction = requirement is null ? null : Interaction.FromRequirement(requirement); - response.Dispose(); - if (interaction is not null) - { - await onInteractionRequired(interaction, cancellationToken).ConfigureAwait(false); - } - - response = await PollPendingAsync(pendingUrl, pollerOptions, cancellationToken).ConfigureAwait(false); - - // 403 access_denied → user explicitly denied. Surface a distinct - // typed exception so UIs / retry policies can treat denial - // differently from "unknown id" (404) or transport failure. - if (response.StatusCode == HttpStatusCode.Forbidden - && await IsAccessDeniedAsync(response, cancellationToken).ConfigureAwait(false)) - { - response.Dispose(); throw new AAuthInteractionDeniedException( "The user denied the AAuth interaction request."); } - } - - // §Mission Status Errors: a 403 mission_terminated means the request - // referenced a mission that is no longer active. Terminal — the - // agent must stop acting on the mission. - if (response.StatusCode == HttpStatusCode.Forbidden - && await TryReadMissionTerminatedAsync(response, cancellationToken).ConfigureAwait(false) - is var (terminated, missionStatus) && terminated) - { - response.Dispose(); - throw new Errors.AAuthMissionTerminatedException(missionStatus); - } + }, + }; + var response = await _exchange.PostAsync( + tokenEndpointUri, body, exchangeOptions, cancellationToken).ConfigureAwait(false); + try + { return await ReadAuthTokenAsync(response, cancellationToken).ConfigureAwait(false); } finally @@ -243,52 +149,6 @@ is var (terminated, missionStatus) && terminated) } } - // Poll the pending URL, translating a poll-budget timeout into the typed - // interaction-timeout exception and stopping early on a clarification 202 so - // the exchange loop can handle it (composing with any caller predicate). - private async Task PollPendingAsync( - Uri pendingUrl, DeferredPollerOptions? pollerOptions, CancellationToken cancellationToken) - { - var composed = ComposePollerOptions(pollerOptions); - try - { - using var pollActivity = AAuthDiagnostics.Source.StartActivity("AAuth.DeferredPoll"); - return await new DeferredPoller(_signedClient, composed) - .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); - } - catch (TimeoutException ex) - { - throw new AAuthInteractionTimeoutException( - $"PS deferred interaction did not complete within the polling budget: {ex.Message}", - ex); - } - } - - // Compose poller options so polling stops on a clarification 202 (returning - // it to the exchange loop), preserving any caller-supplied StopWhenAccepted. - private static DeferredPollerOptions ComposePollerOptions(DeferredPollerOptions? baseOptions) - { - var userStop = baseOptions?.StopWhenAccepted; - bool Stop(HttpResponseMessage resp) - { - if (userStop is not null && userStop(resp)) { return true; } - var requirement = ExtractRequirement(resp); - return requirement?.Requirement == Headers.ClarificationRequirement.RequirementType; - } - - return baseOptions is null - ? new DeferredPollerOptions { StopWhenAccepted = Stop } - : baseOptions with { StopWhenAccepted = Stop }; - } - - private static void AddIfPresent(JsonObject body, string name, string? value) - { - if (!string.IsNullOrEmpty(value)) - { - body[name] = value; - } - } - // Default capability inference: declare "interaction" when the caller can // handle a 202 + user-facing consent redirect, and "clarification" when the // caller can answer clarification questions. An explicit capabilities list @@ -314,7 +174,7 @@ private static async Task IsAccessDeniedAsync( { // Buffer the body so the subsequent ReadAuthTokenAsync (if we // decide it isn't access_denied) still sees it. - var body = await BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + var body = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); try { var json = JsonNode.Parse(body) as JsonObject; @@ -326,91 +186,6 @@ private static async Task IsAccessDeniedAsync( } } - // §Mission Status Errors: detect a 403 mission_terminated body. Buffers the - // body so a non-matching response still flows to ReadAuthTokenAsync. - // Returns (terminated, mission_status). - private static async Task<(bool Terminated, string? MissionStatus)> TryReadMissionTerminatedAsync( - HttpResponseMessage response, CancellationToken cancellationToken) - { - var body = await BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); - try - { - var json = JsonNode.Parse(body) as JsonObject; - if ((string?)json?["error"] == Errors.AAuthMissionTerminatedException.ErrorCode) - { - return (true, (string?)json?["mission_status"]); - } - return (false, null); - } - catch (System.Text.Json.JsonException) - { - return (false, null); - } - } - - // Read a response body to a string and replace the Content with a buffered - // copy (preserving media type / charset) so it can be read again — e.g. by - // a subsequent error classifier or ReadAuthTokenAsync. - private static async Task BufferBodyAsync( - HttpResponseMessage response, CancellationToken cancellationToken) - { - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var originalMediaType = response.Content.Headers.ContentType?.MediaType ?? "application/json"; - var originalCharset = response.Content.Headers.ContentType?.CharSet; - // Fall back to UTF-8 for unknown / malformed charset values rather - // than surfacing an ArgumentException from Encoding.GetEncoding, - // which would mask the real exchange failure the caller is trying - // to diagnose. - System.Text.Encoding encoding; - if (string.IsNullOrEmpty(originalCharset)) - { - encoding = System.Text.Encoding.UTF8; - } - else - { - try { encoding = System.Text.Encoding.GetEncoding(originalCharset); } - catch (ArgumentException) { encoding = System.Text.Encoding.UTF8; } - } - response.Content.Dispose(); - response.Content = new StringContent(body, encoding, originalMediaType); - return body; - } - - private static async Task ReadJsonBodyAsync( - HttpResponseMessage response, CancellationToken cancellationToken) - { - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(body)) - { - return null; - } - try { return JsonNode.Parse(body) as JsonObject; } - catch (System.Text.Json.JsonException) { return null; } - } - - private static AAuthRequirementHeader.ParsedRequirement? ExtractRequirement(HttpResponseMessage response) - { - if (!response.Headers.TryGetValues(AAuthRequirementHeader.Name, out var values)) - { - return null; - } - foreach (var raw in values) - { - if (string.IsNullOrWhiteSpace(raw)) { continue; } - try { return AAuthRequirementHeader.Parse(raw); } - catch (FormatException) { continue; } - } - return null; - } - - private static Uri ResolveLocation(HttpResponseMessage response, Uri @base) - { - var location = response.Headers.Location - ?? throw new HttpRequestException( - "Deferred PS response is missing the Location header — cannot poll."); - return location.IsAbsoluteUri ? location : new Uri(@base, location); - } - private static async Task ReadAuthTokenAsync( HttpResponseMessage response, CancellationToken cancellationToken) { diff --git a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs new file mode 100644 index 0000000..9ba3d01 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Crypto; +using AAuth.Discovery; +using AAuth.Tokens; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the bundled governance facade (AAuth protocol +/// §PS Governance Endpoints). exposes the +/// mission / permission / audit / interaction clients over a single signed +/// channel, and wires one from +/// the same signed exchange pipeline used for token exchange. +/// +public class GovernanceFacadeTests +{ + private const string Ps = "http://localhost:5555"; + + private static readonly MissionClaim TestMission = + new(Ps, "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); + + private static AAuthGovernanceClient BuildFacade(HttpMessageHandler handler) + => new( + new HttpClient(handler) { BaseAddress = new Uri(Ps) }, + new MetadataClient(new HttpClient(handler))); + + [Fact(DisplayName = "§PS Governance Endpoints — facade exposes all four governance clients")] + public void Facade_Ctor_ExposesFourClients() + { + var facade = BuildFacade(new FacadeHandler()); + + Assert.NotNull(facade.Mission); + Assert.NotNull(facade.Permission); + Assert.NotNull(facade.Audit); + Assert.NotNull(facade.Interaction); + } + + [Fact(DisplayName = "§PS Governance Endpoints — null signed client is rejected")] + public void Facade_Ctor_NullSignedClient_Throws() + { + Assert.Throws(() => + new AAuthGovernanceClient(null!, new MetadataClient(new HttpClient()))); + } + + [Fact(DisplayName = "§PS Governance Endpoints — facade clients share one signed channel and work end-to-end")] + public async Task Facade_SubClients_AreFunctional() + { + var handler = new FacadeHandler(); + var facade = BuildFacade(handler); + + var mission = await facade.Mission.ProposeAsync(Ps, new MissionProposal("# Plan a trip") + { + Tools = new[] { new MissionTool("WebSearch", "Search the web") }, + }); + Assert.Equal("aauth:assistant@agent.example", mission.Agent); + + var permission = await facade.Permission.RequestAsync( + Ps, new PermissionRequest("SendEmail") { Mission = TestMission }); + Assert.True(permission.IsGranted); + + await facade.Audit.RecordAsync(Ps, new AuditRecord(TestMission, "WebSearch")); + Assert.True(handler.AuditCalled); + + var answer = await facade.Interaction.AskQuestionAsync(Ps, "Refundable option?"); + Assert.Equal("Yes, go ahead.", answer); + } + + [Fact(DisplayName = "§PS Governance Endpoints — BuildGovernance wires a facade from a signing mode")] + public void BuildGovernance_WithSigningMode_ReturnsWiredFacade() + { + var facade = new AAuthClientBuilder(AAuthKey.Generate()) + .UseHwk() + .WithInnerHandler(new FacadeHandler()) + .BuildGovernance(); + + Assert.NotNull(facade.Mission); + Assert.NotNull(facade.Permission); + Assert.NotNull(facade.Audit); + Assert.NotNull(facade.Interaction); + } + + [Fact(DisplayName = "§PS Governance Endpoints — BuildGovernance requires an explicit signing mode")] + public void BuildGovernance_NoSigningMode_Throws() + { + var builder = new AAuthClientBuilder(AAuthKey.Generate()); + + var ex = Assert.Throws(() => builder.BuildGovernance()); + Assert.Contains("signing mode", ex.Message); + } + + /// Minimal PS mock serving the governance endpoints for the facade. + private sealed class FacadeHandler : HttpMessageHandler + { + public bool AuditCalled { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + + if (path == "/.well-known/aauth-person.json") + { + return Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = Ps, + ["jwks_uri"] = Ps + "/jwks", + ["token_endpoint"] = Ps + "/token", + ["mission_endpoint"] = Ps + "/mission", + ["permission_endpoint"] = Ps + "/permission", + ["audit_endpoint"] = Ps + "/audit", + ["interaction_endpoint"] = Ps + "/interaction", + }); + } + + switch (path) + { + case "/mission": + { + var blob = new JsonObject + { + ["approver"] = Ps, + ["agent"] = "aauth:assistant@agent.example", + ["approved_at"] = "2026-04-07T14:30:00Z", + ["description"] = "# Plan a trip", + ["approved_tools"] = new JsonArray + { + new JsonObject { ["name"] = "WebSearch", ["description"] = "Search the web" }, + }, + }; + var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); + var s256 = Base64UrlEncoder.Encode(SHA256.HashData(bytes)); + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes), + }; + resp.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + resp.Headers.TryAddWithoutValidation( + "AAuth-Mission", $"approver=\"{Ps}\"; s256=\"{s256}\""); + return resp; + } + + case "/permission": + return Json(HttpStatusCode.OK, new JsonObject { ["permission"] = "granted" }); + + case "/audit": + AuditCalled = true; + return new HttpResponseMessage(HttpStatusCode.Created); + + case "/interaction": + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + return (string?)body?["type"] == "question" + ? Json(HttpStatusCode.OK, new JsonObject { ["answer"] = "Yes, go ahead." }) + : new HttpResponseMessage(HttpStatusCode.OK); + } + + default: + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) + => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} From 8760f8a6ea1e35b12fd2270c59b8ad2db782c559 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 21:44:21 +0000 Subject: [PATCH 06/24] feat(missions): add MissionAgent CLI and mission-aware resource governance - MockPersonServer: serve mission/permission/audit/interaction endpoints, three-gate token consent, terminate hook, and {approver, s256} auth-token emission via the Phase 5 governance seams - MockPersonServer: interactive browser consent for mission creation, out-of-scope token, and out-of-tool permission; consent pages show the mission description + approved_tools as context - SDK: mission-aware resource support (AAuthMissionHeader.TryParseStructured, ChallengeOptions.MissionAware, AAuthChallengeMiddleware copies {approver, s256} into the resource token); WhoAmI /jwt/mission demonstrates it - samples/MissionAgent: new CLI driving the full mission lifecycle against the live mock servers (enrol -> propose -> access -> permission -> audit -> interaction -> complete), interactive by default, --auto for scripted - Tests: 12-row Consent-Matrix integration test (MissionAgentFlowTests) and +3 mission-aware ChallengeMiddleware conformance tests (425 / 383 green) - Makefile: demo-mission + agent-mission targets Phase 6a of missions/PS governance. --- .../implementation-plan.md | 71 +- .../research.md | 95 +++ AAuth.slnx | 1 + Makefile | 31 +- samples/MissionAgent/MissionAgent.csproj | 14 + samples/MissionAgent/Program.cs | 320 +++++++ samples/MissionAgent/README.md | 194 +++++ samples/MockPersonServer/MissionGovernance.cs | 356 ++++++++ samples/MockPersonServer/Program.cs | 778 +++++++++++++++++- samples/WhoAmI/Program.cs | 64 +- src/AAuth/Agent/Mission.cs | 31 + .../Challenge/AAuthChallengeMiddleware.cs | 15 + .../Server/Challenge/ChallengeOptions.cs | 11 + .../ChallengeMiddlewareTests.cs | 110 +++ .../Integration/MissionAgentFlowTests.cs | 377 +++++++++ 15 files changed, 2444 insertions(+), 24 deletions(-) create mode 100644 samples/MissionAgent/MissionAgent.csproj create mode 100644 samples/MissionAgent/Program.cs create mode 100644 samples/MissionAgent/README.md create mode 100644 samples/MockPersonServer/MissionGovernance.cs create mode 100644 tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index b66bdf1..d8b98ef 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -455,6 +455,22 @@ rewrite of existing flows. Prior-consent memory keyed by `(mission s256, resource, scope)`. The decision is mock PS policy implemented over the Phase 5 `IPermissionDecider` / `IMissionStore` / mission-log seams — not SDK behavior. +- **Sub-phasing (agreed 2026-06-05):** Phase 6 executes in three committable + sub-phases, each independently buildable + tested: + - **6a — Backend foundation:** MockPersonServer governance endpoints + `s256` + / mission claim emission + three-gate consent + terminate hook; new + `samples/MissionAgent/` CLI; the 12-row Consent-Matrix .NET integration test. + - **6b — Blazor + e2e:** SampleApp `Mission.razor` (+ Home link); GuidedTour + `TourMode.Mission` (+ snippets, sequence diagram); the two Playwright specs. + - **6c — Glue:** Orchestrator mission hop; `make demo-mission` / `e2e-mission`; + READMEs + `tests/e2e/package.json` script. +- **Deterministic consent scripting (agreed 2026-06-05 — option A):** the + integration test drives mission-approval / token / permission outcomes by + extending the existing unsigned `/admin/*` demo pattern (e.g. + `/admin/mission-decision`, `/admin/permission-decision`, plus pre-seeding + `approved_tools` / prior-consent), mirroring today's `/admin/consent`. No + config/convention-encoded policy. + #### Consent Test Matrix (CLI integration test) @@ -483,36 +499,57 @@ mission-log **decision reason**. ### Definition of Done -- [ ] MockPersonServer serves all four governance endpoints (§PS Governance). -- [ ] MockPersonServer embeds `{approver, s256}` in issued auth tokens (§Auth Token). -- [ ] MockPersonServer implements the three-gate consent decision: mission approved +- [x] MockPersonServer serves all four governance endpoints (§PS Governance). _(6a)_ +- [x] MockPersonServer embeds `{approver, s256}` in issued auth tokens (§Auth Token). _(6a)_ +- [x] MockPersonServer implements the three-gate consent decision: mission approved once, then resource/tool access proceeds without re-prompting unless outside - approved scope / `approved_tools` (§Agent Token Request, §Permission Endpoint). -- [ ] MockPersonServer exposes a minimal "terminate mission" hook so the - `mission_terminated` path is exercised end-to-end (§Mission Status Errors). -- [ ] `samples/MissionAgent/` proposes a mission, accesses ≥1 resource under it, + approved scope / `approved_tools` (§Agent Token Request, §Permission Endpoint). _(6a)_ +- [x] MockPersonServer exposes a minimal "terminate mission" hook so the + `mission_terminated` path is exercised end-to-end (§Mission Status Errors). _(6a)_ +- [x] `samples/MissionAgent/` proposes a mission, accesses ≥1 resource under it, requests a permission, records an audit entry, relays an interaction, and - completes it. + completes it. _(6a)_ - [ ] SampleApp `Mission.razor` page renders the flow and labels each consent gate as **prompt** or **silent (in scope)**; Home links to it; existing pages - unchanged. + unchanged. _(6b)_ - [ ] GuidedTour `TourMode.Mission` drives every gate through **both** outcomes (mission approval prompt; in-scope token silent vs out-of-scope token prompt; `approved_tools` permission silent vs non-pre-approved permission prompt) and - surfaces the PS decision reason for each; existing modes unchanged. + surfaces the PS decision reason for each; existing modes unchanged. _(6b)_ - [ ] The PS decision reason (in-scope / prior consent / `approved_tools` / out-of-scope) is visible in both samples so the contrast between prompted - and silent gates is observable. -- [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining). -- [ ] `make demo-mission` boots the mission demo; existing `make demo` unchanged. + and silent gates is observable. _(6b)_ +- [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining). _(6c)_ +- [x] `make demo-mission` boots the mission demo; existing `make demo` unchanged. + _(pulled forward from 6c; also added `agent-mission` runner)_ - [ ] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the - existing `guided-tour`/`sample-app` projects; existing specs still pass. -- [ ] **.NET integration test** for the `MissionAgent` CLI covers **all 12 rows** + existing `guided-tour`/`sample-app` projects; existing specs still pass. _(6b)_ +- [x] **.NET integration test** for the `MissionAgent` CLI covers **all 12 rows** of the Consent Test Matrix (every gate × approve/deny × prompt/silent), including clarification and `mission_terminated`, each asserting the - recorded decision reason. + recorded decision reason. _(6a — `MissionAgentFlowTests`, 12/12)_ - [ ] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally and in CI. -- [ ] Sample READMEs updated. + _(CLI integration green; Blazor e2e in 6b)_ +- [ ] Sample READMEs updated. _(MissionAgent + MockPersonServer done in 6a; others in 6c)_ + +#### Phase 6a additions (spec-driven, beyond the original file list) + +- **Mission-aware resource (SDK):** `AAuthMissionHeader.TryParseStructured`, + `ChallengeOptions.MissionAware`, and `AAuthChallengeMiddleware` copying the + parsed `{approver, s256}` into `ResourceTokenBuilder.Mission` so a resource can + surface the mission claim in the resource token it issues (§Terminology, + §Auth Token). Demonstrated by WhoAmI `/jwt/mission`. Covered by +3 conformance + tests (`ChallengeMiddlewareTests`, total 425). +- **Interactive mission-creation consent screen:** `/mission` defers (202 + + interaction) to a real browser consent screen when running interactively + (`MissionConsentScript.InteractiveBrowser`), via the same deferred path the + token/permission gates use (SDK `MissionClient.ProposeAsync` already routes + through `DeferredExchange`). The PS `/interaction` page now renders all three + consent screens — mission creation (description + tools), out-of-scope token + (mission + tools + resource/scope), and out-of-tool permission (mission + + tools + action) — keeping the demo faithful to §Mission Creation / + §Permission Endpoint (`action` per-call vs mission `approved_tools`). + Scripted mode (the 12-row test) is unaffected (`InteractiveBrowser = false`). --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index cf323c4..afadcc3 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -556,3 +556,98 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a - **Validation:** 422 conformance (417 + 5 facade) + 371 unit green, both unchanged from before the refactor; SDK + full solution build 0/0 — the existing suites were the regression gate and caught nothing. + +### Phase 6a — Samples backend foundation (2026-06-05, complete) + +- **MockPersonServer governance.** `MissionGovernance.cs` adds the scriptable + decision model (`MissionConsentScript`), per-mission policy snapshot + (`MissionPolicyStore`), approval blob builder, and the + decider/sink/relay sample implementations; `Program.cs` wires + `AddAAuthGovernance` + the four governance endpoints, the three-gate `/token` + mission gate, and `/admin/mission-script` + `/admin/mission-terminate` (option + A deterministic scripting). The `/token` gate reads + `MissionClaim.FromPayload(verified resource token)` and decides terminated → + `403`, in-scope/prior-consent → silent issue, else prompt (live `202` + deferred), logging the decision reason each time. +- **12-row Consent Matrix.** `tests/AAuth.Tests/Integration/ + MissionAgentFlowTests.cs` covers every gate × approve/deny × prompt/silent + (incl. clarification respond/cancel and `mission_terminated`) in-process via + `WebApplicationFactory` + the SDK governance/exchange clients, asserting the + recorded mission-log reason per row. +12 unit tests (371 → 383). +- **SPEC-DRIVEN ADDITION — mission-aware resource (§Terminology ~L177:** "a + mission-aware resource includes the mission object from the AAuth-Mission + header in the resource tokens it issues"; §Mission flow ~L423/L429; signed + `aauth-mission` component §HTTP signing ~L619). Promoted this from a sample + hack to a **first-class SDK feature** so the chain — agent `AAuth-Mission` + header → mission-aware resource copies `{approver, s256}` into the resource + token → PS reads it from the verified resource token → embeds it in the auth + token — works for *any* resource, not just the mock. SDK: `AAuthMissionHeader. + TryParseStructured`; `ChallengeOptions.MissionAware` (opt-in, default false); + `AAuthChallengeMiddleware` sets `ResourceTokenBuilder.Mission` when enabled and + the header parses. WhoAmI gains a dedicated `GET /jwt/mission` endpoint + (`ChallengeForMission(ScopeWhoami){MissionAware=true}`) so the mission-aware + path is demoed independently of the plain three-party `/jwt`. +3 conformance + tests (422 → 425). +- **MissionAgent CLI (`samples/MissionAgent/`).** New standalone console driving + the full lifecycle against the **live** mock servers (AP:5301 → PS:5100 → + WhoAmI:5000 `/jwt/mission`): enrol → propose mission → access the mission-aware + resource (out-of-scope **prompt**, then prior-consent **silent**) → pre-approved + permission (silent short-circuit) → non-pre-approved permission (prompt) → + audit → question → completion/terminate. Each resource access **refreshes the + agent token** (`AgentProviderClient.RefreshAsync` → fresh `jti`) — required + because the live resource's default JTI replay detection rejects a re-presented + agent token; this also mirrors real agent token rotation. +- **DEVIATION — genuine browser interactivity (user chose "interactive").** + Rather than only printing the consent URL while the PS auto-resolves, the mock + PS now supports a real browser decision, gated by + `MissionConsentScript.InteractiveBrowser` (default false, so the 12-row test's + scripted auto-resolve is unaffected). When set: the mission-pending / + permission-pending GETs hold at `202` (re-emitting the interaction header) + until a `MissionPendingEntry.Decision` is recorded, and the browser + `/interaction` GET + `/interaction/approve` + `/interaction/deny` handlers now + recognise mission-pending codes and set that decision. `/permission` emits the + interaction header when interactive; `/admin/mission-script` accepts + `{interactive}`. The CLI defaults to interactive (`--auto` opts back into + scripted). Verified live end-to-end both ways (auto full run; interactive via + out-of-band `POST /interaction/approve` for the step-3 token and step-6 + permission prompts). +- **Files:** new `samples/MissionAgent/{MissionAgent.csproj, Program.cs, + README.md}` (already in `AAuth.slnx`); modified `samples/MockPersonServer/ + {MissionGovernance.cs, Program.cs}`, `samples/WhoAmI/Program.cs`, + `src/AAuth/Agent/Mission.cs`, `src/AAuth/Server/Challenge/{ChallengeOptions, + AAuthChallengeMiddleware}.cs`; new test `tests/AAuth.Tests/Integration/ + MissionAgentFlowTests.cs`; modified `tests/AAuth.Conformance/HttpSignatures/ + ChallengeMiddlewareTests.cs`. +- **Validation:** Conformance 425 (+3), AAuth.Tests 383 (+12); `AAuth.slnx` + builds 0 warnings / 0 errors; MissionAgent live smoke (auto + interactive) green. + +#### Phase 6a legibility follow-ups (2026-06-05, complete) + +- **Interactive mission-creation screen.** Mission approval is the most important + consent in the model, so `/mission` now defers (`202` + interaction) to a real + browser consent screen when `InteractiveBrowser` is set — the same deferred + path the token/permission gates use. SDK `MissionClient.ProposeAsync` already + routes through `DeferredExchange`, so no SDK change was needed; only the mock + PS gained `MissionPendingKind.Mission` + `MissionPendingEntry.Proposal`, a new + `GET /mission-create-pending/{id}` that builds+saves the approval blob on + approve (or `403` on decline), and the consent page now branches on creation + (heading "start a new mission"; shows description + `Tools`; no `s256`/resource + yet). `MissionAgent.ProposeAsync` passes `GovernanceFor(...)` so the browser + opens. Scripted mode (the 12-row test) keeps `InteractiveBrowser = false` and + the synchronous auto-approve path → unchanged, 12/12. +- **Consent-screen tool context.** `MissionPolicyStore.ApprovedTools(s256)` added; + the token and permission consent screens now show the mission's approved tools + as context, so the human sees the full mission a request sits under. On the + permission screen this makes the gate self-explanatory: `Approved tools: + send_email, summarize` next to `Action: delete_inbox`. +- **Spec wording confirmed (§Permission Endpoint L1015–1017, L1303).** Per-call + permission requests carry an **`action`**; the mission pre-approves + **`approved_tools`** (tool objects). The agent calls the permission endpoint + only for actions not covered by a pre-approved tool. Consent-screen labels and + the README sequence diagram use this distinction. +- **README sequence diagram** now draws all three browser consent screens + (mission creation, out-of-scope token gate, out-of-tool permission) plus the + silent pre-approved-tool path, with the `action` vs `approved_tools` wording. +- **Files (this follow-up):** modified `samples/MockPersonServer/ + {MissionGovernance.cs, Program.cs}`, `samples/MissionAgent/{Program.cs, + README.md}`. No SDK/test changes; suites unchanged (425 / 383). diff --git a/AAuth.slnx b/AAuth.slnx index 992eaab..78f4079 100644 --- a/AAuth.slnx +++ b/AAuth.slnx @@ -3,6 +3,7 @@ + diff --git a/Makefile b/Makefile index 6e1a2f8..1d61562 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ AGENT_PROJECT := samples/AgentConsole/AgentConsole.csproj SAMPLE_PROJECT := samples/SampleApp/SampleApp.csproj ORCH_PROJECT := samples/Orchestrator/Orchestrator.csproj LIVE_PROJECT := samples/LiveWhoAmITest/LiveWhoAmITest.csproj +MISSION_PROJECT := samples/MissionAgent/MissionAgent.csproj AS_PROJECT := samples/MockAccessServer/MockAccessServer.csproj WHOAMI_URL := http://localhost:5000 @@ -58,7 +59,7 @@ endef .PHONY: help build restore test test-unit test-conformance format clean \ whoami ps ps-consent ap orchestrator tour sampleapp agent live \ - demo \ + demo demo-mission agent-mission \ keycloak access-server demo-keycloak \ agent-federated agent-reset \ e2e-install e2e e2e-tour e2e-sample e2e-report @@ -224,6 +225,34 @@ agent-federated: ## Drive AgentConsole through the four-party /federated flow (K agent-reset: ## Clear the AgentConsole enrollment cache (stale after an AP restart) @rm -rf "$(AGENT_CACHE_DIR)" && echo "Cleared AgentConsole enrollment cache ($(AGENT_CACHE_DIR))." +# ---------------------------------------------------------------------------- +# Mission demo — agent operating under a human-approved mission, PS as the +# policy-enforcement point (three mock servers, no Docker) +# ---------------------------------------------------------------------------- + +demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent CLI + @echo "Starting mission demo (agent operating under a human-approved mission)..." + @echo "" + @echo "------------------------------------------------------------------" + @echo " Backend services:" + @echo " WhoAmI: $(WHOAMI_URL)/jwt/mission (mission-aware resource)" + @echo " MockPersonServer: $(PS_URL) (governs every step under the mission)" + @echo " MockAgentProvider: $(AP_URL) (agent registry)" + @echo "" + @echo " Drive it from another terminal with: make agent-mission" + @echo " (out-of-scope prompts open the PS consent page in your browser;" + @echo " add AUTO=1 to resolve prompts via the PS's scripted defaults)" + @echo "------------------------------------------------------------------" + @echo "" + @trap 'echo; echo "Stopping..."; kill 0' INT TERM; \ + $(DOTNET) run --project $(PS_PROJECT) & \ + $(DOTNET) run --project $(WHOAMI_PROJECT) & \ + $(DOTNET) run --project $(AP_PROJECT) & \ + wait + +agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts) + $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) + # ---------------------------------------------------------------------------- # End-to-end (Playwright) # ---------------------------------------------------------------------------- diff --git a/samples/MissionAgent/MissionAgent.csproj b/samples/MissionAgent/MissionAgent.csproj new file mode 100644 index 0000000..8454449 --- /dev/null +++ b/samples/MissionAgent/MissionAgent.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/samples/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs new file mode 100644 index 0000000..ef14afd --- /dev/null +++ b/samples/MissionAgent/Program.cs @@ -0,0 +1,320 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Crypto; +using AAuth.Discovery; +using AAuth.Headers; +using AAuth.HttpSig; +using AAuth.Tokens; + +// ============================================================================= +// MissionAgent — a console showcase of the AAuth *mission* model and the +// Person Server acting as the policy-enforcement point (§Missions). +// +// A mission is a durable, human-approved statement of intent. Once approved, +// the PS governs every downstream token and permission request *under* that +// mission with a three-gate model (§Agent Token Request): +// +// gate 1 terminated mission -> rejected outright +// gate 2a in-scope (resource,scope) -> granted silently +// gate 2b prior consent this run -> granted silently +// gate 3 out of scope -> the user is prompted to decide +// +// This sample drives the full lifecycle against the live mock servers: +// MockAgentProvider (:5301) -> MockPersonServer (:5100) -> WhoAmI (:5000) +// +// Run the three servers first (see samples/MissionAgent/README.md), then: +// dotnet run --project samples/MissionAgent +// +// By default every out-of-scope prompt is *interactive*: the agent prints the +// Person Server's consent URL and waits while you approve or deny in your +// browser. Pass --auto to resolve prompts via the PS's scripted defaults +// (useful for unattended smoke runs). +// ============================================================================= + +const string Usage = + "Usage: MissionAgent [--ap ] [--ps ] [--resource ] [--sub ] [--auto]"; + +string apUrl = "http://localhost:5301"; +string personServer = "http://localhost:5100"; +string resourceUrl = "http://localhost:5000/jwt/mission"; +string subject = "aauth:mission-demo@ap.example"; +bool interactive = true; + +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--help" or "-h": + Console.WriteLine(Usage); + return 0; + case "--auto": + interactive = false; + break; + case "--ap" or "--ps" or "--resource" or "--sub": + if (i + 1 >= args.Length) + { + Console.Error.WriteLine($"Missing value for {args[i]}."); + return 1; + } + var value = args[++i]; + switch (args[i - 1]) + { + case "--ap": apUrl = value; break; + case "--ps": personServer = value; break; + case "--resource": resourceUrl = value; break; + case "--sub": subject = value; break; + } + break; + default: + Console.Error.WriteLine($"Unknown argument: {args[i]}"); + Console.Error.WriteLine(Usage); + return 1; + } +} + +apUrl = apUrl.TrimEnd('/'); +personServer = personServer.TrimEnd('/'); + +Section("1. Enrol with the Agent Provider"); +// The agent's signing key is long-lived (it spans the agent install). The +// keystore holds the private key; we keep only its handle in memory here. +IKeyStore keyStore = FileKeyStore.Default(); +var discovery = new MetadataClient(new HttpClient()); +var apMeta = await discovery.FetchAsync(MetadataClient.BuildUrl(apUrl, "aauth-agent.json")); +var enrolEndpoint = (string?)apMeta["enrol_endpoint"] ?? $"{apUrl}/enrol"; +var refreshEndpoint = (string?)apMeta["refresh_endpoint"] ?? $"{apUrl}/refresh"; + +var apClient = new AgentProviderClient(new HttpClient(), keyStore); +var enrolment = await apClient.EnrolAsync(apUrl, subject, enrolEndpoint, personServer); +AAuthKey key = enrolment.Key; +string localKeyHandle = enrolment.LocalKeyHandle; +string agentToken = enrolment.AgentToken; +Console.WriteLine($" agent id : {subject}"); +Console.WriteLine($" key thumbprint : {key.ComputeJwkThumbprint()}"); +Console.WriteLine($" person server : {personServer}"); + +// Signed channel for agent-token requests: the resource challenge, the token +// exchange, and every governance call (mission/permission/audit/interaction) +// flow over this handler, which signs each request and carries the agent token +// in the Signature-Key header (§HTTP Message Signatures). +var agentHandler = new AAuthSigningHandler(key, () => agentToken) { InnerHandler = new HttpClientHandler() }; +var signedClient = new HttpClient(agentHandler) { Timeout = Timeout.InfiniteTimeSpan }; +var metadata = new MetadataClient(new HttpClient()); +var governance = new AAuthGovernanceClient(signedClient, metadata); +var exchange = new TokenExchangeClient(signedClient, metadata); + +// Tell the mock PS how to resolve prompts. Interactive mode holds each prompt +// open until you decide in the browser; --auto resolves via scripted defaults. +await ScriptAsync(new JsonObject +{ + ["reset"] = true, + ["interactive"] = interactive, + ["approveMission"] = true, + ["approveToken"] = true, + ["approvePermission"] = true, +}); +Console.WriteLine($" prompt mode : {(interactive ? "interactive (decide in your browser)" : "auto (scripted approvals)")}"); + +// Generous polling budget so a human has time to click Approve. +var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(5) }; + +Section("2. Propose a mission"); +// The user approves a durable statement of intent plus the tools the agent may +// use. The PS returns the signed approval blob and its s256 thumbprint, which +// the agent quotes on every later request to bind it to this mission. In +// interactive mode the PS shows a browser consent screen here; in --auto mode it +// resolves the approval itself. +var mission = await governance.Mission.ProposeAsync(personServer, new MissionProposal( + "Help the user keep their inbox under control for the next hour.") +{ + Tools = new[] + { + new MissionTool("send_email", "Send an email on the user's behalf"), + new MissionTool("summarize", "Summarize a thread"), + }, +}, GovernanceFor("Approve this mission and its tools")); +var missionClaim = new MissionClaim(mission.Approver, mission.S256); +Console.WriteLine($" description : {mission.Description}"); +Console.WriteLine($" approved by : {mission.Approver}"); +Console.WriteLine($" approved tools : {string.Join(", ", mission.ApprovedTools.Select(t => t.Name))}"); +// The s256 is an RFC 7638-style thumbprint of the signed approval blob, NOT the +// text: tokens carry only {approver, s256} as a compact, verifiable reference +// to the mission above (§Mission Approval). The description/tools stay with the +// approver, so a leaked token never exposes the mission's prose. +Console.WriteLine($" mission s256 : {mission.S256} (thumbprint reference to the description above)"); + +Section("3. Access a mission-aware resource — first call is OUT OF SCOPE"); +// WhoAmI's /jwt/mission endpoint is mission-aware: it copies the mission claim +// from the AAuth-Mission header into the resource token it issues (§Terminology). +// The PS reads that claim and governs the token request. This (resource, scope) +// is not in the mission's pre-approved scope, so the PS prompts the user. +var first = await AccessMissionResourceAsync(); +Console.WriteLine($" resource said : access={first?["access"]}, scope={first?["scope"]}"); +// The resource echoes only the {approver, s256} reference from the token — the +// same s256 printed in step 2, which maps back to "{mission.Description}". +Console.WriteLine($" echoed mission : {first?["mission"]?.ToJsonString()}"); +Console.WriteLine($" (s256 references: \"{mission.Description}\")"); + +Section("4. Access it again — now silent via PRIOR CONSENT"); +// The same (resource, scope) was just approved under this mission, so the PS +// grants the token silently this time (gate 2b) — no prompt. +var second = await AccessMissionResourceAsync(); +Console.WriteLine($" resource said : access={second?["access"]}, scope={second?["scope"]} (granted silently)"); + +Section("5. Request a permission for a pre-approved tool — silent"); +// `send_email` is an approved tool, so the SDK short-circuits to granted +// without ever calling the PS (§Permission Endpoint). +var preApproved = await governance.Permission.RequestAsync(personServer, "send_email", mission); +Console.WriteLine($" send_email : {(preApproved.IsGranted ? "granted" : "denied")} ({preApproved.Reason})"); + +Section("6. Request a permission for a NON-pre-approved tool"); +// `delete_inbox` is not an approved tool, so the PS is consulted and the user +// is prompted to decide. +var adHoc = await governance.Permission.RequestAsync( + personServer, + new PermissionRequest("delete_inbox") { Mission = missionClaim }, + GovernanceFor("Permission to permanently delete the inbox")); +Console.WriteLine($" delete_inbox : {(adHoc.IsGranted ? "granted" : "denied")} ({adHoc.Reason})"); + +Section("7. Report an action to the audit endpoint"); +// After acting, the agent records what it did under the mission (§Audit Endpoint). +await governance.Audit.RecordAsync(personServer, new AuditRecord(missionClaim, "send_email") +{ + Description = "Sent a reply to the design-review thread.", + Result = new JsonObject { ["status"] = "success" }, +}); +Console.WriteLine(" recorded send_email = success"); + +Section("8. Ask the user a question"); +var answer = await governance.Interaction.AskQuestionAsync( + personServer, + "Want me to keep going for another hour?", + description: "The mission's hour is nearly up.", + mission: missionClaim, + options: GovernanceFor("A question from your agent")); +Console.WriteLine($" user answered : {answer ?? "(no answer)"}"); + +Section("9. Propose mission completion (terminates the mission)"); +var terminated = await governance.Interaction.ProposeCompletionAsync( + personServer, + "Inbox triaged: 12 read, 3 replied, 1 deleted.", + missionClaim, + GovernanceFor("Your agent says the mission is done")); +Console.WriteLine($" mission ended : {terminated}"); + +Console.WriteLine(); +Console.WriteLine("Done. The Person Server governed every step under the mission."); +return 0; + +// --------------------------------------------------------------------------- +// Resource access: challenge -> token exchange (governed by the PS) -> retry. +// --------------------------------------------------------------------------- +async Task AccessMissionResourceAsync() +{ + // A real agent rotates its short-lived agent token; refreshing here gives + // each request a fresh `jti`, which also satisfies the resource's replay + // detection (§HTTP Message Signatures — replay). + agentToken = await apClient.RefreshAsync(refreshEndpoint, localKeyHandle); + + // 1. Signed request carrying the mission. The signing handler covers the + // aauth-mission header automatically, so the resource can trust it. + var challengeReq = new HttpRequestMessage(HttpMethod.Get, resourceUrl); + challengeReq.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); + using var challenge = await signedClient.SendAsync(challengeReq); + if (challenge.StatusCode != HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException( + $"Expected 401 challenge from the resource, got {(int)challenge.StatusCode}."); + } + + // 2. Parse the AAuth-Requirement header to recover the resource token. + var requirement = string.Join(", ", challenge.Headers.GetValues(AAuthRequirementHeader.Name)); + var parsed = AAuthRequirementHeader.Parse(requirement); + var resourceToken = parsed.ResourceToken + ?? throw new InvalidOperationException("Challenge did not carry a resource token."); + + // 3. Exchange the resource token at the PS. The PS reads the mission claim + // embedded in the (verified) resource token and applies the token gate; + // an out-of-scope request returns 202 and we print the consent URL. + var authToken = await exchange.ExchangeAsync(personServer, resourceToken, new TokenExchangeRequest + { + OnInteractionRequired = PromptUserAsync, + PollerOptions = poller, + }); + + // 4. Replay the request with the auth token to obtain the protected resource. + var authHandler = new AAuthSigningHandler(key, () => authToken) { InnerHandler = new HttpClientHandler() }; + using var authClient = new HttpClient(authHandler); + using var ok = await authClient.GetAsync(resourceUrl); + ok.EnsureSuccessStatusCode(); + return await ok.Content.ReadFromJsonAsync(); +} + +// Build governance options that prompt interactively (or stay scripted). +GovernanceOptions GovernanceFor(string _) => new() +{ + OnInteractionRequired = PromptUserAsync, + PollerOptions = poller, +}; + +// Invoked when the PS asks the user to decide. In interactive mode we surface +// the consent URL (and try to open it) and return — polling proceeds while the +// user acts. In --auto mode the PS resolves the prompt itself, so this is just +// informational. +Task PromptUserAsync(Interaction interaction, CancellationToken ct) +{ + var url = interaction.BuildUserUrl(); + Console.WriteLine(); + Console.WriteLine(" >> The Person Server needs your decision."); + Console.WriteLine($" Open: {url}"); + if (interactive) + { + Console.WriteLine(" Waiting for you to Approve or Deny in the browser..."); + TryOpenBrowser(url); + } + return Task.CompletedTask; +} + +async Task ScriptAsync(JsonObject body) +{ + using var resp = await signedClient.PostAsJsonAsync($"{personServer}/admin/mission-script", body); + resp.EnsureSuccessStatusCode(); +} + +void Section(string title) +{ + Console.WriteLine(); + Console.WriteLine($"== {title} =="); +} + +static void TryOpenBrowser(string url) +{ + var browser = Environment.GetEnvironmentVariable("BROWSER"); + try + { + if (!string.IsNullOrEmpty(browser)) + { + Process.Start(new ProcessStartInfo(browser, url) { UseShellExecute = false }); + } + else + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + } + catch + { + // Headless environment: the printed URL is enough. + } +} diff --git a/samples/MissionAgent/README.md b/samples/MissionAgent/README.md new file mode 100644 index 0000000..36a495c --- /dev/null +++ b/samples/MissionAgent/README.md @@ -0,0 +1,194 @@ +# MissionAgent + +A console showcase of the AAuth **mission** model, with the Person Server (PS) +acting as the policy-enforcement point for everything the agent does. + +A *mission* is a durable, human-approved statement of intent plus the tools the +agent may use (§Missions). Once approved, the PS governs every downstream token +and permission request **under** that mission with a three-gate model +(§Agent Token Request): + +| Gate | Situation | Outcome | +| --- | --- | --- | +| 1 | Mission terminated | Rejected outright | +| 2a | (resource, scope) is in the mission's approved scope | Granted silently | +| 2b | Already consented earlier in this mission | Granted silently | +| 3 | Out of scope | The user is prompted to decide | + +This sample drives the whole lifecycle against the live mock servers: + +```text +MockAgentProvider (:5301) -> MockPersonServer (:5100) -> WhoAmI (:5000) + enrol govern mission-aware + resource +``` + +## What it demonstrates + +1. **Enrol** with the Agent Provider to obtain a signing key + agent token. +2. **Propose a mission** (with two approved tools) — the PS returns the signed + approval blob and its `s256` thumbprint. +3. **Access a mission-aware resource** (`WhoAmI /jwt/mission`). The resource + copies the mission claim from the signed `AAuth-Mission` header into the + resource token it issues (§Terminology), so the PS governs the exchange. The + first call is **out of scope** → the user is prompted. +4. **Access it again** — now granted **silently** via prior consent (gate 2b). +5. **Request a pre-approved tool** permission (`send_email`) — granted silently, + without ever calling the PS (§Permission Endpoint). +6. **Request a non-pre-approved tool** permission (`delete_inbox`) — the PS is + consulted and the user is prompted. +7. **Report an action** to the audit endpoint (§Audit Endpoint). +8. **Ask the user a question** via the interaction endpoint. +9. **Propose mission completion**, which terminates the mission. + +## How the flow works + +There are three parties. The agent never talks to a resource with a long-lived +credential — every resource call is brokered by the agent's **Person Server**, +which is the single point that enforces the mission. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Agent as MissionAgent (CLI) + participant AP as Agent Provider
:5301 + participant PS as Person Server
:5100 + participant R as WhoAmI
:5000 /jwt/mission + + Note over Agent,AP: One-time bootstrap + Agent->>AP: enrol (durable key) + AP-->>Agent: agent token (short-lived, signed) + + Note over User,PS: Mission creation — the human approves intent + tools + Agent->>PS: POST /mission {description, tools} + rect rgb(124, 58, 237) + Note over User,PS: 🖥️ BROWSER CONSENT SCREEN — "start a new mission?"
shows the mission description + the tools it may use + PS->>User: approve this mission? + User-->>PS: ✅ approve + end + PS-->>Agent: signed approval blob + s256 thumbprint + + Note over Agent,R: Access a mission-aware resource + Agent->>R: GET /jwt/mission + AAuth-Mission: {approver, s256} + R-->>Agent: 401 + resource token (mission claim copied in) + Agent->>PS: exchange resource token for an auth token + Note right of PS: Token gate (see below) + rect rgb(124, 58, 237) + Note over User,PS: 🖥️ BROWSER CONSENT SCREEN — out-of-scope access
shows the mission + tools, then "falls outside
pre-approved scope" → approve access? + PS->>User: out of scope — approve access? + User-->>PS: ✅ approve + end + PS-->>Agent: auth token (binds {approver, s256}) + Agent->>R: GET /jwt/mission + Authorization: auth token + R-->>Agent: 200 — echoes the mission reference + + Note over Agent,PS: Permission for a local action (no resource involved) + Agent->>PS: POST /permission {action: send_email} + Note right of PS: send_email is a pre-approved tool + PS-->>Agent: granted silently — no user prompt + Agent->>PS: POST /permission {action: delete_inbox} + Note right of PS: delete_inbox is NOT a pre-approved tool + rect rgb(124, 58, 237) + Note over User,PS: 🖥️ BROWSER CONSENT SCREEN — local action
shows the mission + approved tools, then
"delete_inbox is not pre-approved" → approve? + PS->>User: approve this action? + User-->>PS: ✅ approve + end + PS-->>Agent: granted +``` + +> 🖥️ The **purple** blocks are the three browser-based **consent screens** (the +> PS's `/interaction` page). **(1) Mission creation** — the human approves the +> mission's intent and the tools it may use; this is the authority every later +> request is checked against. **(2) Out-of-scope token gate** — shows the +> mission + tools as context before approving the resource access. **(3) +> Out-of-tool permission** — a local `action` (`delete_inbox`) that isn't one of +> the mission's `approved_tools`, so the PS asks. A pre-approved tool +> (`send_email`, step 5) is granted **silently** and never reaches a screen. In +> `--auto` mode each screen is resolved by the PS's scripted default instead of +> a human click, but they are the same decision points. +> +> The spec distinguishes the two words: a mission pre-approves **`approved_tools`** +> (tools), while each per-call permission request carries an **`action`**. The +> agent calls the permission endpoint only for actions not covered by a +> pre-approved tool (§Permission Endpoint). + + +### The token gate — why the first call says "out of scope" + +When the PS is asked to mint an auth token under a mission, it runs a +**three-gate** decision (§Agent Token Request). Crucially, this gate is about +**resource + scope**, *not* about the approved tools: + +```mermaid +flowchart TD + A[Token request under a mission] --> G1{Mission terminated?} + G1 -- yes --> D1[Reject: 403 mission_terminated] + G1 -- no --> G2a{Is resource+scope in the
mission's in-scope set?} + G2a -- yes --> S1[Grant silently — reason: InScope] + G2a -- no --> G2b{Already consented for this
resource+scope this mission?} + G2b -- yes --> S2[Grant silently — reason: PriorConsent] + G2b -- no --> P[Prompt the user — reason: OutOfScope] + P -- approve --> S3[Grant + remember the consent] + P -- deny --> D2[Reject: access denied] +``` + +A mission carries **two independent** notions of "approved": + +- **Approved tools** (`send_email`, `summarize`) gate the **permission** + endpoint (step 5/6), *not* token issuance. +- **In-scope `(resource, scope)` pairs** gate **silent token issuance** + (gate 2a). This sample seeds **no** in-scope pairs, so the very first call to + `WhoAmI /jwt/mission` (`resource=:5000`, `scope=whoami`) matches neither gate + 2a nor 2b and therefore falls through to **gate 3 → prompt**. That is what the + "falls outside the agent's pre-approved mission scope" message means — the + resource/scope wasn't on the mission's silent-allow list, **not** that any + tool was unapproved. + +Once you approve that first prompt, the PS records the consent. The **second** +call (step 4) hits **gate 2b (prior consent)** and is granted **silently** — no +prompt. That contrast (prompt → then silent) is the core thing this sample +demonstrates. + +> To see **gate 2a** instead (silent from the very first call), a PS could +> pre-seed an in-scope `(resource, scope)` pair at mission approval. This sample +> deliberately leaves it empty so the out-of-scope prompt is visible. + +## Running it + + +Start the three servers (each in its own terminal): + +```bash +dotnet run --project samples/MockAgentProvider # :5301 +dotnet run --project samples/MockPersonServer # :5100 +dotnet run --project samples/WhoAmI # :5000 +``` + +Then run the agent: + +```bash +dotnet run --project samples/MissionAgent +``` + +By default each out-of-scope prompt is **interactive**: the agent prints the +Person Server's consent URL (and tries to open it) and waits while you click +**Approve** or **Deny** in your browser. The PS holds the request at `202` until +you decide, then the agent's next poll resolves. + +For an unattended run (CI, smoke tests), use `--auto` to resolve every prompt +via the PS's scripted defaults: + +```bash +dotnet run --project samples/MissionAgent -- --auto +``` + +## Options + +| Flag | Default | Description | +| --- | --- | --- | +| `--ap ` | `http://localhost:5301` | Agent Provider base URL | +| `--ps ` | `http://localhost:5100` | Person Server base URL | +| `--resource ` | `http://localhost:5000/jwt/mission` | Mission-aware resource endpoint | +| `--sub ` | `aauth:mission-demo@ap.example` | Agent identifier to enrol as | +| `--auto` | _(off)_ | Resolve prompts via scripted PS defaults instead of waiting for a browser decision | diff --git a/samples/MockPersonServer/MissionGovernance.cs b/samples/MockPersonServer/MissionGovernance.cs new file mode 100644 index 0000000..5544c36 --- /dev/null +++ b/samples/MockPersonServer/MissionGovernance.cs @@ -0,0 +1,356 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json.Nodes; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Crypto; +using AAuth.Server.Governance; +using AAuth.Tokens; + +namespace MockPersonServer; + +// ----------------------------------------------------------------------- +// Mission governance support for the mock Person Server (§PS Governance +// Endpoints, §Agent Token Request, §Permission Endpoint, §Mission Log). +// +// The SDK ships the seams (IMissionStore / IMissionLog / IPermissionDecider / +// IAuditSink / IInteractionRelay) and the request parsers; the PS owns the +// policy and the user channel. These types are that PS policy for the demo. +// +// User decisions are SCRIPTED (option A): a test or the GuidedTour sets the +// next outcome on MissionConsentScript before the agent acts, so every +// Consent-Matrix row is reproducible without a real browser prompt. A +// production PS would replace this with an interactive consent screen. +// ----------------------------------------------------------------------- + +/// +/// Deterministic, scriptable stand-in for the user's consent decisions. The mock +/// PS consults it instead of prompting a human, so the mission flow is +/// reproducible end-to-end. +/// +public sealed class MissionConsentScript +{ + /// Whether the next mission proposal is approved (§Mission Creation). + public bool ApproveMissionProposal { get; set; } = true; + + /// Whether an out-of-scope token request is approved when prompted (§Agent Token Request). + public bool ApproveOutOfScopeToken { get; set; } = true; + + /// Whether a non-pre-approved permission is approved when prompted (§Permission Endpoint). + public bool ApprovePermission { get; set; } = true; + + /// The answer the user gives to a question interaction (§Interaction Endpoint). + public string QuestionAnswer { get; set; } = "Yes, go ahead."; + + /// Whether the user accepts a mission-completion proposal (§Interaction Endpoint). + public bool AcceptCompletion { get; set; } = true; + + /// + /// Whether an out-of-scope token request triggers a clarification chat + /// before the user decision (§Clarification Chat). When set, the PS asks + /// before resolving via + /// . + /// + public bool RequireTokenClarification { get; set; } + + /// The question posed during a token clarification chat. + public string ClarificationQuestion { get; set; } = "Why does this mission need this access?"; + + /// + /// When , out-of-scope token and non-pre-approved + /// permission prompts wait for a real decision made through the PS's + /// browser interaction page (/interaction?code=…) instead of + /// resolving immediately via / + /// . The MissionAgent CLI sets this + /// so a human approves each prompt in their browser; the deterministic + /// integration test leaves it (§User Interaction). + /// + public bool InteractiveBrowser { get; set; } + + // (resource|scope) pairs the user treats as within a new mission's intent. + private readonly HashSet _inScope = new(StringComparer.Ordinal); + + /// Declare a (resource, scope) as within the approved mission intent. + public void SeedInScope(string resource, string scope) => _inScope.Add(ScopeKey(resource, scope)); + + /// Snapshot the seeded in-scope set (captured per mission at approval). + public IReadOnlySet InScopeSnapshot() => new HashSet(_inScope, StringComparer.Ordinal); + + /// Canonical (resource, scope) key used for in-scope and prior-consent lookups. + public static string ScopeKey(string resource, string scope) => $"{resource.TrimEnd('/')}|{scope}"; + + /// Reset every decision to its permissive default and clear the in-scope set. + public void Reset() + { + ApproveMissionProposal = true; + ApproveOutOfScopeToken = true; + ApprovePermission = true; + QuestionAnswer = "Yes, go ahead."; + AcceptCompletion = true; + RequireTokenClarification = false; + ClarificationQuestion = "Why does this mission need this access?"; + InteractiveBrowser = false; + _inScope.Clear(); + } +} + +/// +/// Per-mission policy the PS snapshots at approval: the approved tool names and +/// the (resource, scope) set within the mission's intent. Used by the token and +/// permission gates to decide silent-vs-prompt. +/// +public sealed class MissionPolicyStore +{ + private readonly ConcurrentDictionary _byS256 = new(StringComparer.Ordinal); + + /// Record the approved tools and in-scope (resource|scope) set for a mission. + public void Record(string s256, string description, IReadOnlyList approvedTools, IReadOnlySet inScope) + => _byS256[s256] = new MissionPolicy( + description, + new HashSet(approvedTools.Select(t => t.Name), StringComparer.Ordinal), + new HashSet(inScope, StringComparer.Ordinal)); + + /// The human-readable mission description captured at approval, if known. + public string? Describe(string s256) + => _byS256.TryGetValue(s256, out var policy) ? policy.Description : null; + + /// The approved tool names captured at approval (empty if the mission is unknown). + public IReadOnlyCollection ApprovedTools(string s256) + => _byS256.TryGetValue(s256, out var policy) ? policy.Tools : Array.Empty(); + + /// Whether is a pre-approved tool on the mission. + public bool IsApprovedTool(string s256, string action) + => _byS256.TryGetValue(s256, out var policy) && policy.Tools.Contains(action); + + /// Whether (, ) is within the mission's intent. + public bool IsInScope(string s256, string resource, string scope) + => _byS256.TryGetValue(s256, out var policy) + && policy.InScope.Contains(MissionConsentScript.ScopeKey(resource, scope)); + + /// Forget a mission's policy (e.g. on termination). + public void Remove(string s256) => _byS256.TryRemove(s256, out _); + + /// Clear all per-mission policy (demo reset). + public void Clear() => _byS256.Clear(); + + private sealed record MissionPolicy(string Description, HashSet Tools, HashSet InScope); +} + +/// +/// Builds the verbatim mission approval blob (§Mission Approval). The bytes are +/// returned exactly as they will be sent so the s256 the PS advertises in +/// the AAuth-Mission header matches what the agent computes. +/// +public static class MissionApproval +{ + /// Build the approval blob bytes and their s256 identity. + public static (byte[] Blob, string S256) Build( + string approver, + string agent, + MissionProposal proposal, + IReadOnlyList approvedTools, + DateTimeOffset approvedAt) + { + var tools = new JsonArray(); + foreach (var tool in approvedTools) + { + var obj = new JsonObject { ["name"] = tool.Name }; + if (!string.IsNullOrEmpty(tool.Description)) + { + obj["description"] = tool.Description; + } + tools.Add(obj); + } + + var blob = new JsonObject + { + ["approver"] = approver, + ["agent"] = agent, + ["approved_at"] = approvedAt.ToString("o"), + ["description"] = proposal.Description, + ["approved_tools"] = tools, + }; + + var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); + return (bytes, Mission.ComputeS256(bytes)); + } +} + +/// +/// The PS's permission policy (§Permission Endpoint): a pre-approved tool is +/// granted silently; any other action falls to the user, whose scripted decision +/// is reflected here. The reason is recorded so samples can show why each request +/// was silent or prompted. +/// +public sealed class SamplePermissionDecider : IPermissionDecider +{ + private readonly MissionPolicyStore _policy; + + public SamplePermissionDecider(MissionPolicyStore policy) + { + _policy = policy; + } + + public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default) + { + var action = context.Request.Action; + + // §Permission Endpoint: a pre-approved tool resolves without prompting. + if (context.Mission is not null && _policy.IsApprovedTool(context.Mission.S256, action)) + { + return Task.FromResult(new PermissionDecision( + PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool)); + } + + // Otherwise the user must be prompted; the endpoint parks the request + // (202) and the scripted decision resolves it on the pending URL. + return Task.FromResult(new PermissionDecision( + PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope)); + } +} + +/// +/// Records audit entries into the mission log (§Audit Endpoint). A production PS +/// would also run anomaly detection and could revoke the mission. +/// +public sealed class SampleAuditSink : IAuditSink +{ + private readonly IMissionLog _log; + + public SampleAuditSink(IMissionLog log) => _log = log; + + public Task RecordAsync(AuditRecord record, CancellationToken ct = default) + => _log.AppendAsync( + new MissionLogEntry(record.Mission.S256, MissionLogEntryKind.Audit, DateTimeOffset.UtcNow) + { + Action = record.Action, + Detail = record.Description, + }, + ct); +} + +/// +/// Reaches the "user" for interaction requests (§Interaction Endpoint). Answers +/// and completion acceptance come from the consent script so the flow is +/// deterministic. +/// +public sealed class SampleInteractionRelay : IInteractionRelay +{ + private readonly MissionConsentScript _script; + + public SampleInteractionRelay(MissionConsentScript script) => _script = script; + + public Task RelayAsync(InteractionRequest request, CancellationToken ct = default) + => Task.FromResult(request.Type switch + { + InteractionType.Question => new InteractionRelayResult { Answer = _script.QuestionAnswer }, + InteractionType.Completion => new InteractionRelayResult { Accepted = _script.AcceptCompletion }, + _ => new InteractionRelayResult { Pending = false }, + }); +} + +/// The kind of deferred request a pending entry represents. +public enum MissionPendingKind +{ + /// A mission proposal awaiting the user's approval (§Mission Creation). + Mission, + + /// An out-of-scope token request awaiting the user decision. + Token, + + /// A non-pre-approved permission request awaiting the user decision. + Permission, +} + +/// The lifecycle of a parked (202) mission-governance request. +public enum MissionPendingState +{ + /// A clarification chat is open; the poll returns 202 + clarification. + AwaitingClarification, + + /// Ready for the user decision; the poll resolves it via the script. + AwaitingDecision, + + /// The agent withdrew the request (DELETE); the poll returns 410 Gone. + Cancelled, +} + +/// +/// A parked mission-governance request (§User Interaction / §Clarification Chat). +/// Carries everything needed to resolve the request once the user decides, so the +/// agent's poll on the pending URL can complete it. +/// +public sealed class MissionPendingEntry +{ + /// Opaque single-use pending id (also the interaction code). + public string Id { get; } = Guid.NewGuid().ToString("N"); + + /// Whether this is a token or permission request. + public required MissionPendingKind Kind { get; init; } + + /// The agent that made the request (token `sub`). + public required string AgentId { get; init; } + + /// The mission this request belongs to. + public required string S256 { get; init; } + + /// The mission approver (for re-emitting the mission claim). + public required string Approver { get; init; } + + /// The requested resource (token requests). + public string? Resource { get; init; } + + /// The requested scope (token requests). + public string? Scope { get; init; } + + /// The requested action (permission requests). + public string? Action { get; init; } + + /// The proposed mission awaiting approval (mission-creation requests). + public MissionProposal? Proposal { get; init; } + + /// The agent's confirmation key, captured to mint the auth token. + public IAAuthKey? ConfirmationKey { get; init; } + + /// Any upstream act claim to carry into the issued auth token. + public JsonObject? UpstreamAct { get; init; } + + /// The clarification question (when started in clarification). + public string? Question { get; init; } + + /// Current lifecycle state. + public MissionPendingState State { get; set; } + + /// + /// The browser decision when running in interactive mode: + /// while the user has not yet decided, on approve, + /// on deny. Ignored in scripted mode. + /// + public bool? Decision { get; set; } + + /// The mission claim to embed in the issued auth token. + public MissionClaim MissionClaim => new(Approver, S256); +} + +/// In-memory store of parked mission-governance requests. +public sealed class MissionPendingStore +{ + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + + /// Park and return it. + public MissionPendingEntry Add(MissionPendingEntry entry) + { + _entries[entry.Id] = entry; + return entry; + } + + /// Look up a pending entry by id. + public MissionPendingEntry? Get(string id) + => _entries.TryGetValue(id, out var entry) ? entry : null; + + /// Remove a resolved pending entry. + public void Remove(string id) => _entries.TryRemove(id, out _); + + /// Clear all pending entries (demo reset). + public void Clear() => _entries.Clear(); +} diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index 70f1e31..f2e5530 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -2,6 +2,7 @@ using AAuth; using AAuth.Access; using AAuth.Agent; +using AAuth.Agent.Governance; using AAuth.Crypto; using AAuth.Discovery; using AAuth.Errors; @@ -11,6 +12,7 @@ using AAuth.Server.Authorization; using AAuth.Server.CallChaining; using AAuth.Server.Challenge; +using AAuth.Server.Governance; using AAuth.Server.Metadata; using AAuth.Server.Verification; using AAuth.Tokens; @@ -92,6 +94,18 @@ static bool IsAdminAgent(string agentId) => builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +// Mission governance (§PS Governance Endpoints). AddAAuthGovernance registers +// the in-memory mission store + log; the PS supplies the policy/user-channel +// seams (decider / audit sink / interaction relay) and the deterministic +// consent script that stands in for a real user-consent screen. +builder.Services.AddAAuthGovernance(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new UpstreamTokenValidator( sp.GetRequiredService(), @@ -143,6 +157,12 @@ static bool IsAdminAgent(string agentId) => Issuer = psIssuer, TokenEndpoint = $"{psIssuer.TrimEnd('/')}/token", SigningKeys = new Dictionary { [PsKid] = psKey }, + // Mission governance endpoints (§PS Governance Endpoints). Advertised so an + // agent's MetadataClient can resolve them from aauth-person.json. + MissionEndpoint = $"{psIssuer.TrimEnd('/')}/mission", + PermissionEndpoint = $"{psIssuer.TrimEnd('/')}/permission", + AuditEndpoint = $"{psIssuer.TrimEnd('/')}/audit", + InteractionEndpoint = $"{psIssuer.TrimEnd('/')}/mission-interaction", }); // All other endpoints require an AAuth signature — except the @@ -443,6 +463,7 @@ static bool IsAdminAgent(string agentId) => var jwksClient = app.Services.GetRequiredService(); string audience; string requestedScope = PsScope; + MissionClaim? missionClaim = null; try { var verifiedResourceToken = await tokenVerifier.VerifyResourceTokenAsync( @@ -462,6 +483,9 @@ static bool IsAdminAgent(string agentId) => { requestedScope = scopeClaim; } + // §Agent Token Request: the mission context (if any) rides the resource + // token's `mission` claim. It governs the token gate below. + missionClaim = MissionClaim.FromPayload(verifiedResourceToken.Payload); } catch (TokenVerificationException ex) { @@ -478,6 +502,82 @@ static bool IsAdminAgent(string agentId) => statusCode: StatusCodes.Status401Unauthorized); } + // Mission gate (§Agent Token Request, three-gate model). When the resource + // token carries a mission claim, the token request is governed by the + // mission: silent when the (resource, scope) is within the approved intent + // (gate 2a) or already consented earlier in this mission (gate 2b), + // otherwise the user is prompted; a terminated mission is rejected outright. + // Each outcome is recorded in the mission log so the reason is auditable. + if (missionClaim is not null) + { + var missionStore = app.Services.GetRequiredService(); + var missionLog = app.Services.GetRequiredService(); + var missionPolicy = app.Services.GetRequiredService(); + var missionPending = app.Services.GetRequiredService(); + var script = app.Services.GetRequiredService(); + var s256 = missionClaim.S256; + + var stored = await missionStore.GetAsync(s256); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + + var inScope = missionPolicy.IsInScope(s256, audience, requestedScope); + var priorConsent = !inScope + && await missionLog.HasPriorConsentAsync(s256, audience, requestedScope); + + if (inScope || priorConsent) + { + await missionLog.AppendAsync(new MissionLogEntry( + s256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow) + { + Resource = audience, + Scope = requestedScope, + Granted = true, + Detail = inScope ? "InScope" : "PriorConsent", + }); + var silentToken = IssueAuthToken( + agentId, audience, requestedScope, parsed.ConfirmationKey!, upstreamAct, missionClaim); + return Results.Ok(new { auth_token = silentToken }); + } + + // Out of scope -> park the request and prompt the user (§User Interaction). + // The agent polls the pending URL while the (scripted) user decides; a + // clarification chat runs first when the script requests one. + var entry = missionPending.Add(new MissionPendingEntry + { + Kind = MissionPendingKind.Token, + AgentId = agentId, + S256 = s256, + Approver = missionClaim.Approver, + Resource = audience, + Scope = requestedScope, + ConfirmationKey = parsed.ConfirmationKey!, + UpstreamAct = upstreamAct, + Question = script.RequireTokenClarification ? script.ClarificationQuestion : null, + State = script.RequireTokenClarification + ? MissionPendingState.AwaitingClarification + : MissionPendingState.AwaitingDecision, + }); + + ctx.Response.Headers.Location = $"/mission-pending/{entry.Id}"; + ctx.Response.Headers["Retry-After"] = "0"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + if (script.RequireTokenClarification) + { + ctx.Response.Headers[AAuthRequirementHeader.Name] = + $"requirement={ClarificationRequirement.RequirementType}"; + return Results.Json( + new { clarification = script.ClarificationQuestion }, + statusCode: StatusCodes.Status202Accepted); + } + + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format($"{psIssuer.TrimEnd('/')}/interaction", entry.Id); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + // Consent gate. If the PS is configured to require consent and the // (agent, resource, scope) triple hasn't been approved yet, park the // request and tell the agent to direct its user to the interaction @@ -584,6 +684,498 @@ static bool IsAdminAgent(string agentId) => } }); +// ----------------------------------------------------------------------- +// Mission governance endpoints (§PS Governance Endpoints). All four are +// behind AAuth verification (signed agent-token requests); they sit on +// distinct paths from the browser `/interaction` consent page so they are +// not excluded from verification. User decisions come from the scripted +// MissionConsentScript (the mock's stand-in for a consent screen). +// ----------------------------------------------------------------------- + +// mission_endpoint (§Mission Creation): the agent proposes a mission; the PS +// records the approved mission and returns the verbatim approval blob plus the +// `AAuth-Mission` header whose `s256` the agent verifies. +app.MapPost("/mission", async ( + HttpContext ctx, + IMissionStore missions, + MissionPolicyStore policy, + MissionConsentScript script, + MissionPendingStore pending) => +{ + var parsed = ctx.GetAAuthParsedKey()!; + if (ctx.GetAAuthTokenType() != AAuthTokenType.AgentToken) + { + return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status401Unauthorized); + } + var agentId = (string?)parsed.Payload?["sub"]; + if (string.IsNullOrEmpty(agentId)) + { + return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" }, statusCode: StatusCodes.Status401Unauthorized); + } + + JsonObject? body; + try + { + body = await ctx.Request.ReadFromJsonAsync(); + } + catch (System.Text.Json.JsonException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + MissionProposal proposal; + try + { + proposal = GovernanceEndpoints.ParseMissionProposal(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + if (!script.ApproveMissionProposal) + { + return Results.Json(new { error = "access_denied" }, statusCode: StatusCodes.Status403Forbidden); + } + + // Interactive mode (§Mission Creation): mission approval is the most + // important consent in the model, so park the proposal and let the user + // approve it on the PS browser screen — the same deferred (202) path the + // token and permission gates use. The agent's MissionClient polls the + // pending URL and receives the signed approval blob once the user decides. + if (script.InteractiveBrowser) + { + var pendingMission = pending.Add(new MissionPendingEntry + { + Kind = MissionPendingKind.Mission, + AgentId = agentId, + S256 = string.Empty, // computed from the blob once approved + Approver = psIssuer, + Proposal = proposal, + }); + ctx.Response.Headers.Location = $"/mission-create-pending/{pendingMission.Id}"; + ctx.Response.Headers["Retry-After"] = "0"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format($"{psIssuer.TrimEnd('/')}/interaction", pendingMission.Id); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + + // The demo approves every proposed tool; a real PS would let the user prune them. + var approvedTools = proposal.Tools; + var (blob, s256) = MissionApproval.Build(psIssuer, agentId, proposal, approvedTools, DateTimeOffset.UtcNow); + + await missions.SaveAsync(new StoredMission(s256, psIssuer, agentId, blob)); + policy.Record(s256, proposal.Description, approvedTools, script.InScopeSnapshot()); + + ctx.Response.Headers[AAuthMissionHeader.Name] = + AAuthMissionHeader.FormatStructured(psIssuer, s256); + return Results.Bytes(blob, "application/json"); +}); + +// Interactive mission-creation resolution (§Mission Creation). The agent polls +// here while the user approves or declines the proposed mission in the browser. +// On approval the PS builds and stores the verbatim approval blob and returns it +// with the AAuth-Mission header — exactly what the synchronous path returns. +app.MapGet("/mission-create-pending/{id}", async ( + HttpContext ctx, string id, MissionPendingStore pending, + IMissionStore missions, MissionPolicyStore policy, MissionConsentScript script) => +{ + var entry = pending.Get(id); + if (entry is null || entry.Kind != MissionPendingKind.Mission) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + + // Hold at 202 until the user decides on the browser consent screen. + if (entry.Decision is null) + { + ctx.Response.Headers["Retry-After"] = "1"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format($"{psIssuer.TrimEnd('/')}/interaction", entry.Id); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + + pending.Remove(id); + if (!entry.Decision.Value) + { + ctx.Response.Headers["Cache-Control"] = "no-store"; + return Results.Json( + new { error = "access_denied", detail = "the user declined this mission" }, + statusCode: StatusCodes.Status403Forbidden); + } + + var proposal = entry.Proposal!; + // The demo approves every proposed tool; a real PS would let the user prune them. + var approvedTools = proposal.Tools; + var (blob, s256) = MissionApproval.Build(psIssuer, entry.AgentId, proposal, approvedTools, DateTimeOffset.UtcNow); + await missions.SaveAsync(new StoredMission(s256, psIssuer, entry.AgentId, blob)); + policy.Record(s256, proposal.Description, approvedTools, script.InScopeSnapshot()); + ctx.Response.Headers[AAuthMissionHeader.Name] = + AAuthMissionHeader.FormatStructured(psIssuer, s256); + return Results.Bytes(blob, "application/json"); +}); + +// permission_endpoint (§Permission Endpoint): the agent asks whether an action +// is permitted. A pre-approved tool is granted silently; anything else is parked +// (202) and resolved by the (scripted) user decision on the pending URL. +app.MapPost("/permission", async ( + HttpContext ctx, + IMissionStore missions, + IMissionLog log, + IPermissionDecider decider, + MissionConsentScript script, + MissionPendingStore pending) => +{ + var parsed = ctx.GetAAuthParsedKey()!; + var agentId = (string?)parsed.Payload?["sub"] ?? string.Empty; + + var body = await ctx.Request.ReadFromJsonAsync(); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + PermissionRequest request; + try + { + request = GovernanceEndpoints.ParsePermission(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + StoredMission? stored = null; + IReadOnlyList history = []; + if (request.Mission is not null) + { + stored = await missions.GetAsync(request.Mission.S256); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + history = await log.ReadAsync(request.Mission.S256); + } + + var decision = await decider.DecideAsync(new PermissionDecisionContext(request, stored, history)); + + // Prompt -> park the request and let the agent poll while the user decides. + if (decision.Outcome == PermissionOutcome.Prompt && request.Mission is not null) + { + var entry = pending.Add(new MissionPendingEntry + { + Kind = MissionPendingKind.Permission, + AgentId = agentId, + S256 = request.Mission.S256, + Approver = request.Mission.Approver, + Action = request.Action, + }); + ctx.Response.Headers.Location = $"/permission-pending/{entry.Id}"; + ctx.Response.Headers["Retry-After"] = "0"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + // Interactive mode points the user at the PS browser page to decide. + if (script.InteractiveBrowser) + { + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format($"{psIssuer.TrimEnd('/')}/interaction", entry.Id); + } + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + + var granted = decision.Outcome == PermissionOutcome.Granted; + if (request.Mission is not null) + { + await log.AppendAsync(new MissionLogEntry( + request.Mission.S256, MissionLogEntryKind.Permission, DateTimeOffset.UtcNow) + { + Action = request.Action, + Granted = granted, + Detail = decision.Reason.ToString(), + }); + } + + return Results.Json(new + { + permission = granted ? "granted" : "denied", + reason = decision.Message ?? decision.Reason.ToString(), + }); +}); + +// audit_endpoint (§Audit Endpoint): the agent reports an action it took. +app.MapPost("/audit", async ( + HttpContext ctx, + IMissionStore missions, + IAuditSink sink) => +{ + var body = await ctx.Request.ReadFromJsonAsync(); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + AuditRecord record; + try + { + record = GovernanceEndpoints.ParseAudit(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + var stored = await missions.GetAsync(record.Mission.S256); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + + await sink.RecordAsync(record); + return Results.StatusCode(StatusCodes.Status201Created); +}); + +// interaction_endpoint (§Interaction Endpoint): questions and completion +// proposals relayed to the user. A completion the user accepts terminates the +// mission; otherwise the mission stays active. +app.MapPost("/mission-interaction", async ( + HttpContext ctx, + IMissionStore missions, + IMissionLog log, + IInteractionRelay relay) => +{ + var body = await ctx.Request.ReadFromJsonAsync(); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + InteractionRequest request; + try + { + request = GovernanceEndpoints.ParseInteraction(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + if (request.Mission is not null) + { + var stored = await missions.GetAsync(request.Mission.S256); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + } + + var result = await relay.RelayAsync(request); + + if (request.Mission is not null) + { + await log.AppendAsync(new MissionLogEntry( + request.Mission.S256, MissionLogEntryKind.Interaction, DateTimeOffset.UtcNow) + { + Detail = request.Type.ToString(), + }); + } + + switch (request.Type) + { + case InteractionType.Question: + return Results.Json(new { answer = result.Answer ?? string.Empty }); + + case InteractionType.Completion: + // The user accepted completion -> terminate the mission (§Mission Management). + if (result.Accepted == true && request.Mission is not null) + { + await missions.SetStateAsync(request.Mission.S256, MissionState.Terminated); + return Results.Json(new { mission_status = "terminated" }); + } + return Results.Json(new { mission_status = "active" }); + + default: + return Results.Json(new { status = "ok" }); + } +}); + +// ----------------------------------------------------------------------- +// Mission pending URLs. The agent polls (GET) for the user decision, +// answers a clarification (POST), or withdraws the request (DELETE). All +// signed like /token. The pending id is single-use and also the +// interaction code shown to the user. +// ----------------------------------------------------------------------- + +// Out-of-scope token resolution (§User Interaction / §Clarification Chat). The +// poll either runs a clarification round, resolves to an issued auth token, or +// reports the user's denial / the agent's withdrawal. +app.MapGet("/mission-pending/{id}", async ( + HttpContext ctx, string id, MissionPendingStore pending, + IMissionLog log, MissionConsentScript script) => +{ + var entry = pending.Get(id); + if (entry is null || entry.Kind != MissionPendingKind.Token) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + + switch (entry.State) + { + case MissionPendingState.Cancelled: + ctx.Response.Headers["Cache-Control"] = "no-store"; + return Results.Json(new { error = "request_withdrawn" }, statusCode: StatusCodes.Status410Gone); + + case MissionPendingState.AwaitingClarification: + ctx.Response.Headers["Retry-After"] = "0"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = + $"requirement={ClarificationRequirement.RequirementType}"; + return Results.Json( + new { clarification = entry.Question ?? script.ClarificationQuestion }, + statusCode: StatusCodes.Status202Accepted); + + case MissionPendingState.AwaitingDecision: + default: + // Interactive mode: hold at 202 until the user decides in the + // browser (§User Interaction). Scripted mode resolves immediately. + bool approved; + if (script.InteractiveBrowser) + { + if (entry.Decision is null) + { + ctx.Response.Headers["Retry-After"] = "1"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format($"{psIssuer.TrimEnd('/')}/interaction", entry.Id); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + approved = entry.Decision.Value; + } + else + { + approved = script.ApproveOutOfScopeToken; + } + await log.AppendAsync(new MissionLogEntry( + entry.S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow) + { + Resource = entry.Resource, + Scope = entry.Scope, + Granted = approved, + Detail = "OutOfScope", + }); + pending.Remove(id); + if (!approved) + { + ctx.Response.Headers["Cache-Control"] = "no-store"; + return Results.Json( + new { error = "access_denied", detail = "the user denied this request" }, + statusCode: StatusCodes.Status403Forbidden); + } + var token = IssueAuthToken( + entry.AgentId, entry.Resource!, entry.Scope!, entry.ConfirmationKey!, + entry.UpstreamAct, entry.MissionClaim); + return Results.Ok(new { auth_token = token }); + } +}); + +// Clarification answer (`clarification_response`) or updated request +// (`resource_token`). Either satisfies the chat and readies the user decision. +app.MapPost("/mission-pending/{id}", async ( + HttpContext ctx, string id, MissionPendingStore pending, IMissionLog log) => +{ + var entry = pending.Get(id); + if (entry is null || entry.Kind != MissionPendingKind.Token) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + if (entry.State == MissionPendingState.Cancelled) + { + return Results.Json(new { error = "request_withdrawn" }, statusCode: StatusCodes.Status410Gone); + } + + JsonObject? body; + try { body = await ctx.Request.ReadFromJsonAsync(); } + catch (System.Text.Json.JsonException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + var answer = (string?)body?["clarification_response"]; + await log.AppendAsync(new MissionLogEntry( + entry.S256, MissionLogEntryKind.Clarification, DateTimeOffset.UtcNow) + { + Detail = answer ?? "updated_request", + }); + entry.State = MissionPendingState.AwaitingDecision; + return Results.NoContent(); +}); + +// Withdraw the request (§Agent Response to Clarification — cancel). The DELETE +// succeeds (204); a later poll of the same URL returns 410 Gone. +app.MapDelete("/mission-pending/{id}", async ( + string id, MissionPendingStore pending, IMissionLog log) => +{ + var entry = pending.Get(id); + if (entry is null || entry.Kind != MissionPendingKind.Token) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + entry.State = MissionPendingState.Cancelled; + await log.AppendAsync(new MissionLogEntry( + entry.S256, MissionLogEntryKind.Clarification, DateTimeOffset.UtcNow) + { + Detail = "cancelled", + }); + return Results.NoContent(); +}); + +// Non-pre-approved permission resolution (§Permission Endpoint). The poll +// returns the (scripted) user decision. +app.MapGet("/permission-pending/{id}", async ( + HttpContext ctx, string id, MissionPendingStore pending, + IMissionLog log, MissionConsentScript script) => +{ + var entry = pending.Get(id); + if (entry is null || entry.Kind != MissionPendingKind.Permission) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + // Interactive mode: hold at 202 until the user decides in the browser. + bool granted; + if (script.InteractiveBrowser) + { + if (entry.Decision is null) + { + ctx.Response.Headers["Retry-After"] = "1"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format($"{psIssuer.TrimEnd('/')}/interaction", entry.Id); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + granted = entry.Decision.Value; + } + else + { + granted = script.ApprovePermission; + } + await log.AppendAsync(new MissionLogEntry( + entry.S256, MissionLogEntryKind.Permission, DateTimeOffset.UtcNow) + { + Action = entry.Action, + Granted = granted, + Detail = "OutOfScope", + }); + pending.Remove(id); + return Results.Json(new + { + permission = granted ? "granted" : "denied", + reason = granted ? "OutOfScope" : "the user denied this action.", + }); +}); + // ----------------------------------------------------------------------- // DEMO-ONLY admin endpoints. A real PS would NEVER expose unauthenticated // consent flips. These exist so the GuidedTour's "User approves" button @@ -611,21 +1203,84 @@ static bool IsAdminAgent(string agentId) => // Demo-only: wipe all consent + pending state back to baseline so an automated // test harness can start each spec from a known-empty store (see the E2E suite's // resetConsent helper). A production PS would never expose this. -app.MapPost("/admin/reset", (ConsentStore consent, PendingStore pending, FederatedPendingStore fedPending) => +app.MapPost("/admin/reset", (ConsentStore consent, PendingStore pending, FederatedPendingStore fedPending, MissionPendingStore missionPending, MissionConsentScript script) => { consent.Clear(); pending.Clear(); fedPending.Clear(); + missionPending.Clear(); + script.Reset(); + return Results.Ok(new { ok = true }); +}); + +// DEMO-ONLY: script the next mission-governance decisions (option A). The +// CLI/E2E harness POSTs here before driving the agent so each Consent-Matrix +// outcome is reproducible. A real PS resolves these through a live user-consent +// screen. Body (all optional): { reset, approveMission, approveToken, +// approvePermission, questionAnswer, acceptCompletion, interactive, +// inScope: [{resource, scope}] }. +app.MapPost("/admin/mission-script", async (HttpContext ctx, MissionConsentScript script) => +{ + JsonObject? body; + try { body = await ctx.Request.ReadFromJsonAsync(); } + catch (System.Text.Json.JsonException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + body ??= []; + + if ((bool?)body["reset"] == true) { script.Reset(); } + if (body["approveMission"] is JsonValue am) { script.ApproveMissionProposal = am.GetValue(); } + if (body["approveToken"] is JsonValue at) { script.ApproveOutOfScopeToken = at.GetValue(); } + if (body["approvePermission"] is JsonValue ap) { script.ApprovePermission = ap.GetValue(); } + if (body["questionAnswer"] is JsonValue qa) { script.QuestionAnswer = qa.GetValue(); } + if (body["acceptCompletion"] is JsonValue ac) { script.AcceptCompletion = ac.GetValue(); } + if (body["requireClarification"] is JsonValue rc) { script.RequireTokenClarification = rc.GetValue(); } + if (body["clarificationQuestion"] is JsonValue cq) { script.ClarificationQuestion = cq.GetValue(); } + if (body["interactive"] is JsonValue iv) { script.InteractiveBrowser = iv.GetValue(); } + if (body["inScope"] is JsonArray inScope) + { + foreach (var item in inScope.OfType()) + { + var resource = (string?)item["resource"]; + var scope = (string?)item["scope"]; + if (!string.IsNullOrEmpty(resource) && !string.IsNullOrEmpty(scope)) + { + script.SeedInScope(resource, scope); + } + } + } return Results.Ok(new { ok = true }); }); +// DEMO-ONLY: terminate a mission by its s256 (§Mission Management). After this +// the PS rejects token/permission/audit/interaction requests for the mission +// with `mission_terminated`. Body: { s256 }. +app.MapPost("/admin/mission-terminate", async (HttpContext ctx, IMissionStore missions, MissionPolicyStore policy) => +{ + JsonObject? body; + try { body = await ctx.Request.ReadFromJsonAsync(); } + catch (System.Text.Json.JsonException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + var s256 = (string?)body?["s256"]; + if (string.IsNullOrEmpty(s256)) + { + return Results.Json(new { error = "invalid_request", detail = "missing s256" }, statusCode: StatusCodes.Status400BadRequest); + } + await missions.SetStateAsync(s256, MissionState.Terminated); + policy.Remove(s256); + return Results.Ok(new { ok = true, s256, mission_status = "terminated" }); +}); + // User-facing interaction page. The 202 from `POST /token` told the // agent's user to visit this URL with `?code={pending-id}`. In a real PS // this page would be behind the user's signed-in browser session // (cookie/passkey/SSO); here we trust the demo environment and just look // up the pending entry by its single-use code. The form submits to // /interaction/approve or /interaction/deny. -app.MapGet("/interaction", (string? code, PendingStore pending) => +app.MapGet("/interaction", (string? code, PendingStore pending, MissionPendingStore missionPending, MissionPolicyStore missionPolicy) => { if (string.IsNullOrEmpty(code)) { @@ -637,6 +1292,82 @@ static bool IsAdminAgent(string agentId) => statusCode: StatusCodes.Status400BadRequest); } + // A mission token / permission prompt (§Missions) takes priority — these + // single-use codes are the mission-pending entry ids. + var mission = missionPending.Get(code); + if (mission is not null) + { + // Mission creation shows the proposal itself; the token/permission gates + // show the request plus the mission it sits under (§Mission Creation). + var isCreation = mission.Kind == MissionPendingKind.Mission; + var description = isCreation ? mission.Proposal!.Description : missionPolicy.Describe(mission.S256); + var approvedTools = isCreation + ? (IReadOnlyCollection)mission.Proposal!.Tools.Select(t => t.Name).ToArray() + : missionPolicy.ApprovedTools(mission.S256); + var what = mission.Kind switch + { + MissionPendingKind.Token => + $"
Resource: {System.Net.WebUtility.HtmlEncode(mission.Resource)}
" + + $"
Scope: {System.Net.WebUtility.HtmlEncode(mission.Scope)}
", + MissionPendingKind.Permission => + $"
Action: {System.Net.WebUtility.HtmlEncode(mission.Action)}
", + _ => string.Empty, + }; + // The mission claim binds requests to a thumbprint (s256); surface the + // human-readable description the user approved so they can decide in + // context, with the s256 shown only as a verifiable reference. At + // creation time the s256 does not exist yet, so show only the prose. + var missionLine = isCreation + ? $"
Mission: {System.Net.WebUtility.HtmlEncode(description)}
" + : description is not null + ? $"
Mission: {System.Net.WebUtility.HtmlEncode(description)}
" + + $"
{System.Net.WebUtility.HtmlEncode(mission.S256)}
" + : $"
Mission: {System.Net.WebUtility.HtmlEncode(mission.S256)}
"; + // Show the mission's pre-approved tools so the user can see the full + // mission this request sits under (§Mission Approval). For a permission + // prompt this also makes plain WHY it needs a decision: the requested + // action is not among these approved tools. + var toolsLabel = isCreation ? "Tools" : "Approved tools"; + var toolsLine = approvedTools.Count > 0 + ? $"
{toolsLabel}: {System.Net.WebUtility.HtmlEncode(string.Join(", ", approvedTools))}
" + : $"
{toolsLabel}: (none)
"; + var heading = isCreation + ? "An agent wants to start a new mission" + : $"An agent is requesting {(mission.Kind == MissionPendingKind.Token ? "access" : "permission")} under its mission"; + var intro = isCreation + ? "The agent is asking you to approve a durable mission and the tools it may use. This is the authority that every later request will be checked against." + : "This request falls outside the agent's pre-approved mission scope, so the Person Server is asking you to decide."; + var missionHtml = + "Approve a mission request — Person Server" + + "" + + "
Person Server — mission governance
" + + "
localhost:5100 — overseeing what this agent does under its mission
" + + $"

{heading}

" + + $"

{intro}

" + + $"
Agent: {System.Net.WebUtility.HtmlEncode(mission.AgentId)}
" + + missionLine + + toolsLine + + what + + "
" + + $"" + + "" + + "
" + + "
" + + $"" + + "" + + "
"; + return Results.Content(missionHtml, contentType: "text/html"); + } + var entry = pending.Get(code); if (entry is null) { @@ -682,13 +1413,31 @@ static bool IsAdminAgent(string agentId) => // consent for the entry's (agent, resource, scope) triple, and shows a // confirmation page. Idempotent: re-submitting a code whose entry is // already approved still 200s. -app.MapPost("/interaction/approve", async (HttpContext ctx, ConsentStore consent, PendingStore pending) => +app.MapPost("/interaction/approve", async (HttpContext ctx, ConsentStore consent, PendingStore pending, MissionPendingStore missionPending) => { var code = (await ctx.Request.ReadFormAsync())["code"].ToString(); if (string.IsNullOrEmpty(code)) { return Results.BadRequest(new { error = "invalid_request", detail = "missing 'code'" }); } + // Mission token / permission prompt: record the user's approval so the + // agent's next poll resolves to a granted decision (§Missions). + var mission = missionPending.Get(code); + if (mission is not null) + { + mission.Decision = true; + return Results.Content( + "Approved — Person Server" + + "" + + "
Person Server — mission governance
" + + "

Approved

" + + $"

You approved {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent will proceed on its next poll.

" + + "

You can close this tab.

", + contentType: "text/html"); + } var entry = pending.Get(code); if (entry is null) { @@ -713,13 +1462,31 @@ static bool IsAdminAgent(string agentId) => // Deny handler. Marks the pending entry as denied (rather than removing // it) so the agent's next poll receives a deterministic // `403 access_denied` instead of an ambiguous `404 unknown_pending`. -app.MapPost("/interaction/deny", async (HttpContext ctx, PendingStore pending) => +app.MapPost("/interaction/deny", async (HttpContext ctx, PendingStore pending, MissionPendingStore missionPending) => { var code = (await ctx.Request.ReadFormAsync())["code"].ToString(); if (string.IsNullOrEmpty(code)) { return Results.BadRequest(new { error = "invalid_request", detail = "missing 'code'" }); } + // Mission token / permission prompt: record the user's denial so the + // agent's next poll resolves to a denied decision (§Missions). + var mission = missionPending.Get(code); + if (mission is not null) + { + mission.Decision = false; + return Results.Content( + "Denied — Person Server" + + "" + + "
Person Server — mission governance
" + + "

Denied

" + + $"

You denied {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent's next poll will receive 403 access_denied.

" + + "

You can close this tab.

", + contentType: "text/html"); + } var entry = pending.Get(code); if (entry is null) { @@ -744,7 +1511,7 @@ static bool IsAdminAgent(string agentId) => // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- -string IssueAuthToken(string agentId, string audience, string scope, IAAuthKey confirmationKey, JsonObject? upstreamAct = null) +string IssueAuthToken(string agentId, string audience, string scope, IAAuthKey confirmationKey, JsonObject? upstreamAct = null, MissionClaim? mission = null) => new AuthTokenBuilder { Issuer = psIssuer, @@ -758,6 +1525,7 @@ string IssueAuthToken(string agentId, string audience, string scope, IAAuthKey c Roles = IsAdminAgent(agentId) ? demoRoles : null, Groups = IsAdminAgent(agentId) ? demoGroups : null, UpstreamAct = upstreamAct, + Mission = mission, }.Build(); // Peek the `aud` claim of a (possibly unverified) compact JWT without checking diff --git a/samples/WhoAmI/Program.cs b/samples/WhoAmI/Program.cs index 6761fce..a1d8039 100644 --- a/samples/WhoAmI/Program.cs +++ b/samples/WhoAmI/Program.cs @@ -120,6 +120,21 @@ DefaultScopes = scope, }; +// Challenge options for a mission-aware endpoint (§Terminology: "a mission-aware +// resource includes the mission object from the AAuth-Mission header in the +// resource tokens it issues"). When the agent sends a signed AAuth-Mission +// header, the issued resource token carries the mission object (approver + +// s256), so the agent's PS can govern the exchange against that mission. +ChallengeOptions ChallengeForMission(string scope) => new() +{ + AccessMode = AAuthAccessMode.RequireAuthToken, + ResourceSigningKey = resourceKey, + ResourceKeyId = ResourceKid, + ResourceIdentifier = resourceUrl, + DefaultScopes = scope, + MissionAware = true, +}; + // Verification options for the four-party (federated) flow. The auth token is // issued by the AS (iss = AS, dwk = aauth-access.json), so the AS is the // trusted auth-token issuer here — not the PS. @@ -165,6 +180,18 @@ ctx => ctx.Request.Path.StartsWithSegments("/jwks-uri"), branch => branch.UseAAuthVerification(SignatureOnly())); +// /jwt/mission — three-party mission-aware: full verification + a mission-aware +// challenge. When the agent presents a signed AAuth-Mission header, the issued +// resource token carries the mission object (approver + s256), so the agent's +// PS governs the token exchange against that mission (§Terminology, §Missions). +app.UseWhen( + ctx => ctx.Request.Path.StartsWithSegments("/jwt/mission"), + branch => + { + branch.UseAAuthVerification(FullVerification()); + branch.UseAAuthChallenge(ChallengeForMission(ScopeWhoami)); + }); + // /jwt/admin — three-party elevated: full verification + challenge for the // elevated `whoami:admin` scope. app.UseWhen( @@ -198,7 +225,8 @@ app.UseWhen( ctx => ctx.Request.Path.StartsWithSegments("/jwt") && !ctx.Request.Path.StartsWithSegments("/jwt/admin") - && !ctx.Request.Path.StartsWithSegments("/jwt/roles"), + && !ctx.Request.Path.StartsWithSegments("/jwt/roles") + && !ctx.Request.Path.StartsWithSegments("/jwt/mission"), branch => { branch.UseAAuthVerification(FullVerification()); @@ -232,6 +260,7 @@ new { path = "/jkt-jwt", mode = "pseudonymous (key delegation)", auth = "signature only" }, new { path = "/jwks-uri", mode = "agent-identity", auth = "AAuth.Identified" }, new { path = "/jwt", mode = "three-party", auth = "AAuth.Scope.whoami" }, + new { path = "/jwt/mission", mode = "three-party (mission-aware)", auth = "AAuth.Scope.whoami" }, new { path = "/jwt/admin", mode = "three-party (step-up)", auth = "AAuth.Scope.whoami:admin" }, new { path = "/jwt/roles", mode = "three-party (RBAC)", auth = "AAuth.Role.whoami-admin" }, new { path = "/federated", mode = "four-party", auth = "AAuth.Scope.whoami" }, @@ -320,6 +349,39 @@ }); }).RequireAuthorization("AAuth.Scope.whoami"); +// ----------------------------------------------------------------------- +// GET /jwt/mission — Three-party mission-aware access. +// +// This endpoint is mission-aware: when the agent sends a signed AAuth-Mission +// header, the challenge issues a resource token carrying the mission object +// (approver + s256). The agent's PS then governs the token exchange against +// that mission, and the resulting auth token echoes the mission claim back — +// surfaced here so the demo can show the mission round-tripping end to end +// (§Terminology, §Missions, §Auth Token Structure). An agent without a +// mission still gets the baseline `whoami` access (mission = null). +// ----------------------------------------------------------------------- +app.MapGet("/jwt/mission", (HttpContext ctx) => +{ + var result = ctx.GetAAuthVerification()!; + var parsed = ctx.GetAAuthParsedKey()!; + var mission = parsed.Payload?["mission"]; + + return Results.Ok(new + { + mode = "three-party", + scheme = "jwt", + access = "mission", + agent = result.Agent, + sub = result.Subject, + scope = result.Scopes, + iss = result.Issuer, + // The mission object (approver + s256) the PS embedded in the auth + // token, or null when the agent operated without a mission. + mission, + missionAware = true, + }); +}).RequireAuthorization("AAuth.Scope.whoami"); + // ----------------------------------------------------------------------- // GET /jwt/admin — Three-party elevated access (step-up scope). // Requires an auth token carrying the elevated `whoami:admin` scope. diff --git a/src/AAuth/Agent/Mission.cs b/src/AAuth/Agent/Mission.cs index 174154c..0e4a4dc 100644 --- a/src/AAuth/Agent/Mission.cs +++ b/src/AAuth/Agent/Mission.cs @@ -180,4 +180,35 @@ public static string FormatStructured(string approver, string s256) ArgumentException.ThrowIfNullOrEmpty(s256); return $"approver=\"{approver}\"; s256=\"{s256}\""; } + + /// + /// Parse a structured AAuth-Mission header value into its + /// approver and s256 components (§Call Chaining). Returns + /// when the value is absent or either field is missing. + /// + public static bool TryParseStructured(string? value, out string? approver, out string? s256) + { + approver = null; + s256 = null; + if (string.IsNullOrWhiteSpace(value)) + return false; + + foreach (var part in value.Split(';')) + { + var trimmed = part.Trim(); + var eq = trimmed.IndexOf('='); + if (eq <= 0) + continue; + var name = trimmed[..eq].Trim(); + var raw = trimmed[(eq + 1)..].Trim().Trim('"'); + if (raw.Length == 0) + continue; + if (name.Equals("approver", StringComparison.OrdinalIgnoreCase)) + approver = raw; + else if (name.Equals("s256", StringComparison.OrdinalIgnoreCase)) + s256 = raw; + } + + return !string.IsNullOrEmpty(approver) && !string.IsNullOrEmpty(s256); + } } diff --git a/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs b/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs index 2bdb6f9..428f4b1 100644 --- a/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs +++ b/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using AAuth.Agent; using AAuth.Crypto; using AAuth.Headers; using AAuth.HttpSig; @@ -156,6 +157,19 @@ private Task IssueChallenge( "ChallengeOptions.ResourceIdentifier must be set for RequireAuthToken mode."); } + // §Terminology / §Mission Request Header: a mission-aware resource copies + // the mission object from a valid AAuth-Mission header (verified as a + // signed component upstream) into the resource token it issues, so the + // mission context (approver + s256) reaches the PS. + MissionClaim? mission = null; + if (_options.MissionAware + && AAuthMissionHeader.TryParseStructured( + context.Request.Headers[AAuthMissionHeader.Name], + out var missionApprover, out var missionS256)) + { + mission = new MissionClaim(missionApprover!, missionS256!); + } + var resourceToken = new ResourceTokenBuilder { Issuer = _options.ResourceIdentifier, @@ -165,6 +179,7 @@ private Task IssueChallenge( Key = _options.ResourceSigningKey, KeyId = _options.ResourceKeyId, Scope = _options.DefaultScopes, + Mission = mission, }.Build(); context.Response.StatusCode = StatusCodes.Status401Unauthorized; diff --git a/src/AAuth/Server/Challenge/ChallengeOptions.cs b/src/AAuth/Server/Challenge/ChallengeOptions.cs index 88f12f2..5ea14bf 100644 --- a/src/AAuth/Server/Challenge/ChallengeOptions.cs +++ b/src/AAuth/Server/Challenge/ChallengeOptions.cs @@ -53,4 +53,15 @@ public sealed class ChallengeOptions /// When null, all schemes are accepted. ///
public IReadOnlySet? AllowedSignatureKeySchemes { get; init; } + + /// + /// When , the resource is mission-aware (§Terminology: + /// "a mission-aware resource includes the mission object from the + /// AAuth-Mission header in the resource tokens it issues"). If the + /// challenged request carries a valid AAuth-Mission header, the issued + /// resource token includes the mission object (approver + s256) + /// so the mission context flows to the PS. When + /// (default) the header is ignored. + /// + public bool MissionAware { get; init; } } diff --git a/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs b/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs index e1a3174..ab52afd 100644 --- a/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs +++ b/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AAuth.Crypto; using AAuth; +using AAuth.Agent; using AAuth.Discovery; using AAuth.Headers; using AAuth.HttpSig; @@ -389,4 +390,113 @@ private static byte[] Base64UrlDecode(string input) } return Convert.FromBase64String(padded); } + + // ── Mission-aware resource (§Terminology, §Missions) ───────────────── + + private const string MissionApprover = PsIssuer; + private const string MissionS256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + private async Task SendSignedWithMission( + IHost host, string token, string? missionHeader) + { + var capture = new CaptureHandler(); + var provider = new JwtSignatureKeyProvider(() => token); + var handler = new AAuthSigningHandler(_agentKey, provider, () => FixedClock) + { + InnerHandler = capture, + }; + using var client = new HttpClient(handler); + var outbound = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5000/protected"); + if (missionHeader is not null) + outbound.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, missionHeader); + await client.SendAsync(outbound); + var signed = capture.Captured!; + + var relay = new HttpRequestMessage(HttpMethod.Get, "/protected"); + foreach (var h in signed.Headers) + relay.Headers.TryAddWithoutValidation(h.Key, h.Value); + relay.Headers.Host = "localhost:5000"; + return await host.GetTestClient().SendAsync(relay); + } + + private static JsonObject DecodeResourceTokenPayload(HttpResponseMessage response) + { + var headerValue = string.Join(",", response.Headers.GetValues(AAuthRequirementHeader.Name)); + var parsed = AAuthRequirementHeader.Parse(headerValue); + var parts = parsed.ResourceToken!.Split('.'); + return JsonNode.Parse(Base64UrlDecode(parts[1]))!.AsObject(); + } + + [Fact(DisplayName = "§Missions — mission-aware resource copies AAuth-Mission into the resource token")] + public async Task MissionAwareResourceCopiesMissionClaim() + { + var host = await StartResourceServer(new ChallengeOptions + { + AccessMode = AAuthAccessMode.RequireAuthToken, + ResourceSigningKey = _resourceKey, + ResourceKeyId = ResourceKid, + ResourceIdentifier = ResourceId, + DefaultScopes = ResourceScope, + MissionAware = true, + }); + try + { + var token = BuildAgentToken(); + var missionHeader = AAuthMissionHeader.FormatStructured(MissionApprover, MissionS256); + var response = await SendSignedWithMission(host, token, missionHeader); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var payload = DecodeResourceTokenPayload(response); + var mission = Assert.IsType(payload["mission"]); + Assert.Equal(MissionApprover, (string?)mission["approver"]); + Assert.Equal(MissionS256, (string?)mission["s256"]); + } + finally + { + await host.StopAsync(); + host.Dispose(); + } + } + + [Fact(DisplayName = "§Missions — mission-aware resource omits the mission claim when no header is present")] + public async Task MissionAwareResourceOmitsMissionWhenHeaderAbsent() + { + var host = await StartResourceServer(new ChallengeOptions + { + AccessMode = AAuthAccessMode.RequireAuthToken, + ResourceSigningKey = _resourceKey, + ResourceKeyId = ResourceKid, + ResourceIdentifier = ResourceId, + DefaultScopes = ResourceScope, + MissionAware = true, + }); + try + { + var token = BuildAgentToken(); + var response = await SendSignedWithMission(host, token, missionHeader: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var payload = DecodeResourceTokenPayload(response); + Assert.False(payload.ContainsKey("mission")); + } + finally + { + await host.StopAsync(); + host.Dispose(); + } + } + + [Fact(DisplayName = "§Missions — non-mission-aware resource ignores the AAuth-Mission header")] + public async Task NonMissionAwareResourceIgnoresMissionHeader() + { + // _challengeHost is configured WITHOUT MissionAware — the mission header + // must be ignored (opt-in only), so no mission claim is emitted. + var token = BuildAgentToken(); + var missionHeader = AAuthMissionHeader.FormatStructured(MissionApprover, MissionS256); + var response = await SendSignedWithMission(_challengeHost!, token, missionHeader); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var payload = DecodeResourceTokenPayload(response); + Assert.False(payload.ContainsKey("mission")); + } } diff --git a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs new file mode 100644 index 0000000..67b163f --- /dev/null +++ b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Crypto; +using AAuth.Discovery; +using AAuth.Errors; +using AAuth.HttpSig; +using AAuth.Server.Governance; +using AAuth.Tokens; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AAuth.Tests.Integration; + +/// +/// End-to-end Consent-Matrix coverage for the mission-governance MockPersonServer +/// (Phase 6a). Each test drives the shipped SDK governance clients +/// (, , +/// , , +/// ) against the in-process PS and asserts both the +/// agent-observable outcome and the recorded mission-log decision reason. +/// +/// The three-gate model (§Agent Token Request): a mission token request is silent +/// when the (resource, scope) is within the approved intent (gate 2a) or already +/// consented earlier in the mission (gate 2b), otherwise the user is prompted +/// (gate 2c). A permission request is silent for a pre-approved tool, else prompts +/// (§Permission Endpoint). User decisions are scripted via /admin/mission-script. +/// +public class MissionAgentFlowTests : IClassFixture>, IDisposable +{ + private const string PsIssuer = "https://ps.test"; + private const string ResourceUrl = "https://whoami.test"; + private const string ApIssuer = "https://ap.example"; + + private readonly WebApplicationFactory _factory; + + public MissionAgentFlowTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(b => + { + b.UseSetting("AAuth:Issuer", PsIssuer); + b.ConfigureServices(ResourceStub.WireDiscovery); + }); + } + + public void Dispose() => _factory.Dispose(); + + // ---- Mission creation (rows 1-2) ----------------------------------- + + [Fact] + public async Task Row01_MissionApproved_ReturnsActiveMission() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true }); + + var mission = await ProposeMissionAsync(agent, "row01 research mission"); + + Assert.Equal(PsIssuer, mission.Approver); + Assert.Equal(agent.AgentId, mission.Agent); + Assert.False(string.IsNullOrEmpty(mission.S256)); + } + + [Fact] + public async Task Row02_MissionDenied_Aborts() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveMission"] = false }); + + var proposal = new MissionProposal("row02 rejected mission"); + await Assert.ThrowsAsync( + () => MissionClientFor(agent).ProposeAsync(PsIssuer, proposal)); + } + + // ---- Token gate (rows 3-8) ----------------------------------------- + + [Fact] + public async Task Row03_TokenInScope_SilentGrant() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject + { + ["reset"] = true, + ["inScope"] = new JsonArray(InScope(ResourceUrl, "whoami")), + }); + var mission = await ProposeMissionAsync(agent, "row03 in-scope mission"); + + var token = await ExchangeAsync(agent, mission, "whoami", new TokenExchangeRequest()); + + Assert.False(string.IsNullOrEmpty(token)); + await AssertTokenReasonAsync(mission, "whoami", granted: true, reason: "InScope"); + } + + [Fact] + public async Task Row04_TokenRepeat_PriorConsentSilentGrant() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveToken"] = true }); + var mission = await ProposeMissionAsync(agent, "row04 prior-consent mission"); + + // First out-of-scope request: prompted then approved -> recorded as prior consent. + _ = await ExchangeAsync(agent, mission, "whoami:admin", Promptable()); + // Second request for the same (resource, scope): now silent via prior consent. + var token = await ExchangeAsync(agent, mission, "whoami:admin", new TokenExchangeRequest()); + + Assert.False(string.IsNullOrEmpty(token)); + await AssertTokenReasonAsync(mission, "whoami:admin", granted: true, reason: "PriorConsent"); + } + + [Fact] + public async Task Row05_TokenOutOfScope_PromptThenIssue() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveToken"] = true }); + var mission = await ProposeMissionAsync(agent, "row05 out-of-scope approve mission"); + + var prompted = false; + var options = new TokenExchangeRequest + { + OnInteractionRequired = (_, _) => { prompted = true; return Task.CompletedTask; }, + }; + var token = await ExchangeAsync(agent, mission, "whoami:admin", options); + + Assert.True(prompted); + Assert.False(string.IsNullOrEmpty(token)); + await AssertTokenReasonAsync(mission, "whoami:admin", granted: true, reason: "OutOfScope"); + } + + [Fact] + public async Task Row06_TokenOutOfScope_PromptThenDeny() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveToken"] = false }); + var mission = await ProposeMissionAsync(agent, "row06 out-of-scope deny mission"); + + await Assert.ThrowsAsync( + () => ExchangeAsync(agent, mission, "whoami:admin", Promptable())); + await AssertTokenReasonAsync(mission, "whoami:admin", granted: false, reason: "OutOfScope"); + } + + [Fact] + public async Task Row07_TokenClarification_RoundThenIssue() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject + { + ["reset"] = true, + ["approveToken"] = true, + ["requireClarification"] = true, + }); + var mission = await ProposeMissionAsync(agent, "row07 clarification mission"); + + var asked = false; + var options = new TokenExchangeRequest + { + OnInteractionRequired = (_, _) => Task.CompletedTask, + OnClarificationRequired = (_, _) => + { + asked = true; + return Task.FromResult(ClarificationResponse.Respond("The mission needs admin scope to read roles.")); + }, + }; + var token = await ExchangeAsync(agent, mission, "whoami:admin", options); + + Assert.True(asked); + Assert.False(string.IsNullOrEmpty(token)); + await AssertTokenReasonAsync(mission, "whoami:admin", granted: true, reason: "OutOfScope"); + var entries = await ReadLogAsync(mission); + Assert.Contains(entries, e => e.Kind == MissionLogEntryKind.Clarification); + } + + [Fact] + public async Task Row08_TokenClarification_CancelViaDelete() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject + { + ["reset"] = true, + ["approveToken"] = true, + ["requireClarification"] = true, + }); + var mission = await ProposeMissionAsync(agent, "row08 clarification cancel mission"); + + var options = new TokenExchangeRequest + { + OnInteractionRequired = (_, _) => Task.CompletedTask, + OnClarificationRequired = (_, _) => Task.FromResult(ClarificationResponse.Cancel()), + }; + + await Assert.ThrowsAsync( + () => ExchangeAsync(agent, mission, "whoami:admin", options)); + + var entries = await ReadLogAsync(mission); + Assert.Contains(entries, e => e.Kind == MissionLogEntryKind.Clarification && e.Detail == "cancelled"); + // No token was issued for this (resource, scope). + Assert.DoesNotContain(entries, e => e.Kind == MissionLogEntryKind.Token && e.Granted == true); + } + + // ---- Permission gate (rows 9-11) ----------------------------------- + + [Fact] + public async Task Row09_PermissionApprovedTool_SilentGrant() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true }); + var mission = await ProposeMissionAsync(agent, "row09 approved-tool mission", "send_email"); + + var result = await PermissionClientFor(agent) + .RequestAsync(PsIssuer, "send_email", mission); + + Assert.True(result.IsGranted); + Assert.Equal(PermissionGrant.Granted, result.Grant); + } + + [Fact] + public async Task Row10_PermissionNonPreApproved_PromptThenGrant() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = true }); + var mission = await ProposeMissionAsync(agent, "row10 prompt-grant mission", "send_email"); + + var request = new PermissionRequest("delete_file") + { + Mission = new MissionClaim(mission.Approver, mission.S256), + }; + var result = await PermissionClientFor(agent).RequestAsync(PsIssuer, request); + + Assert.True(result.IsGranted); + await AssertPermissionReasonAsync(mission, "delete_file", granted: true); + } + + [Fact] + public async Task Row11_PermissionNonPreApproved_PromptThenDeny() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = false }); + var mission = await ProposeMissionAsync(agent, "row11 prompt-deny mission", "send_email"); + + var request = new PermissionRequest("delete_file") + { + Mission = new MissionClaim(mission.Approver, mission.S256), + }; + var result = await PermissionClientFor(agent).RequestAsync(PsIssuer, request); + + Assert.False(result.IsGranted); + Assert.Equal(PermissionGrant.Denied, result.Grant); + await AssertPermissionReasonAsync(mission, "delete_file", granted: false); + } + + // ---- Termination (row 12) ------------------------------------------ + + [Fact] + public async Task Row12_TerminationMidFlow_RejectsWithMissionTerminated() + { + var agent = NewAgent(); + await ScriptAsync(agent, new JsonObject + { + ["reset"] = true, + ["inScope"] = new JsonArray(InScope(ResourceUrl, "whoami")), + }); + var mission = await ProposeMissionAsync(agent, "row12 terminated mission"); + + // Terminate the mission, then attempt a token request. + using var terminate = await agent.Plain.PostAsJsonAsync("/admin/mission-terminate", + new JsonObject { ["s256"] = mission.S256 }); + Assert.True(terminate.IsSuccessStatusCode); + + await Assert.ThrowsAsync( + () => ExchangeAsync(agent, mission, "whoami", new TokenExchangeRequest())); + } + + // ---- Helpers ------------------------------------------------------- + + private sealed record Agent(string AgentId, AAuthKey AgentKey, HttpClient Signed, HttpClient Plain, MetadataClient Metadata); + + private Agent NewAgent(string? agentId = null) + { + agentId ??= $"aauth:demo@ap.example"; + var agentKey = AAuthKey.Generate(); + var agentToken = new AgentTokenBuilder + { + Issuer = ApIssuer, + Subject = agentId, + KeyId = "demo", + Key = agentKey, + PersonServer = PsIssuer, + }.Build(); + var signing = new AAuthSigningHandler(agentKey, () => agentToken) + { + InnerHandler = _factory.Server.CreateHandler(), + }; + var signed = new HttpClient(signing) { BaseAddress = new Uri(PsIssuer) }; + var plain = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri(PsIssuer), + }); + var metadata = new MetadataClient(new HttpClient(_factory.Server.CreateHandler())); + return new Agent(agentId, agentKey, signed, plain, metadata); + } + + private MissionClient MissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata); + + private PermissionClient PermissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata); + + private async Task ScriptAsync(Agent agent, JsonObject body) + { + using var response = await agent.Plain.PostAsJsonAsync("/admin/mission-script", body); + Assert.True(response.IsSuccessStatusCode, + $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + } + + private async Task ProposeMissionAsync(Agent agent, string description, params string[] tools) + { + var proposal = new MissionProposal(description) + { + Tools = tools.Select(t => new MissionTool(t)).ToArray(), + }; + return await MissionClientFor(agent).ProposeAsync(PsIssuer, proposal); + } + + private async Task ExchangeAsync(Agent agent, Mission mission, string scope, TokenExchangeRequest options) + { + var resourceToken = new ResourceTokenBuilder + { + Issuer = ResourceUrl, + Audience = PsIssuer, + Agent = agent.AgentId, + AgentJkt = agent.AgentKey.ComputeJwkThumbprint(), + Key = ResourceStub.Key, + KeyId = ResourceStub.Kid, + Scope = scope, + Mission = new MissionClaim(mission.Approver, mission.S256), + }.Build(); + + var exchange = new TokenExchangeClient(agent.Signed, agent.Metadata); + return await exchange.ExchangeAsync(PsIssuer, resourceToken, options); + } + + private static TokenExchangeRequest Promptable() => new() + { + OnInteractionRequired = (_, _) => Task.CompletedTask, + }; + + private static JsonObject InScope(string resource, string scope) + => new() { ["resource"] = resource, ["scope"] = scope }; + + private async Task> ReadLogAsync(Mission mission) + { + var log = _factory.Services.GetRequiredService(); + return await log.ReadAsync(mission.S256); + } + + private async Task AssertTokenReasonAsync(Mission mission, string scope, bool granted, string reason) + { + var entries = await ReadLogAsync(mission); + var entry = entries.LastOrDefault(e => + e.Kind == MissionLogEntryKind.Token && e.Scope == scope); + Assert.NotNull(entry); + Assert.Equal(granted, entry!.Granted); + Assert.Equal(reason, entry.Detail); + } + + private async Task AssertPermissionReasonAsync(Mission mission, string action, bool granted) + { + var entries = await ReadLogAsync(mission); + var entry = entries.LastOrDefault(e => + e.Kind == MissionLogEntryKind.Permission && e.Action == action); + Assert.NotNull(entry); + Assert.Equal(granted, entry!.Granted); + } +} From ee5ff130c9cc60a02cca53296cc14e179791d2fb Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 21:50:06 +0000 Subject: [PATCH 07/24] feat(missions): add --pre-approve scope arg to MissionAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add repeatable --pre-approve CLI flag that seeds the (resource origin, scope) pair as in-scope before the mission is proposed, so the matching token request resolves silently at gate 2a (reason InScope) instead of prompting (§Agent Token Request). - Pre-approving the WhoAmI scope drops the token consent screen: an interactive run shows two browser screens (mission creation + delete_inbox permission) instead of three. - Adapt step 3/4 narration to show IN SCOPE (silent) vs OUT OF SCOPE, and document the flag in the README Options table. Phase 6a of missions/PS governance. --- samples/MissionAgent/Program.cs | 54 ++++++++++++++++++++++++++++----- samples/MissionAgent/README.md | 19 ++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/samples/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs index ef14afd..f6f44c5 100644 --- a/samples/MissionAgent/Program.cs +++ b/samples/MissionAgent/Program.cs @@ -43,13 +43,22 @@ // ============================================================================= const string Usage = - "Usage: MissionAgent [--ap ] [--ps ] [--resource ] [--sub ] [--auto]"; + "Usage: MissionAgent [--ap ] [--ps ] [--resource ] [--sub ]\n" + + " [--pre-approve ]... [--auto]"; + +// The scope WhoAmI's /jwt/mission resource demands (and therefore the scope the +// PS gates the token request on). Pre-approving this scope makes step 3 silent. +const string ResourceScope = "whoami"; string apUrl = "http://localhost:5301"; string personServer = "http://localhost:5100"; string resourceUrl = "http://localhost:5000/jwt/mission"; string subject = "aauth:mission-demo@ap.example"; bool interactive = true; +// Scopes the user pre-approves as within the mission's intent (§Agent Token +// Request, gate 2a). Seeding one lets the first resource access resolve +// *silently* (reason = InScope) instead of prompting — one fewer consent screen. +var preApprovedScopes = new List(); for (int i = 0; i < args.Length; i++) { @@ -61,7 +70,7 @@ case "--auto": interactive = false; break; - case "--ap" or "--ps" or "--resource" or "--sub": + case "--ap" or "--ps" or "--resource" or "--sub" or "--pre-approve": if (i + 1 >= args.Length) { Console.Error.WriteLine($"Missing value for {args[i]}."); @@ -74,6 +83,7 @@ case "--ps": personServer = value; break; case "--resource": resourceUrl = value; break; case "--sub": subject = value; break; + case "--pre-approve": preApprovedScopes.Add(value); break; } break; default: @@ -86,6 +96,12 @@ apUrl = apUrl.TrimEnd('/'); personServer = personServer.TrimEnd('/'); +// The PS gates the token request on the resource token's (iss, scope). The +// resource's iss is the WhoAmI *origin* (not the /jwt/mission path), so seed +// the in-scope set against the origin to match what the PS will compare. +var resourceOrigin = new Uri(resourceUrl).GetLeftPart(UriPartial.Authority); +bool resourceScopePreApproved = preApprovedScopes.Contains(ResourceScope, StringComparer.Ordinal); + Section("1. Enrol with the Agent Provider"); // The agent's signing key is long-lived (it spans the agent install). The // keystore holds the private key; we keep only its handle in memory here. @@ -116,6 +132,13 @@ // Tell the mock PS how to resolve prompts. Interactive mode holds each prompt // open until you decide in the browser; --auto resolves via scripted defaults. +// Any --pre-approve scopes are seeded as in-scope (resource origin, scope) pairs +// so the matching token request resolves silently at gate 2a (§Agent Token Request). +var inScopeSeed = new JsonArray(); +foreach (var scope in preApprovedScopes) +{ + inScopeSeed.Add(new JsonObject { ["resource"] = resourceOrigin, ["scope"] = scope }); +} await ScriptAsync(new JsonObject { ["reset"] = true, @@ -123,8 +146,13 @@ await ScriptAsync(new JsonObject ["approveMission"] = true, ["approveToken"] = true, ["approvePermission"] = true, + ["inScope"] = inScopeSeed, }); Console.WriteLine($" prompt mode : {(interactive ? "interactive (decide in your browser)" : "auto (scripted approvals)")}"); +if (preApprovedScopes.Count > 0) +{ + Console.WriteLine($" pre-approved : {string.Join(", ", preApprovedScopes.Select(s => $"{resourceOrigin} / {s}"))} (in scope — no prompt)"); +} // Generous polling budget so a human has time to click Approve. var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(5) }; @@ -154,11 +182,18 @@ await ScriptAsync(new JsonObject // approver, so a leaked token never exposes the mission's prose. Console.WriteLine($" mission s256 : {mission.S256} (thumbprint reference to the description above)"); -Section("3. Access a mission-aware resource — first call is OUT OF SCOPE"); +Section(resourceScopePreApproved + ? "3. Access a mission-aware resource — IN SCOPE (silent, no prompt)" + : "3. Access a mission-aware resource — first call is OUT OF SCOPE"); // WhoAmI's /jwt/mission endpoint is mission-aware: it copies the mission claim // from the AAuth-Mission header into the resource token it issues (§Terminology). -// The PS reads that claim and governs the token request. This (resource, scope) -// is not in the mission's pre-approved scope, so the PS prompts the user. +// The PS reads that claim and governs the token request. When this (resource, +// scope) was pre-approved as in-scope it resolves silently at gate 2a; otherwise +// it falls outside the mission's pre-approved scope and the PS prompts the user. +if (resourceScopePreApproved) +{ + Console.WriteLine($" pre-approved : {resourceOrigin} / {ResourceScope} was seeded in-scope, so this is granted silently (gate 2a — InScope)"); +} var first = await AccessMissionResourceAsync(); Console.WriteLine($" resource said : access={first?["access"]}, scope={first?["scope"]}"); // The resource echoes only the {approver, s256} reference from the token — the @@ -166,9 +201,12 @@ await ScriptAsync(new JsonObject Console.WriteLine($" echoed mission : {first?["mission"]?.ToJsonString()}"); Console.WriteLine($" (s256 references: \"{mission.Description}\")"); -Section("4. Access it again — now silent via PRIOR CONSENT"); -// The same (resource, scope) was just approved under this mission, so the PS -// grants the token silently this time (gate 2b) — no prompt. +Section(resourceScopePreApproved + ? "4. Access it again — still silent (IN SCOPE)" + : "4. Access it again — now silent via PRIOR CONSENT"); +// Either the (resource, scope) is in the mission's in-scope set (gate 2a) or it +// was just approved under this mission (gate 2b prior consent); either way the +// PS grants the token silently this time — no prompt. var second = await AccessMissionResourceAsync(); Console.WriteLine($" resource said : access={second?["access"]}, scope={second?["scope"]} (granted silently)"); diff --git a/samples/MissionAgent/README.md b/samples/MissionAgent/README.md index 36a495c..0d3385b 100644 --- a/samples/MissionAgent/README.md +++ b/samples/MissionAgent/README.md @@ -183,6 +183,24 @@ via the PS's scripted defaults: dotnet run --project samples/MissionAgent -- --auto ``` +### Pre-approving a scope (one fewer consent screen) + +The first resource access prompts because the `(resource, scope)` it needs isn't +yet on the mission's silent-allow list (gate 3). You can declare a scope as +**in scope** up front with `--pre-approve `; the PS then grants that +token request **silently** at gate 2a (reason `InScope`) and no token consent +screen appears: + +```bash +# interactive: only TWO browser screens (mission creation + the delete_inbox +# permission) instead of three — the WhoAmI token gate is now silent +dotnet run --project samples/MissionAgent -- --pre-approve whoami +``` + +The scope is seeded against the resource's **origin** (`http://localhost:5000`), +which is what the PS compares against the resource token's `iss`. Pass +`--pre-approve` more than once to seed several scopes. + ## Options | Flag | Default | Description | @@ -191,4 +209,5 @@ dotnet run --project samples/MissionAgent -- --auto | `--ps ` | `http://localhost:5100` | Person Server base URL | | `--resource ` | `http://localhost:5000/jwt/mission` | Mission-aware resource endpoint | | `--sub ` | `aauth:mission-demo@ap.example` | Agent identifier to enrol as | +| `--pre-approve ` | _(none)_ | Seed `(resource origin, scope)` as in-scope so its token request is granted silently (gate 2a); repeatable | | `--auto` | _(off)_ | Resolve prompts via scripted PS defaults instead of waiting for a browser decision | From a31a09a8ad28f79719aee2e8a1d5405c8aae4d63 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Fri, 5 Jun 2026 21:59:04 +0000 Subject: [PATCH 08/24] feat(missions): enhance Makefile and README for agent-mission with PRE_APPROVE support --- Makefile | 7 ++++--- samples/MissionAgent/README.md | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1d61562..e473b44 100644 --- a/Makefile +++ b/Makefile @@ -241,7 +241,8 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent @echo "" @echo " Drive it from another terminal with: make agent-mission" @echo " (out-of-scope prompts open the PS consent page in your browser;" - @echo " add AUTO=1 to resolve prompts via the PS's scripted defaults)" + @echo " add AUTO=1 to resolve prompts via the PS's scripted defaults;" + @echo " add PRE_APPROVE=whoami to seed an in-scope grant — one fewer prompt)" @echo "------------------------------------------------------------------" @echo "" @trap 'echo; echo "Stopping..."; kill 0' INT TERM; \ @@ -250,8 +251,8 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent $(DOTNET) run --project $(AP_PROJECT) & \ wait -agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts) - $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) +agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts; PRE_APPROVE= to seed an in-scope grant) + $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) $(if $(PRE_APPROVE),--pre-approve $(PRE_APPROVE),) # ---------------------------------------------------------------------------- # End-to-end (Playwright) diff --git a/samples/MissionAgent/README.md b/samples/MissionAgent/README.md index 0d3385b..c350c2d 100644 --- a/samples/MissionAgent/README.md +++ b/samples/MissionAgent/README.md @@ -171,6 +171,30 @@ Then run the agent: dotnet run --project samples/MissionAgent ``` +### Using the Makefile + +The repo ships two convenience targets. Start the backend stack (AP + PS + +WhoAmI) in one terminal: + +```bash +make demo-mission +``` + +Then drive the agent from another terminal: + +```bash +make agent-mission # interactive (decide in your browser) +make agent-mission PRE_APPROVE=whoami # interactive, but seed an in-scope grant — one fewer browser screen +make agent-mission AUTO=1 # unattended (scripted PS defaults — no browser screens) +``` + +`PRE_APPROVE=` maps to `--pre-approve ` and `AUTO=1` maps to +`--auto` (both described below). `PRE_APPROVE` is most useful in **interactive** +runs, where it removes a browser consent screen. Under `AUTO=1` there are no +screens to remove, so it only changes the PS's decision reason (silent `InScope` +at gate 2a instead of a scripted out-of-scope approval) — handy for tests, not +for a human watching. + By default each out-of-scope prompt is **interactive**: the agent prints the Person Server's consent URL (and tries to open it) and waits while you click **Approve** or **Deny** in your browser. The PS holds the request at `202` until @@ -195,6 +219,9 @@ screen appears: # interactive: only TWO browser screens (mission creation + the delete_inbox # permission) instead of three — the WhoAmI token gate is now silent dotnet run --project samples/MissionAgent -- --pre-approve whoami + +# or, via the Makefile: +make agent-mission PRE_APPROVE=whoami ``` The scope is seeded against the resource's **origin** (`http://localhost:5000`), From 5fe680912da518d444df04dfeea367a8910e73d0 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 16:48:21 +0000 Subject: [PATCH 09/24] feat(missions): add out-of-mission scope gate, e2e validation, fix agent-token replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the prompted out-of-mission scope gate (gate 3) so both halves of the spec gate model are demonstrated: a prompted scope (§Agent Token Request gate 3, §Scopes) alongside the prompted tool (§Permission Endpoint). WhoAmI gains scope whoami:elevated_scope at /jwt/mission/elevated. - SampleApp Mission.razor renders the full 5-gate flow (+ Home link); GuidedTour TourMode.Mission drives every gate through both outcomes with the PS decision reason surfaced. - Fix GuidedTour mission hang: refresh the agent token (fresh jti) per resource access so /jwt/mission/elevated is not rejected as a replay, restoring the 202 out-of-mission-scope prompt (§Agent Token replay protection). Remove temporary DIAG instrumentation from TourSession and AAuthChallengeMiddleware. - Default MissionAgent in-scope set to whoami (mirrors SampleApp) and rename --pre-approve to --mission-approved (Makefile MISSION_APPROVED). - Add SampleApp + GuidedTour mission Playwright specs; update picker spec for the 7th (Mission) flow. MockPersonServer consent-screen UX refinements (D9). Phase 6b of missions/PS governance. --- .../implementation-plan.md | 110 ++- .../research.md | 120 +++ Makefile | 11 +- docs/concepts.md | 9 +- samples/GuidedTour/CodeSnippets.cs | 129 +++ .../GuidedTour/Components/Pages/Tour.razor | 28 +- samples/GuidedTour/TourOptions.cs | 1 + samples/GuidedTour/TourSession.cs | 899 +++++++++++++++++- .../playwright-tests/mission.spec.ts | 137 +++ .../playwright-tests/picker.spec.ts | 22 +- samples/MissionAgent/Program.cs | 106 ++- samples/MissionAgent/README.md | 160 ++-- samples/MockPersonServer/MissionGovernance.cs | 13 + samples/MockPersonServer/Program.cs | 68 +- .../SampleApp/Components/Layout/NavMenu.razor | 6 + .../Components/Layout/NavMenu.razor.css | 4 + samples/SampleApp/Components/Pages/Home.razor | 20 + .../SampleApp/Components/Pages/Mission.razor | 667 +++++++++++++ .../playwright-tests/mission.spec.ts | 151 +++ samples/WhoAmI/Program.cs | 59 +- tests/e2e/helpers/tour.ts | 5 + 21 files changed, 2588 insertions(+), 137 deletions(-) create mode 100644 samples/GuidedTour/playwright-tests/mission.spec.ts create mode 100644 samples/SampleApp/Components/Pages/Mission.razor create mode 100644 samples/SampleApp/playwright-tests/mission.spec.ts diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index d8b98ef..35cdc91 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -457,12 +457,12 @@ rewrite of existing flows. `IMissionStore` / mission-log seams — not SDK behavior. - **Sub-phasing (agreed 2026-06-05):** Phase 6 executes in three committable sub-phases, each independently buildable + tested: - - **6a — Backend foundation:** MockPersonServer governance endpoints + `s256` + - **6a — Backend foundation (DONE):** MockPersonServer governance endpoints + `s256` / mission claim emission + three-gate consent + terminate hook; new `samples/MissionAgent/` CLI; the 12-row Consent-Matrix .NET integration test. - - **6b — Blazor + e2e:** SampleApp `Mission.razor` (+ Home link); GuidedTour + - **6b — Blazor + e2e (DONE):** SampleApp `Mission.razor` (+ Home link); GuidedTour `TourMode.Mission` (+ snippets, sequence diagram); the two Playwright specs. - - **6c — Glue:** Orchestrator mission hop; `make demo-mission` / `e2e-mission`; + - **6c — Glue (PENDING):** Orchestrator mission hop; `make demo-mission` / `e2e-mission`; READMEs + `tests/e2e/package.json` script. - **Deterministic consent scripting (agreed 2026-06-05 — option A):** the integration test drives mission-approval / token / permission outcomes by @@ -509,27 +509,28 @@ mission-log **decision reason**. - [x] `samples/MissionAgent/` proposes a mission, accesses ≥1 resource under it, requests a permission, records an audit entry, relays an interaction, and completes it. _(6a)_ -- [ ] SampleApp `Mission.razor` page renders the flow and labels each consent gate +- [x] SampleApp `Mission.razor` page renders the flow and labels each consent gate as **prompt** or **silent (in scope)**; Home links to it; existing pages unchanged. _(6b)_ -- [ ] GuidedTour `TourMode.Mission` drives every gate through **both** outcomes +- [x] GuidedTour `TourMode.Mission` drives every gate through **both** outcomes (mission approval prompt; in-scope token silent vs out-of-scope token prompt; `approved_tools` permission silent vs non-pre-approved permission prompt) and surfaces the PS decision reason for each; existing modes unchanged. _(6b)_ -- [ ] The PS decision reason (in-scope / prior consent / `approved_tools` / +- [x] The PS decision reason (in-scope / prior consent / `approved_tools` / out-of-scope) is visible in both samples so the contrast between prompted and silent gates is observable. _(6b)_ - [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining). _(6c)_ - [x] `make demo-mission` boots the mission demo; existing `make demo` unchanged. _(pulled forward from 6c; also added `agent-mission` runner)_ -- [ ] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the - existing `guided-tour`/`sample-app` projects; existing specs still pass. _(6b)_ +- [x] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the + existing `guided-tour`/`sample-app` projects; existing specs still pass. + _(6b — full suite 29 passed / 1 skipped locally)_ - [x] **.NET integration test** for the `MissionAgent` CLI covers **all 12 rows** of the Consent Test Matrix (every gate × approve/deny × prompt/silent), including clarification and `mission_terminated`, each asserting the recorded decision reason. _(6a — `MissionAgentFlowTests`, 12/12)_ -- [ ] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally and in CI. - _(CLI integration green; Blazor e2e in 6b)_ +- [x] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally. + _(CLI integration 12/12; Blazor e2e full suite 29 passed / 1 skipped locally; CI not separately run)_ - [ ] Sample READMEs updated. _(MissionAgent + MockPersonServer done in 6a; others in 6c)_ #### Phase 6a additions (spec-driven, beyond the original file list) @@ -551,9 +552,94 @@ mission-log **decision reason**. §Permission Endpoint (`action` per-call vs mission `approved_tools`). Scripted mode (the 12-row test) is unaffected (`InteractiveBrowser = false`). ---- +#### Phase 6b amendments (2026-06-06, spec-driven, added mid-phase) + +These refine the consent UX and **add the missing out-of-mission scope gate**. +The original Phase 6 plan (file list, line "out-of-scope token request that +**prompts**") always intended a prompted **token/scope** gate, but 6b shipped +only the prompted **tool** gate (`delete_inbox`). These steps close that gap so +both halves of the spec's gate model — a prompted *scope* (§Agent Token Request +gate 3) **and** a prompted *tool* (§Permission Endpoint) — are demonstrated. + +**Spec grounding:** + +- **Tools are declared; scopes are evaluated** (§Mission Creation L1233 — proposal + is `description` + optional `tools` only, no scopes; §Mission Approval L1299–1303 + — blob carries `approved_tools`, never scopes). The mission proposal lists no + scopes; the PS determines required scopes **per request, over the mission's + whole life** (§Scopes L1793 "The PS evaluates requested scopes against mission + context"; §Concurrent Token Requests L828 "some requests may be resolved + without user interaction … while others may require consent"). +- **Out-of-mission scope ⇒ prompt, not auto-deny** (§Agent Token Request gate 3; + §Scopes L1793). Only an explicit user deny (or `mission_terminated`, gate 1) + yields `access_denied`. + +**Decisions (agreed 2026-06-06 via interview):** + +- **D6 — New mission-aware endpoint + new scope (not reuse `whoami:admin`).** + WhoAmI gains a second mission-aware endpoint guarded by a **new** resource + scope so the out-of-mission scenario is clearly distinct from the existing + non-mission `/jwt/admin` step-up demo. Proposed: scope `whoami:history` + ("See your full account/profile history") at endpoint `/jwt/history`, wired + with `ChallengeForMission(ScopeWhoamiHistory)`. Under the seeded inbox mission + (in-scope = `whoami` only), requesting `whoami:history` falls outside the + mission → PS prompts (gate 3). _(final names confirmed at implementation.)_ +- **D7 — Add as a NEW gate (5 gates total), existing gates unchanged.** Final + order: (1) mission approval **PROMPT** → (2) `whoami` token **SILENT** (in + scope) → (3) `whoami:history` token **PROMPT** (out-of-mission scope) → (4) + `send_email` tool **SILENT** (pre-approved) → (5) `delete_inbox` tool + **PROMPT** (not pre-approved). +- **D8 — "Agent console app" = `samples/MissionAgent/`** (the CLI), not + `samples/AgentConsole/` (which has no mission support and no mermaid). Its + README sequence diagram gains the new out-of-mission scope consent block. +- **D9 — Consent-screen UX refinements (already applied in 6b):** PS + `/interaction` shows **scopes and tools as separate lists**; a spec-grounded + **tool (local) vs scope (remote)** definition box; the **creation screen lists + no scopes** (only a note that the PS determines them per-request from the + mission description); post-creation gates relabel the scope list **"Granted so + far"** (accrual), with empty state "nothing yet — this is the first request". + +### Amendment files -## Phase 7 — Docs +| File | Action | +|------|--------| +| `samples/WhoAmI/Program.cs` | **Modify** — add scope `whoami:history` (+ `scope_descriptions` entry, scope policy) and a mission-aware endpoint `/jwt/history` via `ChallengeForMission`; exclude `/jwt/history` from the baseline `/jwt` branch; list it in the index payload | +| `samples/WhoAmI/README.md` | **Modify** — document the new scope + endpoint | +| `samples/SampleApp/Components/Pages/Mission.razor` | **Modify** — insert the out-of-mission **scope** gate (gate 3) between the silent `whoami` token and the tool gates; client + resource panels show `/protected_endpoint` requesting the elevated scope; 5-gate narrative | +| `samples/GuidedTour/TourSession.cs` | **Modify** — extend `MissionPlan` with an out-of-mission scope token cycle (challenge → 202 PROMPT → approve → poll → exchange); renumber steps + approval/poll constants | +| `samples/GuidedTour/CodeSnippets.cs` | **Modify** — add the out-of-mission scope snippet | +| `samples/GuidedTour/Components/Pages/Tour.razor` | **Modify** — mission lane/flow text reflects the new scope gate | +| `samples/MockPersonServer/Program.cs` | **Modify (done in 6b amendments)** — separate scope/tool lists, definition box, creation screen drops scopes + adds determined-per-request note, "Granted so far" relabel | +| `samples/MissionAgent/Program.cs` | **Modify** — add a step that requests the out-of-mission scope under the mission (prompted token gate) | +| `samples/MissionAgent/README.md` | **Modify** — add the out-of-mission scope consent block to the mermaid sequence diagram + the gate table / "Scope (remote) vs tool (local)" prose | +| `docs/concepts.md` | **Modify (done)** — tools declared vs scopes evaluated; per-request, lifetime-long scope determination | +| `samples/SampleApp/playwright-tests/mission.spec.ts` | **New/Modify** — assert the 5th gate (out-of-mission scope **prompt**) | +| `samples/GuidedTour/playwright-tests/mission.spec.ts` | **New/Modify** — assert the out-of-mission scope **prompt** step | + +### Amendment Definition of Done + +- [x] WhoAmI exposes a new resource scope (`whoami:elevated_scope`) on a + mission-aware endpoint (`/jwt/mission/elevated`); `scope_descriptions` + + scope policy updated; baseline `/jwt/mission` branch excludes it + (§Resource Metadata, §Scopes). _(6b — final names landed as + `whoami:elevated_scope` / `/jwt/mission/elevated`, not the proposed + `whoami:history` / `/jwt/history`; D6 deferred names to implementation)_ +- [x] Under the seeded inbox mission, requesting the new scope is **out of + mission** → PS **prompts** (gate 3), and on approval issues the auth token; + the granted scope then shows under "Granted so far" on later screens + (§Agent Token Request, §Scopes L1793). _(6b)_ +- [x] SampleApp `Mission.razor` shows **5 gates** incl. the out-of-mission scope + prompt, distinct from the out-of-tool permission prompt. _(6b)_ +- [x] GuidedTour `TourMode.Mission` drives the out-of-mission scope cycle + (challenge → prompt → approve → exchange) with the decision reason visible. + _(6b)_ +- [x] `samples/MissionAgent/` requests the out-of-mission scope under the mission; + its README mermaid diagram + gate prose include the new consent block. _(6b)_ +- [x] Consent-screen UX refinements (D9) reflected and live-verified. _(6b)_ +- [x] Playwright specs assert the new prompted-scope gate; existing specs pass. + _(6b)_ + +--- **Goal:** Rewrite the stale missions doc and add PS-governance docs reflecting the implemented surface. Separate phase from samples per the agreed workflow. diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index afadcc3..40e514f 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -651,3 +651,123 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a - **Files (this follow-up):** modified `samples/MockPersonServer/ {MissionGovernance.cs, Program.cs}`, `samples/MissionAgent/{Program.cs, README.md}`. No SDK/test changes; suites unchanged (425 / 383). + +### Phase 6b — Blazor apps + consent-UX refinements + out-of-mission scope gate (2026-06-06, in progress) + +- **GuidedTour + SampleApp mission flows (built, live-verified).** GuidedTour + `TourMode.Mission` (14-step raw-HTTP walkthrough) and SampleApp `Mission.razor` + (one-page 4-gate run) both drive: mission approval **prompt** → in-scope + `whoami` token **silent** → pre-approved `send_email` tool **silent** → + non-pre-approved `delete_inbox` tool **prompt**. Live-verified end-to-end. +- **BUG FOUND + FIXED — `whoami` proposed as a tool.** GuidedTour's proposal + listed `whoami` in `tools`, which is wrong: `whoami` is a **resource scope** + (remote, §Scopes), not a local tool (§Permission Endpoint). After the + separate scope/tool lists were added it showed `whoami` in BOTH the consent + screen's scope list and tool list. Fixed the proposal to `summarize` + + `send_email`. Confirms the **tool-vs-scope** distinction matters in the demo + data, not just the docs. +- **Consent-UX refinements (MockPersonServer `/interaction`, all live-verified):** + - **Separate scope + tool lists** (§Scopes vs §Permission Endpoint) so the + user sees both halves of the mission's authority distinctly. + - **Definition box** grounding the two words: *resource scope* = remote access + via auth token; *tool* = local action via the permission endpoint. + - **Creation screen lists NO scopes.** Per §Mission Creation (L1233) a proposal + carries only `description` + optional `tools` — never scopes. The creation + screen now shows the description + tools and a note that **the PS will + determine the resource scopes the mission needs from its description, + per-request, as the agent works** (§Scopes L1793 "The PS evaluates requested + scopes against mission context"; §Concurrent Token Requests L828). This + corrected an earlier draft that wrongly implied scopes were pre-determined + at creation. + - **CLARIFIED SPEC SEMANTICS — scopes are determined dynamically over the + mission's whole life, never fixed at creation.** A mission is a standing + natural-language context; the PS is a per-request judge (§Overview L141 + "every resource access is evaluated in context"; §Token endpoint L784 — the + PS *remembers* prior consent within a mission so decisions accrue rather than + being declared up front). Out-of-mission scope ⇒ **gate 3 prompt**, not + auto-deny; only an explicit user deny (or `mission_terminated`) yields + `access_denied`. + - **Post-creation gates relabel the scope list "Granted so far"** (accrual + framing) with empty state "nothing yet — this is the first request"; the + token-gate request note now reads "Not yet covered by this mission — approve + to grant this scope (the agent may reuse it for the rest of the mission)", + teaching that the grant is remembered. + - Removed the now-unused `MissionConsentScript script` DI param from the + `/interaction` GET handler. +- **docs/concepts.md** Governance section rewritten to convey the asymmetry: + **tools are declared, scopes are evaluated** (per-request, lifetime-long). + MissionAgent README gained the same distinction. + +- **NEW WORK (designed 2026-06-06, decisions D6–D9 in plan) — out-of-mission + scope gate.** The original Phase 6 plan intended a prompted **token/scope** + gate ("out-of-scope token request that prompts") but 6b shipped only the + prompted **tool** gate. Gap confirmed by reading `MissionPlan` (GuidedTour) and + `Mission.razor` — neither exercises an out-of-mission *scope*. To close it: + - **WhoAmI** gains a **new resource scope** (`whoami:history`, proposed) on a + **new mission-aware endpoint** (`/jwt/history`) via + `ChallengeForMission(...)`. Decision **D6**: new endpoint + new scope (not + reuse the existing non-mission `/jwt/admin` step-up) so the out-of-mission + scenario is unambiguous. WhoAmI already has the levers: `ChallengeForMission` + helper, `AddAAuthScopePolicy`, and `ScopeDescriptions` metadata — the new + path just needs excluding from the baseline `/jwt` branch. + - **Gate model becomes 5 gates** (decision **D7**): mission approval prompt → + `whoami` token silent → `whoami:history` token **PROMPT (out-of-mission + scope)** → `send_email` tool silent → `delete_inbox` tool prompt. Under the + seeded inbox mission (in-scope = `whoami` only), `whoami:history` naturally + falls outside → the existing `/token` gate-3 prompt path fires (no PS logic + change needed — the seed already excludes it). + - **Apps to update:** SampleApp `Mission.razor` (insert gate 3), GuidedTour + `TourSession.cs` MissionPlan + step methods + approval/poll constants + + `CodeSnippets.cs` + `Tour.razor`, and **`samples/MissionAgent/`** (decision + **D8**: the "agent console app" = MissionAgent, which has a mermaid sequence + diagram to extend with the out-of-mission scope consent block) + its README. + - **Playwright specs** assert the new prompted-scope gate. +- **AgentConsole is NOT in scope** (subagent-confirmed): it has no mission + support and its README has **no mermaid diagrams** (only tables + example + invocations). The user's "agent console app" referred to MissionAgent. +- **Status:** consent-UX refinements + bug fix + concept doc DONE and + live-verified; out-of-mission scope gate DESIGNED (this entry) and pending + implementation. SampleApp consent-URL reorder (amendment 3) built, not yet + live-verified. Playwright specs pending. Builds: MockPersonServer / SampleApp / + GuidedTour all 0/0. + +### Phase 6b — e2e validation + jti-replay fix (2026-06-06, complete) + +- **e2e specs written.** `samples/GuidedTour/playwright-tests/mission.spec.ts` + (20-step, three-cycle lifecycle + elevated-deny) and + `samples/SampleApp/playwright-tests/mission.spec.ts` (five-gate run + elevated-deny). + Both projects boot fresh via Playwright's `webServer` array. +- **BUG FOUND via e2e + FIXED — GuidedTour mission flow hung at the elevated + cycle.** Root cause was a **jti replay**: `TourSession` reused a single + `_agentToken` (fixed `jti`) for BOTH the `/jwt/mission` (cycle 1) and + `/jwt/mission/elevated` (cycle 2) signed requests. WhoAmI's `InMemoryJtiStore` + recorded the `jti` on the first access and rejected its reuse on the second → + a **bare 401** (no `AAuth-Requirement`) → `_resourceToken` stayed stale + (`scope=whoami`) → the elevated `/token` exchange evaluated as in-scope and + returned **200 silent** instead of the spec-required **202 prompt** (§Agent + Token Request gate 3) → `_userApproved` was never reset to `false` → + `StepUserApprovesPlaceholder()` no-oped (it only throws when `!_userApproved`) + → the run-all dispatch loop never advanced past step 12 → infinite redispatch. + Both observed symptoms (silent-grant + infinite loop) had this single cause. +- **FIX (spec §Agent Token, replay protection):** added + `TourSession.RefreshAgentToken()` (re-mints `_agentToken` with a fresh `jti` + via `AgentTokenBuilder.Build()`) and call it at the top of + `StepMissionResourceChallengeAsync` AND `StepMissionElevatedChallengeAsync`, + so each signed resource access presents a distinct agent token — exactly as + `MissionAgent` (and SampleApp's `AccessMissionResourceAsync`) already did via a + per-access refresh. SampleApp was already correct; GuidedTour was the only + app missing the refresh. +- **TEST-HARNESS LESSON (not a product bug).** WhoAmI mints an ephemeral + resource signing key (`AAuthKey.Generate()`) at every startup, so restarting + WhoAmI alone while a long-running PS keeps its cached JWKS yields + `401 invalid_resource_token: JWT signature verification failed`. Always boot + the whole AAuth backend set together (let Playwright's `webServer` boot all, + or `pkill -f "dotnet run --project samples"` first). `jti` replay state also + accumulates across e2e runs against a long-lived WhoAmI — prefer fresh + full-stack boots. Recorded in repo memory. +- **DIAG removed.** All temporary `[DIAG-CHAL]` (SDK + `AAuthChallengeMiddleware.cs`) and `[DIAG]` (`TourSession.cs`) tracing removed. +- **Validation:** `AAuth.slnx` builds 0/0. `AAuth.Tests` 383/383, + `AAuth.Conformance` 425/425. GuidedTour mission specs (lifecycle + deny) green; + SampleApp mission specs (five-gate + deny) green. + diff --git a/Makefile b/Makefile index e473b44..7af2c7f 100644 --- a/Makefile +++ b/Makefile @@ -240,9 +240,12 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent @echo " MockAgentProvider: $(AP_URL) (agent registry)" @echo "" @echo " Drive it from another terminal with: make agent-mission" - @echo " (out-of-scope prompts open the PS consent page in your browser;" + @echo " (the whoami token gate is mission-approved by default, so it is silent;" + @echo " the elevated whoami:elevated_scope step is out of the mission and" + @echo " prompts on its own;" @echo " add AUTO=1 to resolve prompts via the PS's scripted defaults;" - @echo " add PRE_APPROVE=whoami to seed an in-scope grant — one fewer prompt)" + @echo " set MISSION_APPROVED to replace the default in-scope set, e.g." + @echo " MISSION_APPROVED='whoami whoami:elevated_scope' to silence the elevated step too)" @echo "------------------------------------------------------------------" @echo "" @trap 'echo; echo "Stopping..."; kill 0' INT TERM; \ @@ -251,8 +254,8 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent $(DOTNET) run --project $(AP_PROJECT) & \ wait -agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts; PRE_APPROVE= to seed an in-scope grant) - $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) $(if $(PRE_APPROVE),--pre-approve $(PRE_APPROVE),) +agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts; MISSION_APPROVED="..." to replace the default in-scope set) + $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) $(foreach scope,$(MISSION_APPROVED),--mission-approved $(scope)) # ---------------------------------------------------------------------------- # End-to-end (Playwright) diff --git a/docs/concepts.md b/docs/concepts.md index c0a65f6..13b22b9 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -41,9 +41,16 @@ Four modes: ### 3. Governance (Missions) -Optional layer. Agent proposes missions; PS approves and scopes permissions. +Optional layer. The agent proposes a mission — a Markdown **description** of intent plus an optional list of **tools** — and the PS approves it (§Mission Creation, §Mission Approval). SDK: `Mission`, `AAuthMissionHeader` +The two kinds of authority a mission governs are handled **asymmetrically**, and this is the key idea: + +- **Tools are *declared*.** A tool is an action the agent runs **itself** (a tool call, file write, sending a message) — no resource is involved. Because the PS can't observe a local action, the mission must name the tools up front: the approved `approved_tools` are pre-approved and resolve at the **permission endpoint** without a PS round-trip; any other action is referred to the user (§Permission Endpoint). SDK: `Mission.ApprovedTools`, `PermissionClient`. +- **Scopes are *evaluated*, never declared.** A scope authorizes access to a remote **resource** (an API), carried in an **auth token** via the challenge → exchange → retry pattern (§Scopes). A mission proposal contains **no scopes**. Instead, when the agent later exchanges a resource token, the PS judges that requested scope *against the mission's natural-language description*: if it fits the stated intent it is granted silently (gate 2a), and prior decisions are remembered for the rest of the mission; otherwise the user is prompted (§Scopes — *"The PS evaluates requested scopes against mission context"*; §Agent Token Request). SDK: `AAuthScopeRequirement`, `AAuthVerificationResult.Scopes`. + +In short: **a mission lists the tools the agent may run locally, but it does not list scopes — the PS decides, per request, whether a requested resource scope fits the mission's intent.** Scopes and AS policy stay enforced by the resource and its Access Server; the mission is "a further restriction applied by the PS" (§Rationale). + See [Missions](https://explorer.aauth.dev/missions/compare). ## Token Types diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 9ce261b..693c920 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -232,4 +232,133 @@ internal static class CodeSnippets var response = await client.GetAsync("https://resource.example/data"); // 401 → exchange → poll → retry all handled transparently """; + + // ── Mission-governed flow (§Missions, §PS Governance Endpoints) ────────── + + public const string MissionDiscoverPs = """ + // GET /.well-known/aauth-person.json + var meta = await metadata.FetchAsync( + "https://ps.example/.well-known/aauth-person.json"); + var mission = (string)meta["mission_endpoint"]; + var tokenEp = (string)meta["token_endpoint"]; + var permission = (string)meta["permission_endpoint"]; + """; + + public const string MissionPropose = """ + var governance = new AAuthGovernanceClient(signedClient, metadata); + var mission = await governance.Mission.ProposeAsync( + "https://ps.example", + new MissionProposal("Triage the user's inbox…") + { + Tools = + { + new MissionTool("summarize"), + new MissionTool("send_email"), + }, + }, + new GovernanceOptions { OnInteractionRequired = SurfaceToUser }); + // SDK POSTs /mission → 202; SurfaceToUser shows the consent link, + // then the client polls until the user approves. + """; + + public const string MissionPollCreate = """ + // The MissionClient polls the mission-pending URL internally and + // returns the parsed, verified Mission once the user approves. + // mission.Approver / mission.S256 / mission.ApprovedTools + var verified = mission.VerifyS256(missionHeaderS256); // s256 integrity + """; + + public const string MissionChallenge = """ + // Advertise the mission so the resource binds it into the resource_token. + using var req = new HttpRequestMessage(HttpMethod.Get, resourceUrl); + req.Headers.Add( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); + var resp = await signedClient.SendAsync(req); // → 401 + AAuth-Requirement + var resourceToken = AAuthRequirementHeader.Parse( + resp.Headers.GetValues(AAuthRequirementHeader.Name).First()).ResourceToken; + """; + + public const string MissionExchange = """ + // The resource_token carries the mission claim; because (resource, whoami) + // is in the mission scope, the PS mints the auth_token SILENTLY. + var authToken = await exchange.ExchangeAsync("https://ps.example", resourceToken); + // Or, end-to-end: AAuthClientBuilder handles 401 → exchange → retry for you. + """; + + public const string MissionReplay = """ + using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(() => authToken) + .Build(); + var data = await client.GetAsync(resourceUrl); // 200 + mission round-tripped + """; + + public const string MissionElevatedChallenge = """ + // Same mission header, but the ELEVATED endpoint requires + // whoami:elevated_scope — a scope the mission never declared. + using var req = new HttpRequestMessage(HttpMethod.Get, elevatedUrl); + req.Headers.Add(AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); + var resp = await signedClient.SendAsync(req); // → 401 + AAuth-Requirement + var resourceToken = AAuthRequirementHeader.Parse( + resp.Headers.GetValues(AAuthRequirementHeader.Name).First()).ResourceToken; + """; + + public const string MissionElevatedExchange = """ + // whoami:elevated_scope is OUTSIDE the mission's intent, so the PS + // cannot mint silently — it returns 202 and asks the user to decide. + // (Out-of-mission scopes prompt; they are never auto-denied.) + var authToken = await exchange.ExchangeAsync("https://ps.example", resourceToken, + new TokenExchangeRequest { OnInteractionRequired = SurfaceToUser }); + """; + + public const string MissionElevatedPoll = """ + // Once the user approves, the poll returns the elevated auth_token. + // The consent accrues to the mission, so a later elevated request + // would resolve silently. + var data = await elevatedClient.GetAsync(elevatedUrl); // 200 + """; + + public const string MissionElevatedReplay = """ + using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(() => elevatedAuthToken) + .Build(); + var data = await client.GetAsync(elevatedUrl); // 200 + elevated claims + """; + + public const string MissionPreApproved = """ + // Pre-approved tools never hit the network — the SDK short-circuits. + var result = await governance.Permission.RequestAsync( + "https://ps.example", "send_email", mission); + // result.IsGranted == true (no PS call: send_email ∈ mission.ApprovedTools) + """; + + public const string MissionPermissionPrompt = """ + // delete_inbox is NOT pre-approved → the PS prompts the user. + var result = await governance.Permission.RequestAsync( + "https://ps.example", + new PermissionRequest("delete_inbox") { Mission = missionClaim }, + new GovernanceOptions { OnInteractionRequired = SurfaceToUser }); + // SDK POSTs /permission → 202; surfaces the link; polls for the decision. + """; + + public const string MissionPollPermission = """ + // The poll returns a DECISION, not a token. The gate-2 auth_token is + // unaffected by whatever the user chooses here. + if (!result.IsGranted) + throw new InvalidOperationException(result.Reason); // user denied + // On grant: run delete_inbox, then report it to the audit_endpoint. + await governance.Audit.RecordAsync("https://ps.example", + new AuditRecord(missionClaim, "delete_inbox")); + """; + + public const string MissionInspect = """ + // One mission approval governed the whole session: + // gate 1 mission creation .... PROMPT + // gate 2 whoami token ........ SILENT (in scope) + // gate 3 elevated scope ...... PROMPT (out of mission scope) + // gate 4 send_email tool ..... SILENT (pre-approved, local) + // gate 5 delete_inbox action . PROMPT (out of scope) + // The PS is the policy-enforcement point; the resource stays oblivious. + """; } diff --git a/samples/GuidedTour/Components/Pages/Tour.razor b/samples/GuidedTour/Components/Pages/Tour.razor index 6ea710d..2f514cd 100644 --- a/samples/GuidedTour/Components/Pages/Tour.razor +++ b/samples/GuidedTour/Components/Pages/Tour.razor @@ -40,6 +40,7 @@ + @if (Session.Mode == TourMode.Identity) @@ -152,6 +153,19 @@ login/consent). break; + case TourMode.Mission: + + Signing mode: Agent Token (sig=jwt) — the Person Server + acts as the policy-enforcement point. The agent first proposes a durable + mission (description + pre-approved tools) which the user approves once; + every later request is then checked against it. An in-scope token (whoami) + is minted silently; an out-of-mission scope (whoami:elevated_scope) + prompts; a pre-approved tool (send_email) is + resolved locally with no PS call; and an out-of-scope action + (delete_inbox) prompts again. Three human approvals: + creating the mission, the out-of-mission scope, and the out-of-scope action — everything in between flows without friction. + + break; }

@@ -186,8 +200,8 @@ Lanes="@ActiveLanes" IsPolling="@Session.IsPolling" PollCount="@Session.PollCount" - LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)" - LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending) ? Session.PollStepNumber : 0)" + LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)" + LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? Session.PollStepNumber : 0)" CompletedLoopLabel="@CompletedLoopLabel()" LoopCompletedKind="@CompletedLoopKind()" /> @@ -238,11 +252,19 @@ new("Access Server", "as", Actor.AccessServer), }; + private static readonly SequenceDiagram.LaneDefinition[] MissionLanes = + { + new("Agent", "agent", Actor.Agent), + new("Resource", "resource", Actor.Resource), + new("Person Server", "ps", Actor.PersonServer), + }; + private SequenceDiagram.LaneDefinition[]? ActiveLanes => Session.IsBootstrapMode ? BootstrapLanes : Session.IsIdentityMode ? IdentityLanes : Session.IsCallChainMode ? CallChainLanes : - Session.IsFederatedMode ? FederatedLanes : null; + Session.IsFederatedMode ? FederatedLanes : + Session.IsMissionMode ? MissionLanes : null; protected override async Task OnInitializedAsync() { diff --git a/samples/GuidedTour/TourOptions.cs b/samples/GuidedTour/TourOptions.cs index 1ec7bb6..207a8dd 100644 --- a/samples/GuidedTour/TourOptions.cs +++ b/samples/GuidedTour/TourOptions.cs @@ -19,6 +19,7 @@ public enum TourMode Deferred, CallChain, Federated, + Mission, } /// diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 5d26592..d4fc74e 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -49,6 +49,17 @@ public sealed class TourSession : IAsyncDisposable private bool _aborted; private TourMode _mode; + // Mission-governed flow state (§Missions). Captured from the mission + // approval blob returned by the mission-create poll (step 5) so later + // steps can bind the AAuth-Mission header + show the mission identity. + private string? _missionApprover; + private string? _missionS256; + private string? _missionDescription; + private int _missionApprovedToolCount; + private string? _missionResponseBody; + private string? _missionEndpoint; + private string? _permissionEndpoint; + // Background polling state (deferred mode, poll step). Mutated from // the polling task; the UI listens to StateChanged and re-renders. private CancellationTokenSource? _pollingCts; @@ -129,6 +140,21 @@ public SigningMode SigningMode _ => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt", }; + /// + /// The mission-aware resource endpoint (§Missions). Distinct from + /// : the mission flow targets the + /// resource's mission-aware path so the 401 challenge copies the + /// AAuth-Mission claim into the resource_token. + /// + private string MissionResourceUrl => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt/mission"; + + /// + /// The ELEVATED mission-aware resource endpoint (§Scopes). Requires + /// `whoami:elevated_scope`, which falls outside the seeded mission scope, + /// so its token exchange surfaces an out-of-mission consent prompt (gate 3). + /// + private string MissionElevatedResourceUrl => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt/mission/elevated"; + /// /// True when the current flow is the identity-based path. Forced on /// when no PS URL is configured, regardless of . @@ -152,6 +178,9 @@ public SigningMode SigningMode /// True when the current flow is the four-party federated path. public bool IsFederatedMode => HasPersonServer && _mode == TourMode.Federated && HasAccessServer; + /// True when the current flow is the mission-governed (PS-as-policy) path. + public bool IsMissionMode => HasPersonServer && _mode == TourMode.Mission; + /// /// True when the call-chain flow has entered its multi-hop consent path: /// the agent's exchange 202'd (no standing consent), so the flow surfaces @@ -176,6 +205,7 @@ public int TotalSteps { if (IsBootstrapMode) return HasAgentProvider ? 3 : 2; if (IsIdentityMode) return 2; + if (IsMissionMode) return 20; if (IsCallChainMode) return _callChainPending ? 13 : 7; if (IsFederatedMode) return _federatedPending ? 10 : 7; return IsDeferredMode ? 9 : 6; @@ -194,6 +224,7 @@ public IReadOnlyList Plan { if (IsBootstrapMode) return HasAgentProvider ? ApBootstrapPlan : LocalBootstrapPlan; if (IsIdentityMode) return IdentityPlan; + if (IsMissionMode) return MissionPlan; if (IsCallChainMode) return _callChainPending ? CallChainConsentPlan : CallChainPlan; if (IsFederatedMode) return _federatedPending ? FederatedConsentPlan : FederatedPlan; return IsDeferredMode ? DeferredPlan : AutonomousPlan; @@ -276,6 +307,37 @@ public IReadOnlyList Plan new(13, "Inspect multi-agent result", "Review the combined response showing the full Agent → Orchestrator → WhoAmI chain.", Actor.Agent, Actor.Agent), }; + // The mission-governed flow (§Missions, §PS Governance Endpoints). The PS + // is the policy-enforcement point: the agent proposes a durable mission + // (PROMPT), then every later request is checked against it — an in-scope + // resource token is minted SILENTLY (gate 2), a pre-approved tool is + // resolved locally with no PS call (gate 3), and an out-of-scope action + // (delete_inbox) is PROMPTED again (gate 4). Mirrors the SampleApp Mission + // page's four-gate use case as a step-by-step raw-HTTP walkthrough. + private static readonly TourPlanStep[] MissionPlan = + { + new(1, "Discover Person Server metadata", "Unsigned GET /.well-known/aauth-person.json for mission_endpoint, token_endpoint + permission_endpoint.", Actor.Agent, Actor.PersonServer), + new(2, "Propose mission → 202 (PROMPT)", "Signed POST /mission {description, tools}; the PS parks the proposal and returns 202 + interaction URL + single-use code.", Actor.Agent, Actor.PersonServer), + new(3, "Direct user to mission approval", "Agent surfaces the {url}?code={code} link for the user to approve the durable mission + its tools.", Actor.Agent, Actor.Agent), + new(4, "User approves the mission at the PS", "User opens the PS consent page and approves the mission; the PS records the approved mission + tools.", Actor.PersonServer, Actor.PersonServer), + new(5, "Poll → 200 mission approval blob", "Signed GETs to /mission-create-pending/{id} until the PS returns the verbatim approval blob + AAuth-Mission header (s256).", Actor.Agent, Actor.PersonServer), + new(6, "Signed GET /jwt/mission → 401", "Signed request carries AAuth-Mission; the resource copies the mission into a resource_token and challenges with 401.", Actor.Agent, Actor.Resource), + new(7, "Exchange → 200 auth_token (SILENT)", "Signed POST /token; the (resource, whoami) pair is in the mission scope, so the PS mints the auth_token with no prompt (gate 2).", Actor.Agent, Actor.PersonServer), + new(8, "Replay GET /jwt/mission → 200", "Signed retry with the auth_token returns the protected claims with the mission binding round-tripped.", Actor.Agent, Actor.Resource), + new(9, "Signed GET /jwt/mission/elevated → 401", "Signed request for the ELEVATED whoami:elevated_scope; the resource copies the mission into a resource_token and challenges with 401.", Actor.Agent, Actor.Resource), + new(10, "Exchange → 202 (PROMPT, out of mission)", "Signed POST /token; whoami:elevated_scope is OUTSIDE the mission's intent, so the PS cannot grant silently — it parks the request and returns 202 + interaction URL (gate 3).", Actor.Agent, Actor.PersonServer), + new(11, "Direct user to scope approval", "Agent relays the interaction URL for the user to approve the out-of-mission elevated scope.", Actor.Agent, Actor.Agent), + new(12, "User approves the elevated scope at the PS", "User approves whoami:elevated_scope at the PS; the consent accrues to the mission for later requests.", Actor.PersonServer, Actor.PersonServer), + new(13, "Poll → 200 auth_token (elevated)", "Signed GETs to the token-pending URL until the PS returns the elevated auth_token.", Actor.Agent, Actor.PersonServer), + new(14, "Replay GET /jwt/mission/elevated → 200", "Signed retry with the elevated auth_token returns the protected claims.", Actor.Agent, Actor.Resource), + new(15, "Permission: send_email (SILENT, local)", "send_email is a pre-approved mission tool, so the agent resolves it locally — no PS round-trip (gate 4).", Actor.Agent, Actor.Agent), + new(16, "Permission: delete_inbox → 202 (PROMPT)", "delete_inbox is NOT a pre-approved tool; signed POST /permission parks the request and returns 202 + interaction URL (gate 5).", Actor.Agent, Actor.PersonServer), + new(17, "Direct user to action approval", "Agent relays the permission interaction URL for the user to approve the out-of-scope delete_inbox action.", Actor.Agent, Actor.Agent), + new(18, "User approves the action at the PS", "User approves delete_inbox at the PS; the PS records the decision against the mission log.", Actor.PersonServer, Actor.PersonServer), + new(19, "Poll → 200 permission granted", "Signed GETs to /permission-pending/{id} until the PS returns {permission: granted}.", Actor.Agent, Actor.PersonServer), + new(20, "Inspect mission result", "Review the full governed flow: one mission, one silent token, one prompted scope, one local tool, one prompted action.", Actor.Agent, Actor.Agent), + }; + private static readonly TourPlanStep[] FederatedPlan = { new(1, "Discover resource metadata", "Unsigned GET /federated/.well-known/aauth-resource.json.", Actor.Agent, Actor.Resource), @@ -320,13 +382,21 @@ public IReadOnlyList Plan /// The step number at which user approval occurs in deferred mode. public int UserApprovalStepNumber => - IsCallChainPending + IsMissionMode + ? (Steps.Count <= MissionHop1PollStep ? MissionHop1ApprovalStep + : Steps.Count <= MissionHop2PollStep ? MissionHop2ApprovalStep + : MissionHop3ApprovalStep) + : IsCallChainPending ? (Steps.Count <= CallChainHop1PollStep ? CallChainHop1ApprovalStep : CallChainHop2ApprovalStep) : 7; /// The step number at which polling occurs in deferred mode. public int PollStepNumber => - IsCallChainPending + IsMissionMode + ? (Steps.Count <= MissionHop1PollStep ? MissionHop1PollStep + : Steps.Count <= MissionHop2PollStep ? MissionHop2PollStep + : MissionHop3PollStep) + : IsCallChainPending ? (Steps.Count <= CallChainHop1PollStep ? CallChainHop1PollStep : CallChainHop2PollStep) : 8; @@ -337,6 +407,16 @@ public IReadOnlyList Plan private const int CallChainHop2ApprovalStep = 11; private const int CallChainHop2PollStep = 12; + // Mission consent path step numbers: cycle 1 (mission creation, steps 4/5), + // cycle 2 (out-of-mission elevated scope token, steps 12/13), and cycle 3 + // (out-of-scope delete_inbox permission, steps 18/19). + private const int MissionHop1ApprovalStep = 4; + private const int MissionHop1PollStep = 5; + private const int MissionHop2ApprovalStep = 12; + private const int MissionHop2PollStep = 13; + private const int MissionHop3ApprovalStep = 18; + private const int MissionHop3PollStep = 19; + /// /// The actor the current poll loop targets: the Person Server for the /// three-party / federated / call-chain hop-1 polls, or the Orchestrator @@ -352,7 +432,7 @@ public IReadOnlyList Plan /// and the UI should expose the "Approve as user" action button. /// public bool AwaitingUserApproval => - (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending) + (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode) && Steps.Count + 1 == UserApprovalStepNumber && !_userApproved; /// The user-facing interaction URL captured during step 7 (deferred only). @@ -431,6 +511,13 @@ private void ResetTimeline() _federatedPending = false; _callChainPending = false; _aborted = false; + _missionApprover = null; + _missionS256 = null; + _missionDescription = null; + _missionApprovedToolCount = 0; + _missionResponseBody = null; + _missionEndpoint = null; + _permissionEndpoint = null; } /// @@ -627,6 +714,62 @@ public async Task RunNextAsync(CancellationToken ct = default) case 7: StepFederatedInspectResult(); break; } } + else if (IsMissionMode) + { + switch (nextStep) + { + // Cycle 1 — mission creation (gate 1 PROMPT) → silent token (gate 2). + case 1: await StepMissionDiscoverPersonAsync(ct); break; + case 2: await StepMissionProposeAsync(ct); break; + case 3: StepDirectUserToInteraction(); break; + case 4: StepUserApprovesPlaceholder(); break; + case 5: + if (_pollingTask is { } mCreate && !mCreate.IsCompleted) + { + await mCreate.ConfigureAwait(false); + } + else if (Steps.Count + 1 == PollStepNumber) + { + await StepMissionPollCreateAsync(ct); + } + break; + case 6: await StepMissionResourceChallengeAsync(ct); break; + case 7: await StepMissionExchangeAsync(ct); break; + case 8: await StepMissionReplayAsync(ct); break; + // Cycle 2 — out-of-mission elevated scope (gate 3 PROMPT). + case 9: await StepMissionElevatedChallengeAsync(ct); break; + case 10: await StepMissionElevatedExchangeAsync(ct); break; + case 11: StepDirectUserToInteraction(); break; + case 12: StepUserApprovesPlaceholder(); break; + case 13: + if (_pollingTask is { } mElev && !mElev.IsCompleted) + { + await mElev.ConfigureAwait(false); + } + else if (Steps.Count + 1 == PollStepNumber) + { + await StepMissionElevatedPollAsync(ct); + } + break; + case 14: await StepMissionElevatedReplayAsync(ct); break; + // Cycle 3 — pre-approved tool (gate 4) → out-of-scope tool (gate 5 PROMPT). + case 15: StepMissionPreApprovedTool(); break; + case 16: await StepMissionPermissionPromptAsync(ct); break; + case 17: StepDirectUserToInteraction(); break; + case 18: StepUserApprovesPlaceholder(); break; + case 19: + if (_pollingTask is { } mPerm && !mPerm.IsCompleted) + { + await mPerm.ConfigureAwait(false); + } + else if (Steps.Count + 1 == PollStepNumber) + { + await StepMissionPollPermissionAsync(ct); + } + break; + case 20: StepMissionInspectResult(); break; + } + } else { switch (nextStep) @@ -685,6 +828,32 @@ await adminClient.PostAsJsonAsync( } } + /// + /// Re-mints with a fresh `jti` (the + /// generates a new token id on each + /// Build()). The resource server enforces replay detection per + /// signed request, so a single long-lived agent token cannot be reused + /// across two separate signed requests to the same resource. The mission + /// flow hits the resource twice (the `/jwt/mission` and + /// `/jwt/mission/elevated` challenges), so each challenge must present a + /// distinct agent token — exactly as a real agent would refresh its token + /// per access (spec §Agent Token, replay protection). + /// + private void RefreshAgentToken() + { + var personServer = IsIdentityMode || string.IsNullOrWhiteSpace(_options.PersonServerUrl) + ? null + : _options.PersonServerUrl; + _agentToken = new AgentTokenBuilder + { + Issuer = _selfIdentity.Issuer, + Subject = _options.AgentId, + KeyId = _selfIdentity.KeyId, + Key = _selfIdentity.Key, + PersonServer = personServer, + }.Build(); + } + /// /// Records the user-approval step: the user opened the PS's interaction page in a /// separate browser tab and (hopefully) clicked Approve. The Guided @@ -694,7 +863,7 @@ await adminClient.PostAsJsonAsync( /// public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default) { - if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending)) { return Task.CompletedTask; } + if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode)) { return Task.CompletedTask; } if (Steps.Count + 1 != UserApprovalStepNumber) { throw new InvalidOperationException( @@ -734,6 +903,58 @@ public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default) return Task.CompletedTask; } + if (IsMissionMode) + { + var hopStep = Steps.Count + 1; + var isCreation = hopStep == MissionHop1ApprovalStep; + var isElevated = hopStep == MissionHop2ApprovalStep; + var title = isCreation + ? "User approves the mission at the PS" + : isElevated + ? "User approves the elevated scope at the PS" + : "User approves delete_inbox at the PS"; + var narrative = isCreation + ? "The tour opened the PS's mission-approval page in a new browser tab. " + + "The Person Server rendered its consent screen showing the proposed " + + "**mission** description and the tools it may use. The user clicked " + + "**Approve**, and the PS recorded the durable mission via " + + "`POST /interaction/approve`. This is the single most important " + + "consent in the model: every later request is checked against this " + + "mission. The agent discovers the signed approval blob on its next poll." + : isElevated + ? "The tour opened the PS's consent page in a new browser tab. The " + + "Person Server showed that the agent is requesting the elevated " + + "**whoami:elevated_scope** \u2014 a scope that falls **outside** the " + + "mission's natural-language intent, so it could not be granted " + + "silently. The user clicked **Approve**, and the PS recorded the " + + "consent against the mission via `POST /interaction/approve`; the " + + "decision now accrues to the mission, so the agent may reuse this " + + "scope for the rest of the session. The agent learns the verdict on " + + "its next poll. (A **Deny** here yields `access_denied`.)" + : "The tour opened the PS's permission page in a new browser tab. The " + + "Person Server showed that the agent wants to run **delete_inbox** \u2014 " + + "an action that is **not** among the mission's pre-approved tools \u2014 " + + "under the existing mission. The user clicked **Approve**, and the PS " + + "recorded the decision against the mission log via " + + "`POST /interaction/approve`. The agent learns the verdict on its next poll. " + + "Note: this returns a *decision*, not a token \u2014 the gate-2 auth token is unaffected."; + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = title, + From = Actor.PersonServer, + To = Actor.PersonServer, + Narrative = narrative, + ResponseBody = userUrl, + TokenDecoded = + $"Interaction URL opened in new tab:\n {userUrl}\n\n" + + "User performed (browser → PS):\n" + + $" GET /interaction?code={_interactionCode}\n" + + $" POST /interaction/approve (form: code={_interactionCode})", + }); + return Task.CompletedTask; + } + Steps.Add(new StepRecord { Number = Steps.Count + 1, @@ -791,6 +1012,34 @@ public async Task PrepareConsentStateAsync(CancellationToken ct = default) return; } + // Mission mode: reset all PS state, then script the consent screen to + // be interactive (browser-driven) and seed the in-scope (resource, + // whoami) pair so gate 2 is silent. Mission creation + the out-of-scope + // delete_inbox both surface a real user approval (§Missions). Matches + // the SampleApp Mission page's ConfigurePersonServerAsync script. + if (IsMissionMode) + { + var ps = _options.PersonServerUrl!.TrimEnd('/'); + try + { + await client.PostAsync($"{ps}/admin/reset", null, ct); + await client.PostAsJsonAsync($"{ps}/admin/mission-script", new + { + reset = true, + interactive = true, + approveMission = true, + approveToken = true, + approvePermission = true, + inScope = new[] + { + new { resource = _options.WhoAmIUrl.TrimEnd('/'), scope = "whoami" }, + }, + }, ct); + } + catch { /* /admin/* only exists on MockPersonServer — swallow. */ } + return; + } + var endpoint = IsDeferredMode ? "/admin/revoke" : "/admin/consent"; var url = $"{_options.PersonServerUrl!.TrimEnd('/')}{endpoint}"; try @@ -1444,7 +1693,7 @@ private async Task RunPendingPollAsync( /// public Task StartPendingPollAsync() { - if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending) || _pendingUrl is null) + if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode) || _pendingUrl is null) { return Task.CompletedTask; } @@ -1460,6 +1709,13 @@ public Task StartPendingPollAsync() // URL with the agent token. var hop2 = IsCallChainPending && Steps.Count + 1 == CallChainHop2PollStep; + // Mission mode has three distinct poll cycles: cycle 1 returns the mission + // approval blob (step 5), cycle 2 returns the elevated auth_token (step 13), + // cycle 3 returns the permission decision (step 19). + var missionCreatePoll = IsMissionMode && Steps.Count + 1 == MissionHop1PollStep; + var missionElevatedPoll = IsMissionMode && Steps.Count + 1 == MissionHop2PollStep; + var missionPermissionPoll = IsMissionMode && Steps.Count + 1 == MissionHop3PollStep; + // Serialize the check-then-assign so two near-simultaneous UI // events (e.g. "Open consent" + "Simulate deny") can't both kick // off a poll. Blazor Server's circuit context already serializes @@ -1478,7 +1734,13 @@ public Task StartPendingPollAsync() { try { - await (hop2 ? StepCallChainPollHop2Async(ct) : StepPollPendingAsync(ct)).ConfigureAwait(false); + var poll = + missionCreatePoll ? StepMissionPollCreateAsync(ct) + : missionElevatedPoll ? StepMissionElevatedPollAsync(ct) + : missionPermissionPoll ? StepMissionPollPermissionAsync(ct) + : hop2 ? StepCallChainPollHop2Async(ct) + : StepPollPendingAsync(ct); + await poll.ConfigureAwait(false); } catch (OperationCanceledException) { @@ -2410,6 +2672,631 @@ private void StepFederatedInspectResult() }); } + // ----------------------------------------------------------------- + // Mission-governed (PS-as-policy) step implementations + // ----------------------------------------------------------------- + + private async Task StepMissionDiscoverPersonAsync(CancellationToken ct) + { + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + using var client = new HttpClient(capture); + var url = $"{_options.PersonServerUrl!.TrimEnd('/')}/.well-known/aauth-person.json"; + await client.GetAsync(url, ct); + var ex = capture.Last!; + + var meta = JsonNode.Parse(ex.ResponseBody); + _tokenEndpoint = (string?)meta?["token_endpoint"]; + _missionEndpoint = (string?)meta?["mission_endpoint"] + ?? $"{_options.PersonServerUrl!.TrimEnd('/')}/mission"; + _permissionEndpoint = (string?)meta?["permission_endpoint"] + ?? $"{_options.PersonServerUrl!.TrimEnd('/')}/permission"; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Discover Person Server metadata", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "Unsigned discovery to the Person Server announces its governance " + + "endpoints: the `mission_endpoint` the agent proposes the mission to, " + + "the `token_endpoint` for the in-scope token exchange, and the " + + "`permission_endpoint` for per-action checks. In the mission model the " + + "PS is the **policy-enforcement point** — every one of these endpoints " + + "is governed by the mission the user approves next.", + RequestLine = $"{ex.RequestLine} → {url}", + RequestHeaders = ex.RequestHeaders, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionDiscoverPs, + }); + } + + private async Task StepMissionProposeAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + // The proposal: a durable mission description + the tools the agent + // wants pre-approved. send_email is in the proposal (so gate 3 is + // silent later); delete_inbox is NOT (so gate 4 prompts). These are + // local tools — whoami is a resource scope and is handled separately + // by the in-scope token exchange at gate 2, not as a tool. + using var resp = await client.PostAsJsonAsync(_missionEndpoint!, new + { + description = "Triage the user's inbox: summarize unread mail and send routine replies.", + tools = new[] + { + new { name = "summarize", description = "Summarize an email thread." }, + new { name = "send_email", description = "Send a routine reply on the user's behalf." }, + }, + }, ct); + + var ex = capture.Last!; + CaptureInteractionFrom(resp, _missionEndpoint!); + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Propose mission → 202 (user approval required)", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent signs a `POST /mission` with its agent token (`sig=jwt`, MUST " + + "per spec) carrying the proposed mission description and the local tools it " + + "wants pre-approved (`summarize`, `send_email`). Mission approval is the " + + "**most important consent in the model**, so this PS parks the proposal " + + "and returns `202 Accepted` + a `Location` (the mission-pending URL) and " + + "an `AAuth-Requirement: requirement=interaction` header pointing the user " + + "at the consent screen. `delete_inbox` is deliberately **not** proposed — " + + "you will see it prompt separately at gate 4.", + RequestLine = $"{ex.RequestLine} → {_missionEndpoint}", + RequestHeaders = ex.RequestHeaders, + RequestBody = PrettyJson(ex.RequestBody), + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionPropose, + }); + } + + private Task StepMissionPollCreateAsync(CancellationToken ct) => + RunPendingPollAsync(ct, () => _agentToken!, Actor.Agent, Actor.PersonServer, (last, capturedBase) => + { + // The mission-create poll returns the verbatim approval blob bytes + // (not an auth_token). Parse it to surface the mission identity. + _missionResponseBody = last.ResponseBody; + try + { + var mission = Mission.FromApprovalBytes( + System.Text.Encoding.UTF8.GetBytes(last.ResponseBody)); + _missionApprover = mission.Approver; + _missionS256 = mission.S256; + _missionDescription = mission.Description; + _missionApprovedToolCount = mission.ApprovedTools.Count; + } + catch { /* malformed blob — leave fields null, step still shows raw body */ } + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Poll → 200 mission approval blob", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "While the user approves on the PS screen, the agent polls the " + + "mission-pending URL with a signed `GET`. Once the mission is " + + "approved the PS returns `200 OK` with the **verbatim approval blob** " + + "(stored byte-for-byte) plus an `AAuth-Mission` header carrying the " + + "`s256` thumbprint. The agent verifies `s256 == base64url(SHA-256(" + + "blob))` and now holds a durable mission it can bind to later requests. " + + "If the user clicks **Deny**, this step records `403 access_denied`.", + RequestLine = $"{last.RequestLine} → {_pendingUrl}", + RequestHeaders = last.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = last.StatusLine, + ResponseHeaders = last.ResponseHeaders, + ResponseBody = PrettyJson(last.ResponseBody), + TokenDecoded = _missionS256 is null + ? null + : $"Mission identity:\n approver: {_missionApprover}\n s256: {_missionS256}\n" + + $" tools: {_missionApprovedToolCount} pre-approved", + CodeSnippet = CodeSnippets.MissionPollCreate, + }); + }); + + private async Task StepMissionResourceChallengeAsync(CancellationToken ct) + { + // Fresh agent token (new jti) so the resource's replay detection does + // not reject this signed request if the agent token was used earlier. + RefreshAgentToken(); + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var req = new HttpRequestMessage(HttpMethod.Get, MissionResourceUrl); + // The agent advertises the mission it is acting under so the resource + // copies the {approver, s256} claim into the resource_token it mints. + if (_missionApprover is not null && _missionS256 is not null) + { + req.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(_missionApprover, _missionS256)); + } + using var resp = await client.SendAsync(req, ct); + var ex = capture.Last!; + + if (resp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals)) + { + foreach (var raw in reqVals) + { + if (string.IsNullOrWhiteSpace(raw)) { continue; } + try + { + _resourceToken = AAuthRequirementHeader.Parse(raw).ResourceToken; + if (_resourceToken is not null) { break; } + } + catch (FormatException) { /* try the next header value */ } + } + } + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Signed GET /jwt/mission → 401", + From = Actor.Agent, + To = Actor.Resource, + Narrative = + "The agent makes its first signed request to the resource's " + + "mission-aware endpoint, advertising the mission it acts under via the " + + "`AAuth-Mission` header (`approver` + `s256`). The resource verifies the " + + "signature, then mints a `resource_token` that **copies the mission " + + "claim into it**, and challenges with `401` + `AAuth-Requirement`. The " + + "mission now travels with the token to the PS — the resource itself " + + "stays oblivious to the user's policy.", + RequestLine = $"{ex.RequestLine} → {MissionResourceUrl}", + RequestHeaders = ex.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + TokenJwt = _resourceToken, + TokenHeader = DecodeJwt(_resourceToken)?.Header, + TokenPayload = DecodeJwt(_resourceToken)?.Payload, + CodeSnippet = CodeSnippets.MissionChallenge, + }); + } + + private async Task StepMissionExchangeAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var resp = await client.PostAsJsonAsync(_tokenEndpoint!, new + { + resource_token = _resourceToken, + }, ct); + + var ex = capture.Last!; + var body = JsonNode.Parse(ex.ResponseBody); + _authToken = (string?)body?["auth_token"]; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Exchange → 200 auth_token (SILENT, in-scope)", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent POSTs the `resource_token` to the `token_endpoint`. Because " + + "the token carries the mission claim, the PS evaluates it as " + + "**gate 2**: the requested `(resource, whoami)` pair is within the " + + "mission's approved scope, so the PS mints the `auth_token` **silently** " + + "— no user prompt. This is the heart of the mission model: the up-front " + + "mission approval lets in-scope work proceed without interrupting the " + + "user. The token records `mission.{approver, s256}` for audit, but " + + "never the tool list.", + RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}", + RequestHeaders = ex.RequestHeaders, + RequestBody = PrettyJson(ex.RequestBody), + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + TokenJwt = _authToken, + TokenHeader = DecodeJwt(_authToken)?.Header, + TokenPayload = DecodeJwt(_authToken)?.Payload, + CodeSnippet = CodeSnippets.MissionExchange, + }); + } + + private async Task StepMissionReplayAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _authToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + await client.GetAsync(MissionResourceUrl, ct); + var ex = capture.Last!; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Replay GET /jwt/mission → 200", + From = Actor.Agent, + To = Actor.Resource, + Narrative = + "The agent replays the request, now carrying the `auth_token` in the " + + "`Signature-Key` header. The resource verifies it, confirms `cnf.jwk` " + + "matches the signer, and returns `200` with the protected claims — and " + + "the `mission` binding round-tripped, proving the access was governed.", + RequestLine = $"{ex.RequestLine} → {MissionResourceUrl}", + RequestHeaders = ex.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionReplay, + }); + } + + private async Task StepMissionElevatedChallengeAsync(CancellationToken ct) + { + // Fresh agent token (new jti): the resource already recorded the agent + // token used for the /jwt/mission challenge, so reusing it here would + // trip replay detection and return a bare 401 (no resource_token). + RefreshAgentToken(); + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var req = new HttpRequestMessage(HttpMethod.Get, MissionElevatedResourceUrl); + if (_missionApprover is not null && _missionS256 is not null) + { + req.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(_missionApprover, _missionS256)); + } + using var resp = await client.SendAsync(req, ct); + var ex = capture.Last!; + + if (resp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals)) + { + foreach (var raw in reqVals) + { + if (string.IsNullOrWhiteSpace(raw)) { continue; } + try + { + _resourceToken = AAuthRequirementHeader.Parse(raw).ResourceToken; + if (_resourceToken is not null) { break; } + } + catch (FormatException) { /* try the next header value */ } + } + } + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Signed GET /jwt/mission/elevated → 401", + From = Actor.Agent, + To = Actor.Resource, + Narrative = + "The agent now needs more than basic profile data — it requests the " + + "resource's **elevated** endpoint, which is protected by " + + "`whoami:elevated_scope`. As before it advertises the mission via the " + + "`AAuth-Mission` header; the resource copies the mission claim into a " + + "fresh `resource_token` and challenges with `401`. The resource does not " + + "judge the scope against the mission — that is the PS's job at the next step.", + RequestLine = $"{ex.RequestLine} → {MissionElevatedResourceUrl}", + RequestHeaders = ex.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + TokenJwt = _resourceToken, + TokenHeader = DecodeJwt(_resourceToken)?.Header, + TokenPayload = DecodeJwt(_resourceToken)?.Payload, + CodeSnippet = CodeSnippets.MissionElevatedChallenge, + }); + } + + private async Task StepMissionElevatedExchangeAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var resp = await client.PostAsJsonAsync(_tokenEndpoint!, new + { + resource_token = _resourceToken, + }, ct); + + var ex = capture.Last!; + if (resp.StatusCode == HttpStatusCode.Accepted) + { + _userApproved = false; // a fresh user approval is required for this gate + CaptureInteractionFrom(resp, _tokenEndpoint!); + } + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Exchange → 202 (PROMPT, out of mission scope)", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent POSTs the elevated `resource_token` to the `token_endpoint`. " + + "The PS evaluates the requested `whoami:elevated_scope` against the " + + "mission's natural-language intent (\"triage the inbox\") — it does **not** " + + "fit. Unlike gate 2, the PS cannot mint silently: out-of-mission scopes " + + "are **not** auto-denied, so it parks the request and returns `202` + an " + + "interaction URL for the user to decide (gate 3). Only an explicit user " + + "**Deny** would yield `access_denied`.", + RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}", + RequestHeaders = ex.RequestHeaders, + RequestBody = PrettyJson(ex.RequestBody), + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionElevatedExchange, + }); + } + + private Task StepMissionElevatedPollAsync(CancellationToken ct) => + RunPendingPollAsync(ct, () => _agentToken!, Actor.Agent, Actor.PersonServer, (last, capturedBase) => + { + var body = JsonNode.Parse(last.ResponseBody); + _authToken = (string?)body?["auth_token"]; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Poll → 200 auth_token (elevated)", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent polls the token-pending URL with a signed `GET`. Once the " + + "user approves the elevated scope, the PS returns `200` with the " + + "`auth_token` carrying `whoami:elevated_scope`, bound to the agent's " + + "signing key. The consent now accrues to the mission, so a later " + + "elevated request would be silent. A **Deny** here records " + + "`403 access_denied`.", + RequestLine = $"{last.RequestLine} → {_pendingUrl}", + RequestHeaders = last.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = last.StatusLine, + ResponseHeaders = last.ResponseHeaders, + ResponseBody = PrettyJson(last.ResponseBody), + TokenJwt = _authToken, + TokenHeader = DecodeJwt(_authToken)?.Header, + TokenPayload = DecodeJwt(_authToken)?.Payload, + CodeSnippet = CodeSnippets.MissionElevatedPoll, + }); + }); + + private async Task StepMissionElevatedReplayAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _authToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + await client.GetAsync(MissionElevatedResourceUrl, ct); + var ex = capture.Last!; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Replay GET /jwt/mission/elevated → 200", + From = Actor.Agent, + To = Actor.Resource, + Narrative = + "The agent replays the elevated request, now carrying the elevated " + + "`auth_token`. The resource verifies it, confirms `whoami:elevated_scope`, " + + "and returns `200` with the protected claims — the out-of-mission scope " + + "is now governed by the consent the user just gave.", + RequestLine = $"{ex.RequestLine} → {MissionElevatedResourceUrl}", + RequestHeaders = ex.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionElevatedReplay, + }); + } + + private void StepMissionPreApprovedTool() + { + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Permission: send_email (SILENT — resolved locally)", + From = Actor.Agent, + To = Actor.Agent, + Narrative = + "Before running a tool the agent checks it against the mission. " + + "`send_email` **is** one of the mission's pre-approved tools, so the " + + "agent's `PermissionClient` short-circuits to *granted* **without any " + + "PS round-trip** (gate 4). Pre-approving routine tools at mission " + + "creation is exactly what keeps the agent fast: only out-of-scope " + + "actions reach the PS. (The agent still SHOULD report the action to the " + + "`audit_endpoint` afterwards, but that is fire-and-forget, not a gate.)", + TokenDecoded = + "PermissionClient.RequestAsync(ps, \"send_email\", mission)\n" + + " → mission.ApprovedTools contains \"send_email\"\n" + + " → PermissionResult { Grant = Granted } (no HTTP)", + CodeSnippet = CodeSnippets.MissionPreApproved, + }); + } + + private async Task StepMissionPermissionPromptAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var resp = await client.PostAsJsonAsync(_permissionEndpoint!, new + { + action = "delete_inbox", + mission = new { approver = _missionApprover, s256 = _missionS256 }, + }, ct); + + var ex = capture.Last!; + if (resp.StatusCode == HttpStatusCode.Accepted) + { + _userApproved = false; // a fresh user approval is required for this gate + CaptureInteractionFrom(resp, _permissionEndpoint!); + } + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Permission: delete_inbox → 202 (user approval required)", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent now wants to run `delete_inbox` — a destructive action that " + + "was **not** pre-approved at mission creation. It signs a " + + "`POST /permission` with `{ action, mission }`. The PS evaluates " + + "**gate 5**: the action is out of scope, so it parks the request and " + + "returns `202` + an interaction URL for the user to decide. Crucially " + + "this endpoint returns a **decision**, not a token — whatever the user " + + "chooses, the gate-2 `auth_token` is unaffected.", + RequestLine = $"{ex.RequestLine} → {_permissionEndpoint}", + RequestHeaders = ex.RequestHeaders, + RequestBody = PrettyJson(ex.RequestBody), + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionPermissionPrompt, + }); + } + + private Task StepMissionPollPermissionAsync(CancellationToken ct) => + RunPendingPollAsync(ct, () => _agentToken!, Actor.Agent, Actor.PersonServer, (last, capturedBase) => + { + var body = JsonNode.Parse(last.ResponseBody) as JsonObject; + var permission = (string?)body?["permission"]; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = $"Poll → 200 permission {permission ?? "decided"}", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent polls the permission-pending URL with a signed `GET`. " + + "Once the user decides, the PS returns `200` with " + + "`{ permission: \"granted\" | \"denied\" }` and records the outcome " + + "in the mission log for audit. A **decision**, not a credential: the " + + "agent already holds its in-scope token; this only governs whether it " + + "may take the out-of-scope action. A **Deny** here surfaces as " + + "`{ permission: \"denied\" }` (the SDK raises " + + "`AAuthInteractionDeniedException`), and the gate-2 token still works " + + "for in-scope reads.", + RequestLine = $"{last.RequestLine} → {_pendingUrl}", + RequestHeaders = last.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = last.StatusLine, + ResponseHeaders = last.ResponseHeaders, + ResponseBody = PrettyJson(last.ResponseBody), + CodeSnippet = CodeSnippets.MissionPollPermission, + }); + }); + + private void StepMissionInspectResult() + { + var summary = new System.Text.StringBuilder(); + summary.AppendLine("═══ Mission-governed Summary ═══"); + summary.AppendLine(); + summary.AppendLine($" Mission: {_missionDescription}"); + summary.AppendLine($" approver: {_missionApprover}"); + summary.AppendLine($" s256: {_missionS256}"); + summary.AppendLine($" tools: {_missionApprovedToolCount} pre-approved (summarize, send_email)"); + summary.AppendLine(); + summary.AppendLine(" Gate 1 — mission creation .... PROMPT (durable consent)"); + summary.AppendLine(" Gate 2 — whoami token ........ SILENT (in mission scope)"); + summary.AppendLine(" Gate 3 — elevated scope ...... PROMPT (out of mission scope)"); + summary.AppendLine(" Gate 4 — send_email tool ..... SILENT (pre-approved, local)"); + summary.AppendLine(" Gate 5 — delete_inbox action . PROMPT (out of scope)"); + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Inspect mission result", + From = Actor.Agent, + To = Actor.Agent, + Narrative = + "One durable mission approval governed the whole session. The PS acted " + + "as the **policy-enforcement point**: it prompted only when a request " + + "fell outside the mission (creating the mission, the elevated scope, and " + + "the out-of-scope `delete_inbox`), and stayed silent for the in-scope " + + "token and the pre-approved tool. This is the mission " + + "model's promise — front-load the user's consent into a single " + + "reviewable mission, then let in-scope work flow without friction while " + + "still gating anything outside it (§Missions, §Scopes, §Permission Endpoint).", + TokenDecoded = summary.ToString(), + CodeSnippet = CodeSnippets.MissionInspect, + }); + } + + /// + /// Capture the pending URL + interaction (URL + single-use code) from a + /// mission/permission `202 Accepted` response so the user-approval and + /// poll steps can drive the deferred cycle. Shared by the mission-create + /// (step 2) and permission-prompt (step 10) gates. + /// + private void CaptureInteractionFrom(HttpResponseMessage resp, string baseUrl) + { + var location = resp.Headers.Location?.ToString(); + if (location is not null) + { + _pendingUrl = location.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? location + : $"{_options.PersonServerUrl!.TrimEnd('/')}{location}"; + } + + if (resp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals)) + { + foreach (var raw in reqVals) + { + if (string.IsNullOrWhiteSpace(raw)) { continue; } + try + { + var parsed = AAuthRequirementHeader.Parse(raw); + var interaction = AAuth.Headers.Interaction.FromRequirement(parsed); + if (interaction is not null) + { + _interactionUrl = interaction.Url; + _interactionCode = interaction.Code; + break; + } + } + catch (FormatException) { /* try the next header value */ } + } + } + } + // ----------------------------------------------------------------- // Helpers // ----------------------------------------------------------------- diff --git a/samples/GuidedTour/playwright-tests/mission.spec.ts b/samples/GuidedTour/playwright-tests/mission.spec.ts new file mode 100644 index 0000000..46ecc65 --- /dev/null +++ b/samples/GuidedTour/playwright-tests/mission.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from '../../../tests/e2e/helpers/fixtures'; +import { + openTour, + selectFlow, + runAll, + selectStep, + expectResponse, + readResponseJson, + doneSteps, + TourMode, +} from '../../../tests/e2e/helpers/tour'; +import { approveInPopup, denyInPopup } from '../../../tests/e2e/helpers/consent'; +import { Agents, Urls } from '../../../tests/e2e/helpers/agents'; + +/** + * Mission (PS-Governed) — the Person Server acts as the policy-enforcement + * point for a durable, human-approved mission, 20 steps across three consent + * cycles. On flow selection the tour seeds the PS for an interactive run with + * the `whoami` scope in-scope (so the first token gate is silent), while every + * out-of-mission request surfaces its own PS consent page: + * + * 1. Mission creation (steps 4/5): the user approves the durable mission + + * its tools; the agent polls for the signed approval blob. + * 2. Out-of-mission elevated scope (steps 12/13): requesting + * `whoami:elevated_scope` falls outside the mission's intent, so the PS + * prompts before issuing the elevated auth_token (gate 3). + * 3. Out-of-scope delete_inbox (steps 18/19): a tool that is NOT pre-approved + * prompts the user; the PS returns a decision, not a token. + * + * In between, the in-scope `whoami` token (gate 2) and the pre-approved + * send_email tool (gate 4) resolve silently. Generous timeout covers three + * poll loops. + */ +test.describe('Mission (Guided Tour)', () => { + test.describe.configure({ timeout: 240_000 }); + + test('three approvals govern the full mission lifecycle to a 200', async ({ page, context }) => { + await openTour(page); + await selectFlow(page, TourMode.Mission); + + // ---- Cycle 1: mission creation (PROMPT) ------------------------------ + await runAll(page); + // Parked on the mission-approval step (3 done: discover, propose, direct-user). + await expect(doneSteps(page)).toHaveCount(3); + const createLink = page.locator('a.primary.approve'); + await expect(createLink).toBeVisible(); + const [createPopup] = await Promise.all([ + context.waitForEvent('page'), + createLink.click(), + ]); + await approveInPopup(createPopup); + // user-approval + create poll resolve (5 of 20). + await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 }); + + // ---- Silent gate 2 token + cycle 2: elevated scope (PROMPT) ---------- + await runAll(page); + // Steps 6 (challenge), 7 (exchange SILENT), 8 (replay), 9 (elevated + // challenge), 10 (elevated exchange → 202), 11 (direct-user) run, parking + // on the elevated-scope approval (11 done). + await expect(doneSteps(page)).toHaveCount(11, { timeout: 60_000 }); + const elevatedLink = page.locator('a.primary.approve'); + await expect(elevatedLink).toBeVisible(); + const [elevatedPopup] = await Promise.all([ + context.waitForEvent('page'), + elevatedLink.click(), + ]); + await approveInPopup(elevatedPopup); + // user-approval + elevated poll resolve (13 of 20). + await expect(doneSteps(page)).toHaveCount(13, { timeout: 120_000 }); + + // ---- Silent gate 4 tool + cycle 3: delete_inbox (PROMPT) ------------- + await runAll(page); + // Steps 14 (elevated replay), 15 (send_email SILENT), 16 (permission → + // 202), 17 (direct-user) run, parking on the delete_inbox approval (17). + await expect(doneSteps(page)).toHaveCount(17, { timeout: 60_000 }); + const deleteLink = page.locator('a.primary.approve'); + await expect(deleteLink).toBeVisible(); + const [deletePopup] = await Promise.all([ + context.waitForEvent('page'), + deleteLink.click(), + ]); + await approveInPopup(deletePopup); + // user-approval + permission poll resolve (19 of 20). + await expect(doneSteps(page)).toHaveCount(19, { timeout: 120_000 }); + + // ---- Final inspect step still needs an explicit "Run all" ------------ + await runAll(page); + await expect(doneSteps(page)).toHaveCount(20, { timeout: 30_000 }); + + // Step 8 ("Replay GET /jwt/mission → 200") is the in-scope resource result. + await selectStep(page, 7); + await expectResponse(page, 200, ['mission']); + const inScope = (await readResponseJson(page)) as Record; + expect(inScope.access).toBe('mission'); + expect(inScope.scope).toEqual(['whoami']); + expect(inScope.iss).toBe(Urls.personServer); + expect(inScope.agent).toBe(Agents.tour); + + // Step 14 ("Replay GET /jwt/mission/elevated → 200") is the elevated result. + await selectStep(page, 13); + await expectResponse(page, 200, ['mission-elevated']); + const elevated = (await readResponseJson(page)) as Record; + expect(elevated.access).toBe('mission-elevated'); + expect(elevated.scope).toEqual(['whoami:elevated_scope']); + }); + + test('deny at the elevated-scope gate yields access_denied', async ({ page, context }) => { + await openTour(page); + await selectFlow(page, TourMode.Mission); + + // Cycle 1: approve the mission. + await runAll(page); + const createLink = page.locator('a.primary.approve'); + await expect(createLink).toBeVisible(); + const [createPopup] = await Promise.all([ + context.waitForEvent('page'), + createLink.click(), + ]); + await approveInPopup(createPopup); + await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 }); + + // Advance to the elevated-scope gate and DENY it. + await runAll(page); + const elevatedLink = page.locator('a.primary.approve'); + await expect(elevatedLink).toBeVisible(); + const [elevatedPopup] = await Promise.all([ + context.waitForEvent('page'), + elevatedLink.click(), + ]); + await denyInPopup(elevatedPopup); + + // The flow aborts: the primary button locks to "Aborted" and the poll loop + // records a terminal denied step (403 access_denied). + await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 }); + await expect(doneSteps(page).last()).toContainText(/denied/i); + }); +}); diff --git a/samples/GuidedTour/playwright-tests/picker.spec.ts b/samples/GuidedTour/playwright-tests/picker.spec.ts index 8d6efd9..78a5291 100644 --- a/samples/GuidedTour/playwright-tests/picker.spec.ts +++ b/samples/GuidedTour/playwright-tests/picker.spec.ts @@ -2,16 +2,16 @@ import { test, expect } from '../../../tests/e2e/helpers/fixtures'; import { openTour } from '../../../tests/e2e/helpers/tour'; /** - * Flow picker structure: all six flows are offered, the signing-mode picker is + * Flow picker structure: all seven flows are offered, the signing-mode picker is * Identity-only, and the description text reacts to the selected flow. This is a * UI-structure spec (no protocol result), guarding the entry point every other * spec depends on. */ -test('flow picker offers all six flows and reacts to selection', async ({ page }) => { +test('flow picker offers all seven flows and reacts to selection', async ({ page }) => { await openTour(page); const flow = page.locator('select#flow-select'); - await expect(flow.locator('option')).toHaveCount(6); + await expect(flow.locator('option')).toHaveCount(7); await expect(flow.locator('option')).toContainText([ 'Bootstrap', 'Identity-based', @@ -19,16 +19,24 @@ test('flow picker offers all six flows and reacts to selection', async ({ page } 'PS-Asserted (Deferred)', 'Call Chain', 'Federated (Four-Party)', + 'Mission (PS-Governed)', ]); // Signing-mode picker only appears for the Identity flow. await expect(page.locator('select#signing-mode-select')).toHaveCount(0); - await flow.selectOption('Identity'); - await expect(page.locator('select#signing-mode-select')).toBeVisible(); + // The " + "" diff --git a/samples/SampleApp/Components/Layout/NavMenu.razor b/samples/SampleApp/Components/Layout/NavMenu.razor index 2ac9f52..142cb56 100644 --- a/samples/SampleApp/Components/Layout/NavMenu.razor +++ b/samples/SampleApp/Components/Layout/NavMenu.razor @@ -55,6 +55,12 @@ Federated (Four-Party) + + diff --git a/samples/SampleApp/Components/Layout/NavMenu.razor.css b/samples/SampleApp/Components/Layout/NavMenu.razor.css index 4719e12..7d15a88 100644 --- a/samples/SampleApp/Components/Layout/NavMenu.razor.css +++ b/samples/SampleApp/Components/Layout/NavMenu.razor.css @@ -66,6 +66,10 @@ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M5.338 1.59a61 61 0 0 0-2.837.856.48.48 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.7 10.7 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.62.62 0 0 0 .206 0 .55.55 0 0 0 .1-.025q.114-.034.294-.118c.24-.113.547-.29.893-.533a10.7 10.7 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453C1.44 10.135.056 6.817.652 2.34A1.54 1.54 0 0 1 1.696 1.08c.658-.214 1.777-.57 2.887-.87z'/%3E%3Cpath d='M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99a1.5 1.5 0 1 1 2.5-1.415z'/%3E%3C/svg%3E"); } +.bi-bullseye-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E%3Cpath d='M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'/%3E%3Cpath d='M8 9a1 1 0 1 1 0-2 1 1 0 0 1 0 2z'/%3E%3C/svg%3E"); +} + .nav-item { font-size: 0.9rem; padding-bottom: 0.5rem; diff --git a/samples/SampleApp/Components/Pages/Home.razor b/samples/SampleApp/Components/Pages/Home.razor index 35524db..4ab7785 100644 --- a/samples/SampleApp/Components/Pages/Home.razor +++ b/samples/SampleApp/Components/Pages/Home.razor @@ -142,6 +142,26 @@ + +
+
+
+
🎯 Mission — PS-Governed Agent
+

+ A human-approved mission lets the Person Server govern + every step. One run drives all four gates: two resolve + silently (in-scope token, pre-approved tool), two + prompt the user (mission creation, an out-of-tool action). +

+ 3-party + sig=jwt + governance +
+ +
+

Prerequisites

diff --git a/samples/SampleApp/Components/Pages/Mission.razor b/samples/SampleApp/Components/Pages/Mission.razor new file mode 100644 index 0000000..01f5a74 --- /dev/null +++ b/samples/SampleApp/Components/Pages/Mission.razor @@ -0,0 +1,667 @@ +@page "/mission" +@using System.Net +@using System.Net.Http.Json +@using System.Text.Json.Nodes +@using AAuth.Agent +@using AAuth.Agent.Governance +@using AAuth.Crypto +@using AAuth.Discovery +@using AAuth.Headers +@using AAuth.HttpSig +@using AAuth.Tokens +@inject IJSRuntime JS +@rendermode InteractiveServer + +Mission — PS-Governed Agent + +

Mission — the Person Server governs every step

+ +

+ A mission is a durable, human-approved statement of intent plus the + tools the agent may use (§Missions). Once approved, the Person Server governs every + downstream token and permission request under that mission. This page + drives all five governance gates in one run and labels each as + prompt (the user must decide) or + silent (resolved without a screen). +

+ +
+ Tools are declared; scopes are evaluated. The mission proposal lists the + tools the agent may run locally, but it lists no scopes. When the agent + later requests a resource scope, the PS judges that request against the mission's + natural-language description — granting it silently if it fits the intent, otherwise + prompting the user (§Mission Creation, §Scopes). +
+ +
+
+
Client Code
+
// Self-issued agent token signs every governance call.
+var agentToken = new AgentTokenBuilder
+{
+    Issuer = identity.Issuer,
+    Subject = identity.AgentId,
+    KeyId = identity.KeyId,
+    Key = identity.Key,
+    PersonServer = personServer,
+}.Build();
+
+var handler = new AAuthSigningHandler(
+    identity.Key, () => agentToken)
+    { InnerHandler = new HttpClientHandler() };
+var signed = new HttpClient(handler);
+var governance = new AAuthGovernanceClient(signed, metadata);
+
+// 1. Propose a mission (PROMPT — the human approves intent + tools).
+//    A proposal carries a Markdown description + an optional `tools`
+//    list ONLY. It declares NO scopes — scopes are never pre-listed
+//    on a mission (§Mission Creation).
+var mission = await governance.Mission.ProposeAsync(
+    personServer,
+    new MissionProposal("Keep the inbox under control for an hour.")
+    {
+        Tools = new[]
+        {
+            new MissionTool("send_email", "Send email"),
+            new MissionTool("summarize", "Summarize a thread"),
+        },
+    },
+    governanceOptions);
+
+// 2. Access a mission-aware resource (SILENT — the PS judges the scope
+//    fits the mission intent). The client calls the resource directly;
+//    the SDK only mints the auth token. Standard challenge -> exchange
+//    -> retry:
+//
+//    a) GET the resource with the agent token + AAuth-Mission header.
+//       Having no auth token yet, the resource answers 401 and hands
+//       back a resource token (aud = the agent's PS) in AAuth-Requirement.
+var get = new HttpRequestMessage(HttpMethod.Get, $"{resourceOrigin}/protected_endpoint");
+get.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name,
+    AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256));
+using var challenge = await signed.SendAsync(get);            // 401 Unauthorized
+var resourceToken = AAuthRequirementHeader
+    .Parse(challenge.Headers.GetValues(AAuthRequirementHeader.Name).First())
+    .ResourceToken!;
+
+//    b) Exchange the resource token at the PS for an auth token. The
+//       mission named no scopes, so the PS evaluates the requested
+//       `whoami` scope against the mission's description; it fits the
+//       intent, so the PS grants it silently — no user prompt
+//       (§Scopes, §Agent Token Request, gate 2a).
+var authToken = await exchange.ExchangeAsync(personServer, resourceToken);
+
+//    c) Retry the SAME request, now signing with the auth token. 200 OK.
+var authed = new HttpClient(new AAuthSigningHandler(identity.Key, () => authToken)
+    { InnerHandler = new HttpClientHandler() });
+using var ok = await authed.GetAsync($"{resourceOrigin}/protected_endpoint");
+var who = await ok.Content.ReadFromJsonAsync<JsonObject>();   // the profile
+
+// 3. Access an ELEVATED scope (PROMPT — out of mission). The same
+//    challenge -> exchange -> retry, but against an endpoint requiring
+//    `whoami:elevated_scope`. The mission's intent does not cover this
+//    scope, so the PS cannot grant it silently: it prompts the user.
+//    A user deny throws AAuthInteractionDeniedException (access_denied);
+//    otherwise the consent accrues to the mission (§Scopes, gate 3).
+var elevated = await ExchangeForScopeAsync(
+    $"{resourceOrigin}/protected_endpoint/elevated", mission, governanceOptions);
+
+// 4. Permission for a pre-approved tool (SILENT — no PS call).
+var sendEmail = await governance.Permission.RequestAsync(
+    personServer, "send_email", mission);
+
+// 5. Permission for a non-approved tool (PROMPT — the PS asks).
+var deleteInbox = await governance.Permission.RequestAsync(
+    personServer,
+    new PermissionRequest("delete_inbox")
+        { Mission = new MissionClaim(mission.Approver, mission.S256) },
+    governanceOptions);
+
+
+
Resource — protecting the action with a scope
+
// Publish the scopes this resource offers so agents (and their
+// Person Servers) can discover them (§Resource Metadata).
+app.MapAAuthResourceWellKnown(new AAuthResourceMetadataOptions
+{
+    Issuer = resourceUrl,
+    SigningKeys = new() { [kid] = resourceKey },
+    ScopeDescriptions = new()
+    {
+        ["whoami"] = "See basic profile information",
+        ["whoami:elevated_scope"] = "See your full account and profile history",
+    },
+});
+
+// Bind the `whoami` scope to a policy.
+builder.Services.AddAAuthScopePolicy("AAuth.Scope.whoami", "whoami");
+// ...and the elevated scope used by gate 3.
+builder.Services.AddAAuthScopePolicy(
+    "AAuth.Scope.whoami:elevated_scope", "whoami:elevated_scope");
+
+// /protected_endpoint is the `whoami`-protected ACTION the agent calls
+// in gate 2. The mission-aware challenge copies the {approver, s256}
+// from the signed AAuth-Mission header into the resource token, so
+// the PS governs the exchange under the mission. Presenting only an
+// agent token yields a 401 + resource token (aud = the agent's PS)
+// requesting `whoami`.
+app.UseWhen(
+    ctx => ctx.Request.Path.StartsWithSegments("/protected_endpoint"),
+    branch =>
+    {
+        branch.UseAAuthVerification(FullVerification());
+        branch.UseAAuthChallenge(ChallengeForMission("whoami"));
+    });
+
+// The endpoint itself just requires the verified `whoami` scope.
+app.MapGet("/protected_endpoint", (HttpContext ctx) =>
+{
+    var v = ctx.GetAAuthVerification()!;  // auth token already verified
+    // v.Scopes contains "whoami" — safe to return the profile.
+    return Results.Ok(Profile(v));
+}).RequireAuthorization("AAuth.Scope.whoami");
+
+// /protected_endpoint/elevated is the ELEVATED action gate 3 calls. Same
+// mission-aware challenge, but it requests `whoami:elevated_scope`. Because
+// the mission's intent does not cover this scope, the PS prompts the user
+// at the exchange step before issuing the auth token (§Scopes, gate 3).
+app.UseWhen(
+    ctx => ctx.Request.Path.StartsWithSegments("/protected_endpoint/elevated"),
+    branch =>
+    {
+        branch.UseAAuthVerification(FullVerification());
+        branch.UseAAuthChallenge(ChallengeForMission("whoami:elevated_scope"));
+    });
+
+app.MapGet("/protected_endpoint/elevated", (HttpContext ctx) =>
+    Results.Ok(Profile(ctx.GetAAuthVerification()!)))
+   .RequireAuthorization("AAuth.Scope.whoami:elevated_scope");
+
+
+ +
+
+
Client — running the approved tool
+
// Gate 5 returned `granted` for delete_inbox. It is a LOCAL tool —
+// the agent runs it itself; no resource is involved (§Permission
+// Endpoint). Run it, then record it on the mission log so the PS
+// keeps a complete audit trail (§Audit Endpoint).
+if (deleteInbox.IsGranted)
+{
+    await DeleteInboxAsync(); // your own code / MCP tool call
+    await governance.Audit.RecordAsync(personServer,
+        new AuditRecord(
+            new MissionClaim(mission.Approver, mission.S256),
+            "delete_inbox")
+        {
+            Result = new JsonObject { ["deleted"] = true },
+        });
+}
+
+
+ +

+ Two governance paths, one mission: the permission endpoint gates + local actions (the agent runs the tool itself, then audits it), while a + scope-protected resource gates remote endpoints via the + challenge → exchange → retry pattern. The mission binding + {approver, s256} threads through both so the PS governs every step in + context (§Permission Endpoint, §Missions). +

+ +
+ Self-issued agent identity. Key ID: @_identity.KeyId, + agent: @_identity.AgentId. +
+ +@if (!_running && _steps.Count == 0) +{ + +

+ + Two gates resolve silently (in-scope token, pre-approved tool); three prompt + you in a new tab (mission creation, the elevated whoami:elevated_scope, + the delete_inbox action). + +

+} + +@if (_running) +{ + +} + +@if (_steps.Count > 0) +{ +
+
Gate outcomes
+

+ Each gate is independent. A resource token carries scope plus the + mission binding {approver, s256} — never the mission's tools. The + permission endpoint returns a decision, not a token, so denying + an action does not change any token issued by an earlier gate (§Missions, + §Permission Endpoint). +

+ @foreach (var s in _steps) + { +
+
+ Gate @s.Number — @s.Title + + @if (s.Prompted) + { + prompt + } + else + { + silent + } + @s.Outcome + +
+
+

@s.Summary

+ @if (s.Note is not null) + { +

@s.Note

+ } + @if (s.Payload is not null) + { +
@s.PayloadLabel
+
@s.Payload
+ } +
+
+ } +
+} + +@if (_waitingForApproval) +{ +
+
User Approval Required — @_currentGate
+

+ The gates above have run in order. This one fell outside the + mission, so the Person Server returned 202 Accepted and is waiting + for the user to approve before the flow continues. +

+

+ Interaction URL:
+ @_interactionUrl +

+

+ Open the link in a new tab and click Approve. The SDK is polling + the pending endpoint… + + (@_pollCount polls) +

+
+} + +@if (_error is not null) +{ +
@_error
+} + +@code { + [Inject] private SelfIssuedIdentity _identity { get; set; } = default!; + [Inject] private IConfiguration Config { get; set; } = default!; + + private readonly List _steps = new(); + private bool _running; + private bool _waitingForApproval; + private string? _currentGate; + private string? _interactionUrl; + private int _pollCount; + private string? _resourceJson; + private string? _error; + + // Reused across the flow so each gate signs with the same agent identity. + private string _agentToken = string.Empty; + + private sealed record GateStep( + int Number, + string Title, + bool Prompted, + string Outcome, + string Summary, + string? Note, + string? Payload, + string PayloadLabel = "", + string PayloadLang = "json"); + + private static string OutcomeClass(string outcome) => outcome switch + { + "denied" => "bg-danger", + _ => "bg-success", + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("highlightCode"); + } + + private async Task RunFlow() + { + _running = true; + _steps.Clear(); + _resourceJson = null; + _error = null; + _interactionUrl = null; + _waitingForApproval = false; + _pollCount = 0; + + var personServer = Config["AAuth:PersonServer"]!.TrimEnd('/'); + var resourceOrigin = Config["AAuth:Resource"]!.TrimEnd('/'); + var resourceUrl = $"{resourceOrigin}/jwt/mission"; + var elevatedUrl = $"{resourceOrigin}/jwt/mission/elevated"; + + try + { + // Reset prior runs and script the PS for an interactive demo: the + // browser decides the prompts, while `whoami` is seeded in scope so + // the token gate resolves silently (gate 2a). The approve* flags are + // only the scripted fallback used outside interactive mode. + await ConfigurePersonServerAsync(personServer, resourceOrigin); + + // Self-issued signing channel: SampleApp is its own AP (§Self-Hosted + // Agents), so it mints its own short-lived agent token. + _agentToken = MintAgentToken(personServer); + var handler = new AAuthSigningHandler(_identity.Key, () => _agentToken) + { InnerHandler = new HttpClientHandler() }; + using var signed = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + var metadata = new MetadataClient(new HttpClient()); + var governance = new AAuthGovernanceClient(signed, metadata); + var exchange = new TokenExchangeClient(signed, metadata); + var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(2) }; + + GovernanceOptions GovernanceFor() => new() + { + OnInteractionRequired = SurfaceInteractionAsync, + PollerOptions = poller, + }; + + // ---- Gate 1: propose the mission (PROMPT) ---------------------- + _currentGate = "Mission creation"; + var mission = await governance.Mission.ProposeAsync(personServer, new MissionProposal( + "Help the user keep their inbox under control for the next hour.") + { + Tools = new[] + { + new MissionTool("send_email", "Send an email on the user's behalf"), + new MissionTool("summarize", "Summarize a thread"), + }, + }, GovernanceFor()); + ClearWaiting(); + _steps.Add(new GateStep(1, "Mission creation", Prompted: true, + Outcome: "approved", + Summary: $"The user approved the mission's intent and {mission.ApprovedTools.Count} pre-approved tool(s).", + Note: "The mission approval is a durable restriction held at the PS — it is not a token. " + + "It names the agent's pre-approved TOOLS but lists NO scopes; resources never see " + + "mission content (§Mission Creation, §Missions).", + Payload: MissionPayload(mission), + PayloadLabel: "Mission approval (held at the PS)")); + await InvokeAsync(StateHasChanged); + + // ---- Gate 2: access a mission-aware resource (SILENT, in scope) - + _currentGate = "Resource token"; + var who = await AccessMissionResourceAsync( + signed, exchange, personServer, resourceUrl, mission, poller); + _resourceJson = Pretty(who); + ClearWaiting(); + _steps.Add(new GateStep(2, "Resource token (whoami)", Prompted: false, + Outcome: "granted", + Summary: $"The mission named no scopes, so the PS evaluated the requested scope against " + + $"the mission's intent; it fit, so an auth_token was minted silently. " + + $"scope={who?["scope"]?.ToJsonString()}", + Note: "This token carries the resource's scope and the mission binding {approver, s256} — " + + "NOT the mission's tools. It is unaffected by any later permission decision (§Scopes, §Missions).", + Payload: _resourceJson, + PayloadLabel: "Mission-aware resource response (auth_token claims)")); + await InvokeAsync(StateHasChanged); + + // ---- Gate 3: access an ELEVATED scope (PROMPT, out of mission) -- + // The mission named no scopes and its intent ("keep the inbox under + // control") does not cover `whoami:elevated_scope`. The PS cannot + // silently approve this exchange, so it prompts the user before + // minting the auth_token (§Agent Token Request gate 3, §Scopes). + _currentGate = "Resource token (elevated)"; + try + { + var elevated = await AccessMissionResourceAsync( + signed, exchange, personServer, elevatedUrl, mission, poller); + ClearWaiting(); + _steps.Add(new GateStep(3, "Resource token (elevated)", Prompted: true, + Outcome: "granted", + Summary: "This scope falls outside the mission's intent, so the PS could not grant " + + "it silently — it prompted the user, who approved. The PS may reuse this " + + $"consent for the rest of the mission. scope={elevated?["scope"]?.ToJsonString()}", + Note: "Out-of-mission scopes are not auto-denied: the PS asks the user, and the " + + "decision accrues to the mission for later requests (§Scopes, §Token Endpoint).", + Payload: Pretty(elevated), + PayloadLabel: "Elevated mission-aware resource response (auth_token claims)")); + } + catch (AAuthInteractionDeniedException) + { + ClearWaiting(); + _steps.Add(new GateStep(3, "Resource token (elevated)", Prompted: true, + Outcome: "denied", + Summary: "This scope falls outside the mission's intent; the PS prompted the user, " + + "who clicked Deny — so no auth_token was issued (access_denied).", + Note: "Only an explicit user deny (or a terminated mission) yields access_denied; " + + "an out-of-mission scope on its own just prompts (§Scopes).", + Payload: "{\n \"error\": \"access_denied\"\n}", + PayloadLabel: "Token exchange outcome")); + } + await InvokeAsync(StateHasChanged); + + // ---- Gate 4: permission for a pre-approved tool (SILENT) -------- + var sendEmail = await governance.Permission.RequestAsync( + personServer, "send_email", mission); + _steps.Add(new GateStep(4, "Permission send_email", Prompted: false, + Outcome: sendEmail.IsGranted ? "granted" : "denied", + Summary: "Pre-approved tool — resolved locally without a PS round-trip.", + Note: "Tools listed in the mission's approved_tools are granted without calling the " + + "permission endpoint (§Permission Endpoint).", + Payload: PermissionPayload(sendEmail), + PayloadLabel: "Permission decision")); + await InvokeAsync(StateHasChanged); + + // ---- Gate 5: permission for a non-approved tool (PROMPT) -------- + _currentGate = "Permission delete_inbox"; + try + { + var deleteInbox = await governance.Permission.RequestAsync( + personServer, + new PermissionRequest("delete_inbox") + { Mission = new MissionClaim(mission.Approver, mission.S256) }, + GovernanceFor()); + ClearWaiting(); + _steps.Add(new GateStep(5, "Permission delete_inbox", Prompted: true, + Outcome: deleteInbox.IsGranted ? "granted" : "denied", + Summary: deleteInbox.IsGranted + ? "Not a pre-approved tool — the PS prompted the user, who approved this action." + : "Not a pre-approved tool — the PS prompted the user, who denied this action.", + Note: "The permission endpoint returns a decision, not a token. This decision governs " + + "the action only; the Gate 2 token above is unchanged (§Permission Endpoint).", + Payload: PermissionPayload(deleteInbox), + PayloadLabel: "Permission decision")); + } + catch (AAuthInteractionDeniedException) + { + ClearWaiting(); + _steps.Add(new GateStep(5, "Permission delete_inbox", Prompted: true, + Outcome: "denied", + Summary: "Not a pre-approved tool — the user clicked Deny at the PS consent page.", + Note: "The permission endpoint returns a decision, not a token. Denying blocks this " + + "action only; the Gate 2 token above is unchanged (§Permission Endpoint).", + Payload: "{\n \"permission\": \"denied\"\n}", + PayloadLabel: "Permission decision")); + } + await InvokeAsync(StateHasChanged); + } + catch (AAuthInteractionDeniedException) + { + _error = "The user denied a consent request."; + } + catch (AAuthInteractionTimeoutException) + { + _error = "Timed out waiting for user approval (polling budget expired)."; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + ClearWaiting(); + _running = false; + } + } + + // Build a fresh self-issued agent token (a new jti each call satisfies the + // resource's replay detection on repeated access). + private string MintAgentToken(string personServer) => new AgentTokenBuilder + { + Issuer = _identity.Issuer, + Subject = _identity.AgentId, + KeyId = _identity.KeyId, + Key = _identity.Key, + PersonServer = personServer, + Lifetime = TimeSpan.FromHours(1), + }.Build(); + + // Challenge -> token exchange (governed by the PS) -> retry, carrying the + // mission claim so the resource copies it into the resource token. + private async Task AccessMissionResourceAsync( + HttpClient signed, + TokenExchangeClient exchange, + string personServer, + string resourceUrl, + AAuth.Agent.Mission mission, + DeferredPollerOptions poller) + { + // Refresh the agent token for a fresh jti before each resource access. + _agentToken = MintAgentToken(personServer); + + var challengeReq = new HttpRequestMessage(HttpMethod.Get, resourceUrl); + challengeReq.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); + using var challenge = await signed.SendAsync(challengeReq); + if (challenge.StatusCode != HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException( + $"Expected 401 challenge from the resource, got {(int)challenge.StatusCode}."); + } + + if (!challenge.Headers.TryGetValues(AAuthRequirementHeader.Name, out var requirementValues)) + { + var sigError = challenge.Headers.TryGetValues("Signature-Error", out var se) + ? string.Join(", ", se) + : "(none)"; + throw new InvalidOperationException( + $"The resource returned 401 without an {AAuthRequirementHeader.Name} header " + + $"(Signature-Error: {sigError}). The signed request was likely rejected — " + + "check that the resource can fetch the agent's current JWKS."); + } + + var requirement = string.Join(", ", requirementValues); + var parsed = AAuthRequirementHeader.Parse(requirement); + var resourceToken = parsed.ResourceToken + ?? throw new InvalidOperationException("Challenge did not carry a resource token."); + + var authToken = await exchange.ExchangeAsync(personServer, resourceToken, new TokenExchangeRequest + { + OnInteractionRequired = SurfaceInteractionAsync, + PollerOptions = poller, + }); + + var authHandler = new AAuthSigningHandler(_identity.Key, () => authToken) + { InnerHandler = new HttpClientHandler() }; + using var authClient = new HttpClient(authHandler); + using var ok = await authClient.GetAsync(resourceUrl); + ok.EnsureSuccessStatusCode(); + return await ok.Content.ReadFromJsonAsync(); + } + + // Surface the PS interaction URL in the UI and start a poll counter. + private async Task SurfaceInteractionAsync(Interaction interaction, CancellationToken ct) + { + _interactionUrl = interaction.BuildUserUrl(); + _waitingForApproval = true; + _pollCount = 0; + _ = Task.Run(async () => + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (_waitingForApproval && await timer.WaitForNextTickAsync(ct)) + { + _pollCount++; + await InvokeAsync(StateHasChanged); + } + }, ct); + await InvokeAsync(StateHasChanged); + } + + private void ClearWaiting() + { + _waitingForApproval = false; + _interactionUrl = null; + } + + private async Task ConfigurePersonServerAsync(string personServer, string resourceOrigin) + { + using var http = new HttpClient(); + try + { + await http.PostAsJsonAsync($"{personServer}/admin/reset", new { }); + await http.PostAsJsonAsync($"{personServer}/admin/mission-script", new + { + reset = true, + interactive = true, + approveMission = true, + approveToken = true, + approvePermission = true, + inScope = new[] { new { resource = resourceOrigin, scope = "whoami" } }, + }); + } + catch + { + // Admin endpoints only exist on MockPersonServer — swallow errors. + } + } + + private static string MissionPayload(AAuth.Agent.Mission m) + { + var tools = new JsonArray(); + foreach (var t in m.ApprovedTools) + { + tools.Add(new JsonObject { ["name"] = t.Name, ["description"] = t.Description }); + } + return Pretty(new JsonObject + { + ["approver"] = m.Approver, + ["s256"] = m.S256, + ["approved_tools"] = tools, + }); + } + + private static string PermissionPayload(PermissionResult r) + { + var obj = new JsonObject { ["permission"] = r.IsGranted ? "granted" : "denied" }; + if (!string.IsNullOrEmpty(r.Reason)) + { + obj["reason"] = r.Reason; + } + return Pretty(obj); + } + + private static string Pretty(JsonObject? json) => json is null + ? "(no body)" + : System.Text.Json.JsonSerializer.Serialize(json, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); +} diff --git a/samples/SampleApp/playwright-tests/mission.spec.ts b/samples/SampleApp/playwright-tests/mission.spec.ts new file mode 100644 index 0000000..301fc70 --- /dev/null +++ b/samples/SampleApp/playwright-tests/mission.spec.ts @@ -0,0 +1,151 @@ +import type { Page, BrowserContext } from '@playwright/test'; +import { test, expect } from '../../../tests/e2e/helpers/fixtures'; +import { waitForInteractive, clickAndConfirm } from '../../../tests/e2e/helpers/blazor'; +import { approveInPopup, denyInPopup } from '../../../tests/e2e/helpers/consent'; + +/** + * Mission (PS-Governed) — the Person Server is the policy-enforcement point for + * a durable, human-approved mission. The SampleApp `/mission` page runs five + * gates in order against the same self-issued agent identity (§Missions): + * + * 1. Mission creation — PROMPT (the user approves intent + tools). + * 2. Resource token whoami — SILENT (in scope: the seeded `whoami` fits the + * mission's intent, so the PS mints silently, gate 2a). + * 3. Resource token elevated— PROMPT (`whoami:elevated_scope` falls OUTSIDE the + * mission's intent, so the PS prompts, gate 3). + * 4. Permission send_email — SILENT (a pre-approved tool resolves locally). + * 5. Permission delete_inbox— PROMPT (a non-approved tool, so the PS asks). + * + * On run, the page scripts the PS for an interactive demo and seeds `whoami` + * in-scope, so the three out-of-mission gates each surface their own PS consent + * page while the two in-mission gates resolve without a prompt. Each gate hits + * the resource with a freshly-minted agent token (a new `jti`) so the resource's + * replay detection never rejects a second access (§Agent Token). + * + * The approval banner (`.alert-warning`) is a single shared element reused for + * every prompt — between two prompted gates the silent gate resolves and the + * banner is immediately re-shown for the next prompt, so it never goes hidden + * mid-flow. Tests therefore sync on the gate-outcome **card count** growing + * (each resolved gate appends one card) rather than on the banner toggling. + */ +test.describe('Mission (SampleApp)', () => { + test.describe.configure({ timeout: 180_000 }); + + /** The PS consent link surfaced while a gate is parked on user approval. */ + function approvalLink(page: Page) { + return page.locator('.alert-warning a[target="_blank"]'); + } + + /** A gate-outcome card by its 1-based gate number (cards render in order). */ + function gateCard(page: Page, n: number) { + return page.locator('.card').nth(n - 1); + } + + /** + * Resolve the currently-parked prompt: open the surfaced PS consent page in a + * popup, click Approve / Deny, then wait until the gate-outcome cards reach + * `expectedCards` (the prompted gate plus any silent gate that follows it + * resolve and append cards) so the next gate is ready. + */ + async function resolvePrompt( + page: Page, + context: BrowserContext, + action: 'approve' | 'deny', + expectedCards: number, + ): Promise { + await expect(page.locator('.alert-warning')).toBeVisible({ timeout: 60_000 }); + const [popup] = await Promise.all([ + context.waitForEvent('page'), + approvalLink(page).click(), + ]); + if (action === 'approve') { + await approveInPopup(popup); + } else { + await denyInPopup(popup); + } + await expect(page.locator('.card')).toHaveCount(expectedCards, { timeout: 120_000 }); + } + + test('three approvals drive all five gates to their expected outcomes', async ({ page, context }) => { + await page.goto('/mission'); + await expect(page.locator('h2')).toContainText('Mission'); + await waitForInteractive(page, 'button.btn-primary'); + + // Start the flow; gate 1 (mission creation) parks on the first PS prompt. + await clickAndConfirm(page, 'button.btn-primary', () => + page.locator('.alert-warning').isVisible()); + + // Gate 1: approve the mission's intent + tools (PROMPT). + // Gate 1 approve → gate 1 card + silent gate 2 card (2 cards), then gate 3 prompts. + await resolvePrompt(page, context, 'approve', 2); + // Gate 3: approve the out-of-mission elevated scope (PROMPT). + // Gate 3 approve → gate 3 card + silent gate 4 card (4 cards), then gate 5 prompts. + await resolvePrompt(page, context, 'approve', 4); + // Gate 5: approve the non-pre-approved delete_inbox action (PROMPT). + // Gate 5 approve → gate 5 card (5 cards); flow finishes. + await resolvePrompt(page, context, 'approve', 5); + + // The flow finished: the "Running…" button is gone and all five gate + // cards are present. + await expect(page.getByRole('button', { name: /Running/ })).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('.card')).toHaveCount(5); + + // Gate 1 — PROMPT, approved. + await expect(gateCard(page, 1)).toContainText('Mission creation'); + await expect(gateCard(page, 1).locator('.badge.bg-warning')).toHaveText('prompt'); + await expect(gateCard(page, 1).locator('.badge.bg-success').last()).toHaveText('approved'); + + // Gate 2 — SILENT, granted (in-scope whoami). + await expect(gateCard(page, 2)).toContainText('whoami'); + await expect(gateCard(page, 2).locator('.badge.bg-success').first()).toHaveText('silent'); + await expect(gateCard(page, 2)).toContainText('whoami'); + + // Gate 3 — PROMPT, granted (out-of-mission elevated scope). + await expect(gateCard(page, 3)).toContainText('elevated'); + await expect(gateCard(page, 3).locator('.badge.bg-warning')).toHaveText('prompt'); + await expect(gateCard(page, 3).locator('.badge.bg-success').last()).toHaveText('granted'); + await expect(gateCard(page, 3)).toContainText('whoami:elevated_scope'); + + // Gate 4 — SILENT, granted (pre-approved tool). + await expect(gateCard(page, 4)).toContainText('send_email'); + await expect(gateCard(page, 4).locator('.badge.bg-success').first()).toHaveText('silent'); + + // Gate 5 — PROMPT, granted (non-pre-approved tool). + await expect(gateCard(page, 5)).toContainText('delete_inbox'); + await expect(gateCard(page, 5).locator('.badge.bg-warning')).toHaveText('prompt'); + await expect(gateCard(page, 5).locator('.badge.bg-success').last()).toHaveText('granted'); + }); + + test('deny at the elevated-scope gate records access_denied without affecting the prior token', async ({ page, context }) => { + await page.goto('/mission'); + await expect(page.locator('h2')).toContainText('Mission'); + await waitForInteractive(page, 'button.btn-primary'); + + await clickAndConfirm(page, 'button.btn-primary', () => + page.locator('.alert-warning').isVisible()); + + // Gate 1: approve the mission (2 cards: gate 1 + silent gate 2). + await resolvePrompt(page, context, 'approve', 2); + // Gate 3: DENY the out-of-mission elevated scope (4 cards: gate 3 denied + silent gate 4). + await resolvePrompt(page, context, 'deny', 4); + // Gate 5: approve the delete_inbox action — the flow continues past the + // denied gate because each gate is independent (5 cards). + await resolvePrompt(page, context, 'approve', 5); + + await expect(page.getByRole('button', { name: /Running/ })).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('.card')).toHaveCount(5); + + // Gate 2 (the in-scope whoami token) was issued BEFORE the deny and is + // unaffected by it (§Missions: a permission/scope decision does not revoke + // an earlier token). + await expect(gateCard(page, 2).locator('.badge.bg-success').last()).toHaveText('granted'); + + // Gate 3 — PROMPT, denied → access_denied. + await expect(gateCard(page, 3).locator('.badge.bg-warning')).toHaveText('prompt'); + await expect(gateCard(page, 3).locator('.badge.bg-danger')).toHaveText('denied'); + await expect(gateCard(page, 3)).toContainText('access_denied'); + + // Gate 5 — PROMPT, granted: denying gate 3 did not abort the mission. + await expect(gateCard(page, 5).locator('.badge.bg-success').last()).toHaveText('granted'); + }); +}); diff --git a/samples/WhoAmI/Program.cs b/samples/WhoAmI/Program.cs index a1d8039..df5e4bf 100644 --- a/samples/WhoAmI/Program.cs +++ b/samples/WhoAmI/Program.cs @@ -21,11 +21,13 @@ const string ResourceKid = "whoami-1"; // Scope + role taxonomy this resource recognises. -// whoami — basic profile read (three-party baseline) -// whoami:admin — elevated profile access (step-up scope) -// whoami-admin — RBAC role asserted by the PS +// whoami — basic profile read (three-party baseline) +// whoami:admin — elevated profile access (step-up scope) +// whoami:elevated_scope — elevated, mission-aware access (out-of-mission scope demo) +// whoami-admin — RBAC role asserted by the PS const string ScopeWhoami = "whoami"; const string ScopeWhoamiAdmin = "whoami:admin"; +const string ScopeWhoamiElevated = "whoami:elevated_scope"; const string RoleWhoamiAdmin = "whoami-admin"; var resourceUrl = builder.Configuration["AAuth:Issuer"] ?? "http://localhost:5000"; @@ -71,6 +73,7 @@ builder.Services.AddAAuthAuthorization(); builder.Services.AddAAuthScopePolicy("AAuth.Scope.whoami", ScopeWhoami); builder.Services.AddAAuthScopePolicy("AAuth.Scope.whoami:admin", ScopeWhoamiAdmin); +builder.Services.AddAAuthScopePolicy("AAuth.Scope.whoami:elevated_scope", ScopeWhoamiElevated); builder.Services.AddAAuthRolePolicy("AAuth.Role.whoami-admin", RoleWhoamiAdmin); var app = builder.Build(); @@ -88,6 +91,7 @@ { [ScopeWhoami] = "See basic profile information", [ScopeWhoamiAdmin] = "See and manage administrative profile information", + [ScopeWhoamiElevated] = "See your full account and profile history", }, SignatureWindow = signatureWindowSeconds, }); @@ -180,12 +184,28 @@ ctx => ctx.Request.Path.StartsWithSegments("/jwks-uri"), branch => branch.UseAAuthVerification(SignatureOnly())); +// /jwt/mission/elevated — three-party mission-aware, ELEVATED scope. Same +// mission-aware challenge as /jwt/mission, but it requests the elevated +// `whoami:elevated_scope`. Under a mission whose intent does not cover this +// scope, the agent's PS cannot silently approve the exchange — it must prompt +// the user (§Agent Token Request gate 3; §Scopes — "the PS evaluates requested +// scopes against mission context"). Registered BEFORE the /jwt/mission branch +// because its path is the more specific prefix. +app.UseWhen( + ctx => ctx.Request.Path.StartsWithSegments("/jwt/mission/elevated"), + branch => + { + branch.UseAAuthVerification(FullVerification()); + branch.UseAAuthChallenge(ChallengeForMission(ScopeWhoamiElevated)); + }); + // /jwt/mission — three-party mission-aware: full verification + a mission-aware // challenge. When the agent presents a signed AAuth-Mission header, the issued // resource token carries the mission object (approver + s256), so the agent's // PS governs the token exchange against that mission (§Terminology, §Missions). app.UseWhen( - ctx => ctx.Request.Path.StartsWithSegments("/jwt/mission"), + ctx => ctx.Request.Path.StartsWithSegments("/jwt/mission") + && !ctx.Request.Path.StartsWithSegments("/jwt/mission/elevated"), branch => { branch.UseAAuthVerification(FullVerification()); @@ -261,6 +281,7 @@ new { path = "/jwks-uri", mode = "agent-identity", auth = "AAuth.Identified" }, new { path = "/jwt", mode = "three-party", auth = "AAuth.Scope.whoami" }, new { path = "/jwt/mission", mode = "three-party (mission-aware)", auth = "AAuth.Scope.whoami" }, + new { path = "/jwt/mission/elevated", mode = "three-party (mission-aware, elevated)", auth = "AAuth.Scope.whoami:elevated_scope" }, new { path = "/jwt/admin", mode = "three-party (step-up)", auth = "AAuth.Scope.whoami:admin" }, new { path = "/jwt/roles", mode = "three-party (RBAC)", auth = "AAuth.Role.whoami-admin" }, new { path = "/federated", mode = "four-party", auth = "AAuth.Scope.whoami" }, @@ -382,6 +403,36 @@ }); }).RequireAuthorization("AAuth.Scope.whoami"); +// ----------------------------------------------------------------------- +// GET /jwt/mission/elevated — Three-party mission-aware ELEVATED access. +// +// Identical mission-aware mechanics to /jwt/mission, but it requires the +// elevated `whoami:elevated_scope`. When the agent operates under a mission +// whose intent does not cover this scope, its PS cannot silently approve the +// exchange and must prompt the user before issuing the auth token (§Agent +// Token Request gate 3; §Scopes). Used by the samples to demonstrate the +// out-of-mission scope consent gate alongside the in-scope `whoami` gate. +// ----------------------------------------------------------------------- +app.MapGet("/jwt/mission/elevated", (HttpContext ctx) => +{ + var result = ctx.GetAAuthVerification()!; + var parsed = ctx.GetAAuthParsedKey()!; + var mission = parsed.Payload?["mission"]; + + return Results.Ok(new + { + mode = "three-party", + scheme = "jwt", + access = "mission-elevated", + agent = result.Agent, + sub = result.Subject, + scope = result.Scopes, + iss = result.Issuer, + mission, + missionAware = true, + }); +}).RequireAuthorization("AAuth.Scope.whoami:elevated_scope"); + // ----------------------------------------------------------------------- // GET /jwt/admin — Three-party elevated access (step-up scope). // Requires an auth token carrying the elevated `whoami:admin` scope. diff --git a/tests/e2e/helpers/tour.ts b/tests/e2e/helpers/tour.ts index 7a8a913..4fae430 100644 --- a/tests/e2e/helpers/tour.ts +++ b/tests/e2e/helpers/tour.ts @@ -22,6 +22,7 @@ export const TourMode = { Deferred: 'Deferred', CallChain: 'CallChain', Federated: 'Federated', + Mission: 'Mission', } as const; export type TourMode = (typeof TourMode)[keyof typeof TourMode]; @@ -50,6 +51,10 @@ const PLAN_STEPS: Record = { // exchange returns 202 (the AS requires consent — its own stub screen or // Keycloak) the plan expands to 10 (consent + poll), mirroring deferred. Federated: 7, + // Mission (PS-governed): 20 steps across three consent cycles — mission + // creation (4/5), the out-of-mission elevated scope token (12/13), and the + // out-of-scope delete_inbox permission (18/19). + Mission: 20, }; /** Select a flow in the `#flow-select` picker and wait for the timeline to reset. */ From c313090a2a7c8029352c588bbca3857af23b1775 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 16:52:40 +0000 Subject: [PATCH 10/24] docs(plan): restore accidentally dropped Phase 7 heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 7 — Docs section heading was lost in 5fe6809; the section body was intact. Restore the heading so phase numbering is contiguous. --- .../2026-06-05-missions-ps-governance/implementation-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index 35cdc91..3be9a81 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -641,6 +641,8 @@ gate 3) **and** a prompted *tool* (§Permission Endpoint) — are demonstrated. --- +## Phase 7 — Docs + **Goal:** Rewrite the stale missions doc and add PS-governance docs reflecting the implemented surface. Separate phase from samples per the agreed workflow. From 937932cb8be4935efbf47b5647a8d070e481dd0b Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 17:21:36 +0000 Subject: [PATCH 11/24] docs(missions): rewrite missions doc and add PS-governance docs - Rewrite docs/advanced/missions.md to the shipped blob model (s256, two states, structured AAuth-Mission header, MissionClaim binding chain) - Add mission-governance-clients, clarification-chat, server/mission-governance, and workflows/mission-governed-access docs - Document MissionAware, mission claim emission, mission_terminated, AddAAuthGovernance + governance-client wiring - Index new docs and add API-Map rows in docs/README.md Phase 7 of missions/PS governance. --- .../implementation-plan.md | 95 +++++-- .../research.md | 55 ++++ docs/README.md | 31 +++ docs/advanced/clarification-chat.md | 159 ++++++++++++ docs/advanced/error-handling.md | 55 ++++ docs/advanced/mission-governance-clients.md | 211 ++++++++++++++++ docs/advanced/missions.md | 230 ++++++++++------- docs/concepts.md | 2 +- docs/reference/dependency-injection.md | 38 +++ docs/server/challenge-middleware.md | 26 ++ docs/server/mission-governance.md | 235 ++++++++++++++++++ docs/server/token-issuance.md | 30 +++ docs/workflows/call-chaining.md | 1 + docs/workflows/mission-governed-access.md | 158 ++++++++++++ 14 files changed, 1225 insertions(+), 101 deletions(-) create mode 100644 docs/advanced/clarification-chat.md create mode 100644 docs/advanced/mission-governance-clients.md create mode 100644 docs/server/mission-governance.md create mode 100644 docs/workflows/mission-governed-access.md diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index 3be9a81..0584b3f 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -643,33 +643,96 @@ gate 3) **and** a prompted *tool* (§Permission Endpoint) — are demonstrated. ## Phase 7 — Docs -**Goal:** Rewrite the stale missions doc and add PS-governance docs reflecting the -implemented surface. Separate phase from samples per the agreed workflow. +**Goal:** Rewrite the stale missions doc and add mission/PS-governance docs +reflecting the implemented surface. Separate phase from samples per the agreed +workflow. **Spec:** §Missions; §Mission Approval; §Mission Log; §Policy Evaluation Points; §Why Missions Are Not a Policy Language; §Permission/Audit/Interaction Endpoints; -§Clarification Chat. +§Clarification Chat; §Mission Status Errors. + +> **Scope revised (2026-06-06).** A read-only audit of the shipped API against +> the existing docs (research.md "Phase 7 — doc audit") showed the original +> six-file list under-covered what Phases 1–6 actually built. `docs/advanced/ +> missions.md` describes a **non-existent** model (`Mission.Id`/`Status`/ +> `Requirements`/`FromJson`, `AAuthMissionHeader.Format(missionId)`), and the +> entire **agent-side governance client** surface, **clarification chat**, +> **server governance seams**, **`mission_terminated`**, **`ChallengeOptions. +> MissionAware`**, and **`AddAAuthGovernance`** have **zero** doc coverage. The +> file list and DoD below are expanded accordingly. `docs/concepts.md` (the +> tools-declared-vs-scopes-evaluated split) and `docs/workflows/call-chaining.md` +> (mission forwarding via `WithCallChaining`) were already corrected in Phase 6 +> and need only cross-link/index touch-ups. + +### Implemented surface the docs must match (audited 2026-06-06) + +- **Mission model** (`AAuth.Agent`): `Mission { Approver, Agent, ApprovedAt, + Description, ApprovedTools, Capabilities, S256, RawBytes, State }`, + `FromApprovalBytes`, `VerifyS256`, `ComputeS256`; `MissionState + { Active, Terminated }`; `MissionTool(Name, Description?)`. +- **Header / claim**: `AAuthMissionHeader.FormatStructured(approver, s256)` + + `TryParseStructured(...)`; `MissionClaim(Approver, S256)` in tokens. +- **Agent governance clients** (`AAuth.Agent.Governance`): facade + `AAuthGovernanceClient { Mission, Permission, Audit, Interaction }`; + `MissionClient.ProposeAsync`; `PermissionClient.RequestAsync` (missionless + + mission-scoped pre-approved-tool short-circuit); `AuditClient.RecordAsync`; + `InteractionClient.SendAsync` / `RelayInteractionAsync`; DTOs `MissionProposal`, + `PermissionRequest`/`PermissionResult`/`PermissionGrant`, `AuditRecord`, + `InteractionRequest`/`InteractionResult`/`InteractionType`, `GovernanceOptions`. +- **Builder**: `AAuthClientBuilder.BuildGovernance()`; + `MissionForwardingHandler`; `WithCallChaining(...)`. +- **Clarification chat**: `ClarificationExchange` (rounds, `DefaultMaxRounds=5`), + `ClarificationResponse` (`Respond`/`Update`/`Cancel`), `ClarificationRequirement`. +- **Token-exchange mission params** (`TokenExchangeRequest`): `Justification`, + `LoginHint`, `Tenant`, `DomainHint`, `Platform`, `Device`, + `OnClarificationRequired`, `MaxClarificationRounds`. +- **Errors**: `AAuthMissionTerminatedException` (`mission_terminated`). +- **Mission-aware resource**: `ChallengeOptions.MissionAware`. +- **Server seams** (`AAuth.Server.Governance`): `IMissionStore`/`StoredMission`/ + `InMemoryMissionStore`; `IPermissionDecider`/`PermissionDecisionContext`/ + `PermissionDecision`/`PermissionOutcome`/`PermissionDecisionReason`; + `IAuditSink`; `IInteractionRelay`/`InteractionRelayResult`; `IMissionLog`/ + `MissionLogEntry`/`MissionLogEntryKind`/`InMemoryMissionLog`; + `GovernanceEndpoints` parsers. DI: `AddAAuthGovernance`. ### Files | File | Action | |------|--------| -| `docs/advanced/missions.md` | **Rewrite** — spec blob, `s256`, two states, lifecycle, binding chain | -| `docs/server/mission-governance.md` | **New** — PS as contextual policy point; permission/audit/interaction; mission log | -| `docs/workflows/mission-governed-access.md` | **New** — end-to-end walkthrough (the three research flows) | -| `docs/server/token-issuance.md` | **Modify** — add `s256` verify + mission claim emission | -| `docs/workflows/call-chaining.md` | **Modify** — mission forwarding + governance | -| `docs/concepts.md` / `docs/README.md` | **Modify** — index + concept of PS policy enforcement | +| `docs/advanced/missions.md` | **Rewrite** — replace the stale model with the spec blob (`Approver`/`Agent`/`ApprovedAt`/`Description`/`ApprovedTools`/`Capabilities`/`S256`/`RawBytes`/`State`), `s256` identity, two states, `FromApprovalBytes`/`VerifyS256`, `AAuthMissionHeader.FormatStructured`/`TryParseStructured`, the `MissionClaim` binding chain through resource→auth tokens, and `ChallengeOptions.MissionAware`; fix the lifecycle mermaid (agent-proposes-to-PS, not resource-proposes) | +| `docs/advanced/mission-governance-clients.md` | **New** — agent-side `AAuthGovernanceClient` facade + `Mission`/`Permission`/`Audit`/`Interaction` clients, their DTOs, `BuildGovernance()`, and the tool-declared (permission endpoint, `approved_tools` short-circuit) vs scope-evaluated split | +| `docs/advanced/clarification-chat.md` | **New** — `ClarificationRequirement`, `ClarificationExchange` rounds + `DefaultMaxRounds`, `ClarificationResponse` (`Respond`/`Update`/`Cancel`), the token-exchange `OnClarificationRequired`/`MaxClarificationRounds` hooks (§Clarification Chat) | +| `docs/server/mission-governance.md` | **New** — PS as contextual policy point; server seams `IMissionStore`/`IMissionLog`/`IPermissionDecider`/`IAuditSink`/`IInteractionRelay`, `GovernanceEndpoints` parsers, the three-gate decision + reasons, prior-consent lookup, `mission_terminated` (§PS Governance, §Mission Log, §Why Missions Are Not a Policy Language) | +| `docs/workflows/mission-governed-access.md` | **New** — end-to-end walkthrough: propose → operate (silent in-scope + prompted out-of-mission scope) → permission (silent tool + prompted action) → audit → interaction → completion, with the binding chain | +| `docs/server/token-issuance.md` | **Modify** — add `s256` verify + `mission` claim emission/echo on resource + auth tokens | +| `docs/server/challenge-middleware.md` | **Modify** — document `ChallengeOptions.MissionAware` (mission-aware resource copies the `AAuth-Mission` claim into the resource token) | +| `docs/advanced/error-handling.md` | **Modify** — add `AAuthMissionTerminatedException` (`mission_terminated`) | +| `docs/reference/dependency-injection.md` | **Modify** — add `AddAAuthGovernance` (server seams) + governance-client wiring | +| `docs/workflows/call-chaining.md` | **Modify (light)** — cross-link mission forwarding to the new mission docs (forwarding itself already accurate) | +| `docs/concepts.md` | **Modify (light)** — cross-link the new mission docs (tools-vs-scopes split already corrected in Phase 6) | +| `docs/README.md` | **Modify** — index the new docs; add API-Map rows for `AAuthGovernanceClient`, the four governance clients, clarification types, and the server seams | ### Definition of Done -- [ ] `docs/advanced/missions.md` matches the implemented model (no stale fields/states). -- [ ] New governance doc explains the deterministic-vs-contextual split - (§Why Missions Are Not a Policy Language). -- [ ] Walkthrough doc covers create → operate → permission → audit → interaction → - completion, with the binding chain. -- [ ] All doc code samples compile against the Phase 1–5 API. -- [ ] docs index/README updated; cross-links valid. +- [x] `docs/advanced/missions.md` matches the implemented model (no stale + `Id`/`Status`/`Requirements`/`FromJson`/`Format(missionId)`; correct blob + fields, two states, `s256`, structured header, binding chain). +- [x] Agent-side governance clients doc covers the facade + four clients + DTOs + + `BuildGovernance()`, with a runnable propose→permission→audit→interaction + example. +- [x] Clarification-chat doc covers requirement parsing, the round loop + + `DefaultMaxRounds`, and `Respond`/`Update`/`Cancel` (§Clarification Chat). +- [x] Server governance doc explains the deterministic-vs-contextual split + (§Why Missions Are Not a Policy Language) and documents every seam + + `mission_terminated`. +- [x] Walkthrough doc covers create → operate (silent + prompted) → permission + (silent + prompted) → audit → interaction → completion, with the binding chain. +- [x] Touch-ups landed: `token-issuance.md` (`s256`/mission claim), + `challenge-middleware.md` (`MissionAware`), `error-handling.md` + (`mission_terminated`), `dependency-injection.md` (`AddAAuthGovernance`). +- [x] All doc code samples compile against the shipped Phase 1–6 API + (exact type/member names). +- [x] docs index/README updated; API-Map rows added; cross-links valid. --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index 40e514f..a1b5918 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -771,3 +771,58 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a `AAuth.Conformance` 425/425. GuidedTour mission specs (lifecycle + deny) green; SampleApp mission specs (five-gate + deny) green. +### Phase 7 — doc audit (2026-06-06) + +Read-only audit of every `docs/**` file that mentions missions/governance +against the shipped Phase 1–6 public API, to re-scope Phase 7 before writing +(user: "Phase 7 may need updates as we touched new things during +implementation"). Findings drive the revised Phase 7 file list + DoD in the +implementation plan. + +- **`docs/advanced/missions.md` is fundamentally stale.** It documents a + **non-existent** model: `Mission.Id`, `Mission.Status` (pending/approved/ + denied/completed), `Mission.Requirements` (JsonArray), `Mission.StatusUrl`/ + `InteractionUrl`, `Mission.FromJson(JsonObject)`, and + `AAuthMissionHeader.Format(string missionId)` — plus a resource-proposes-mission + lifecycle mermaid. The shipped model is the spec **approval blob**: + `Mission { Approver, Agent, ApprovedAt, Description, ApprovedTools, + Capabilities, S256, RawBytes, State(Active|Terminated) }`, + `FromApprovalBytes`/`VerifyS256`/`ComputeS256`, and + `AAuthMissionHeader.FormatStructured(approver, s256)`/`TryParseStructured`. + Requires a full rewrite, not a patch. +- **Agent-side governance clients have ZERO doc coverage.** `AAuthGovernanceClient` + facade + `MissionClient.ProposeAsync`, `PermissionClient.RequestAsync` (both + overloads), `AuditClient.RecordAsync`, `InteractionClient.SendAsync`/ + `RelayInteractionAsync`, all DTOs (`MissionProposal`, `PermissionRequest`/ + `PermissionResult`/`PermissionGrant`, `AuditRecord`, `InteractionRequest`/ + `InteractionResult`/`InteractionType`, `GovernanceOptions`), and + `AAuthClientBuilder.BuildGovernance()` — none documented. +- **Clarification chat has ZERO doc coverage.** `ClarificationExchange` + (`DefaultMaxRounds = 5`), `ClarificationResponse` (`Respond`/`Update`/`Cancel`), + `ClarificationRequirement`, and the `TokenExchangeRequest`/`GovernanceOptions` + `OnClarificationRequired` + `MaxClarificationRounds` hooks. +- **Server governance seams have ZERO doc coverage.** `IMissionStore`/ + `StoredMission`/`InMemoryMissionStore`, `IPermissionDecider`/ + `PermissionDecisionContext`/`PermissionDecision`/`PermissionOutcome`/ + `PermissionDecisionReason`, `IAuditSink`, `IInteractionRelay`/ + `InteractionRelayResult`, `IMissionLog`/`MissionLogEntry`/`MissionLogEntryKind`/ + `InMemoryMissionLog`, `GovernanceEndpoints`, and `AddAAuthGovernance` DI. +- **Smaller gaps.** `AAuthMissionTerminatedException` (`mission_terminated`) is + absent from `error-handling.md`; `ChallengeOptions.MissionAware` (the + mission-aware resource that copies the `AAuth-Mission` claim into the resource + token) is absent from `challenge-middleware.md`; `README.md`'s API Map lacks + rows for every governance client + server seam; the six mission token-exchange + params (`Justification`/`LoginHint`/`Tenant`/`DomainHint`/`Platform`/`Device`) + are undocumented. +- **Already correct (Phase 6 / earlier work) — no rewrite needed.** + `docs/concepts.md` (tools-declared-vs-scopes-evaluated split, corrected in + 6b), `docs/workflows/call-chaining.md` (mission forwarding via + `WithCallChaining`/`MissionForwardingHandler`), and `docs/server/ + token-issuance.md` (the `mission.approver` constraint) only need cross-link / + index touch-ups. +- **Design choices recorded (D10–D11, user-approved 2026-06-06).** D10: the + agent-side governance clients get a dedicated `docs/advanced/ + mission-governance-clients.md` (keeps `docs/server/mission-governance.md` + focused on PS/server seams). D11: clarification chat gets its own + `docs/advanced/clarification-chat.md`; all smaller touch-ups land in Phase 7. + diff --git a/docs/README.md b/docs/README.md index ca3364b..25c05a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov - [Bootstrap & Enrollment](workflows/bootstrap-enrollment.md) - [Deferred Consent](workflows/deferred-consent.md) - [Call Chaining](workflows/call-chaining.md) +- [Mission-Governed Access](workflows/mission-governed-access.md) ## Server Implementation @@ -42,10 +43,13 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov - [Token Issuance](server/token-issuance.md) - [Replay Detection](server/replay-detection.md) - [Multi-Scheme Verification](server/multi-scheme-verification.md) +- [Mission Governance](server/mission-governance.md) — PS-side mission policy seams ## Advanced Topics - [Missions](advanced/missions.md) +- [Mission Governance Clients](advanced/mission-governance-clients.md) — propose, permission, audit, interaction +- [Clarification Chat](advanced/clarification-chat.md) — answering a server's follow-up questions - [Interaction Chaining](advanced/interaction-chaining.md) - [Platform Attestation](advanced/platform-attestation.md) - [Key Management](advanced/key-management.md) @@ -103,6 +107,17 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov | `AgentProviderClient` | Enrols with an Agent Provider (CLI/desktop agents; hosted services self-issue) | | `Mission` / `AAuthMissionHeader` | Mission state + the `AAuth-Mission` header helpers | | `MissionForwardingHandler` | `DelegatingHandler` that forwards mission context downstream | +| `AAuthGovernanceClient` | Facade bundling the four PS governance clients | +| `MissionClient` | Propose missions at the PS `mission_endpoint` | +| `PermissionClient` | Request permission at the PS `permission_endpoint` | +| `AuditClient` | Record actions at the PS `audit_endpoint` | +| `InteractionClient` | Reach the user via the PS `interaction_endpoint` | +| `MissionProposal` / `MissionTool` | Mission proposal body + a declared tool | +| `PermissionRequest` / `PermissionResult` | Permission request + grant/deny result | +| `AuditRecord` | Audit entry (requires a mission) | +| `InteractionRequest` / `InteractionResult` | Interaction request + typed terminal result | +| `GovernanceOptions` | Deferral callbacks shared by the governance clients | +| `ClarificationExchange` / `ClarificationResponse` | Drive a clarification chat; respond / update / cancel | | `AAuthCapabilitiesHeader` | Helpers for the `AAuth-Capabilities` request header | | `IInteractionPresenter` | Surface interaction URLs to the user | | `IPlatformAttestor` / `NoopAttestor` | Platform attestation hook + built-in no-op implementation | @@ -118,6 +133,7 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov | `ResourceTokenBuilder` | Builds `aa-resource+jwt` (401 challenge payload) | | `AuthTokenBuilder` | Builds `aa-auth+jwt` (person delegation proof) | | `TokenVerifier` | EdDSA JWT verification with claim checks and JWKS resolution | +| `MissionClaim` | The `mission` claim (`approver` + `s256`) carried in tokens | ### `AAuth.Discovery` — Metadata and JWKS @@ -133,6 +149,7 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov |------|---------| | `AAuthRequirementHeader` | Format/parse the `AAuth-Requirement` challenge header | | `Interaction` | Interaction URL + code from 202 responses | +| `ClarificationRequirement` | Typed `requirement=clarification` projection (untrusted question) | > The `AAuthCapabilitiesHeader` and `AAuthMissionHeader` types live in the `AAuth.Agent` namespace (alongside `Mission` and `MissionForwardingHandler`), not in `AAuth.Headers`. @@ -151,6 +168,19 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov |------|---------| | `AAuthChallengeMiddleware` | Auto-challenge: issues 401 with resource token | +### `AAuth.Server.Governance` — PS-side mission governance + +| Type | Purpose | +|------|---------| +| `GovernanceEndpoints` | Parse governance request bodies + emit `mission_terminated` | +| `IMissionStore` / `InMemoryMissionStore` | Persist missions (verbatim blob + state) | +| `IMissionLog` / `InMemoryMissionLog` | Ordered mission log + prior-consent lookup | +| `IPermissionDecider` | PS policy seam for the permission endpoint | +| `IAuditSink` | PS sink for audit records | +| `IInteractionRelay` | PS user-channel seam for interactions | +| `StoredMission` / `MissionLogEntry` | Persisted mission + log entry records | +| `PermissionDecision` / `PermissionOutcome` / `PermissionDecisionReason` | Typed permission decision vocabulary | + ### `AAuth.Server.Authorization` — Scope authorization | Type | Purpose | @@ -191,6 +221,7 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov | `AAuthAgentServiceCollectionExtensions` | `services.AddAAuthAgent(...)` | | `AAuthResourceServiceCollectionExtensions` | `services.AddAAuthResource(...)` | | `AAuthDiscoveryServiceCollectionExtensions` | `services.AddAAuthDiscovery(...)` | +| `AAuthGovernanceServiceCollectionExtensions` | `services.AddAAuthGovernance()` | | `AAuthApplicationBuilderExtensions` | `app.UseAAuthVerification()` | > These extension methods live in the conventional `Microsoft.Extensions.DependencyInjection` and `Microsoft.AspNetCore.Builder` namespaces so they surface automatically in ASP.NET Core projects. The associated options records (`AAuthAgentOptions`, `AAuthResourceOptions`, `AAuthDiscoveryOptions`, etc.) live in the root `AAuth` namespace. diff --git a/docs/advanced/clarification-chat.md b/docs/advanced/clarification-chat.md new file mode 100644 index 0000000..5030e6b --- /dev/null +++ b/docs/advanced/clarification-chat.md @@ -0,0 +1,159 @@ +# Clarification Chat + +> [Clarification Chat](https://explorer.aauth.dev/missions/clarification) + +## Overview + +A clarification chat is the protocol's way for a server to ask the agent a +follow-up question before it decides a deferred request, rather than approving or +rejecting outright (§Clarification Chat). Two places use it: + +- **Token exchange.** When the agent exchanges a resource token, the PS may need + more detail before granting the scope — it defers and asks a clarifying + question. +- **Mission proposal and governance.** When the agent proposes a mission or + requests permission, the PS may ask the agent to refine the intent before + approving. + +The agent answers the question, replaces its request with a narrower one, or +withdraws — and the exchange continues until the server decides or the round +limit is reached. + +## The question: `ClarificationRequirement` + +When a server needs clarification it returns `AAuth-Requirement: +requirement=clarification` and carries the question in the response body. The SDK +projects that into a typed `ClarificationRequirement`: + +```csharp +namespace AAuth.Headers; + +public sealed record ClarificationRequirement( + string Clarification, // the Markdown question — UNTRUSTED, sanitize before display + int? TimeoutSeconds = null, // optional deadline to respond by + IReadOnlyList? Options = null); // optional discrete choices for a closed question +``` + +> [!WARNING] +> The `Clarification` value is untrusted input from the server. Sanitize it +> before rendering it to a user (§Clarification Required). + +## The answer: `ClarificationResponse` + +The agent replies with one of three actions (§Agent Response to Clarification): + +```csharp +namespace AAuth.Agent; + +public sealed class ClarificationResponse +{ + public enum Kind { Respond, Update, Cancel } + + public static ClarificationResponse Respond(string markdown); // answer the question + public static ClarificationResponse Update(string resourceToken, string? justification = null); // replace the request + public static ClarificationResponse Cancel(); // withdraw +} +``` + +- `Respond` posts a Markdown answer and resumes the exchange. +- `Update` replaces the original request with a new resource token (for example a + reduced scope) plus an optional justification. +- `Cancel` withdraws the request entirely. + +## Driving the chat: `ClarificationExchange` + +For manual control over a deferred pending URL, use `ClarificationExchange`. It +tracks the round count and enforces a maximum (§Clarification Limits). + +```csharp +namespace AAuth.Agent; + +public sealed class ClarificationExchange +{ + public const int DefaultMaxRounds = 5; + + public ClarificationExchange(HttpClient signedClient, Uri pendingUrl, int maxRounds = DefaultMaxRounds); + + public int MaxRounds { get; } + public int Rounds { get; } + + public Task ApplyAsync(ClarificationResponse response, CancellationToken ct = default); + public Task RespondAsync(string markdown, CancellationToken ct = default); + public Task UpdateRequestAsync(string resourceToken, string? justification = null, CancellationToken ct = default); + public Task CancelAsync(CancellationToken ct = default); +} +``` + +The supplied `HttpClient` must be wired with the agent's `AAuthSigningHandler` so +every POST/DELETE to the pending URL is signed. + +```csharp +var exchange = new ClarificationExchange(signedClient, pendingUrl); + +await exchange.ApplyAsync(ClarificationResponse.Respond( + "The export is for the user's own tax records, read-only.")); + +// Cancelling throws AAuthClarificationCancelledException after withdrawing. +// Exceeding MaxRounds throws AAuthClarificationLimitException(MaxRounds). +``` + +Both `Respond` and `Update` consume a round; `Cancel` issues a DELETE and throws +`AAuthClarificationCancelledException`. Once `Rounds` reaches `MaxRounds` the next +attempt throws `AAuthClarificationLimitException`. + +## Automatic handling during token exchange + +You rarely need to drive the exchange by hand. The token-exchange request exposes +a callback the SDK invokes whenever the PS asks for clarification, looping until +the PS decides or `MaxClarificationRounds` is hit. + +```csharp +var request = new TokenExchangeRequest +{ + MaxClarificationRounds = ClarificationExchange.DefaultMaxRounds, + OnClarificationRequired = async (requirement, ct) => + { + // requirement.Clarification is untrusted — sanitize before display. + string question = Sanitize(requirement.Clarification); + + if (requirement.Options is { Count: > 0 } options) + { + string choice = await AskUserToPick(question, options); + return ClarificationResponse.Respond(choice); + } + + string answer = await AskUser(question); + return ClarificationResponse.Respond(answer); + }, +}; + +var authToken = await exchangeClient.ExchangeAsync(personServer, resourceToken, request); +``` + +## Automatic handling during governance + +The same pattern applies to the governance clients. Supply +`OnClarificationRequired` (and optionally `MaxClarificationRounds`) on +`GovernanceOptions` when proposing a mission or requesting permission: + +```csharp +var mission = await governance.Mission.ProposeAsync( + "https://ps.example", + new MissionProposal("Reconcile last month's invoices."), + new GovernanceOptions + { + MaxClarificationRounds = 3, + OnClarificationRequired = async (requirement, ct) => + ClarificationResponse.Respond(await AskUser(Sanitize(requirement.Clarification))), + }); +``` + +When the callback is `null` and the server asks for clarification, the request +fails rather than blocking. + +## Further reading + +- [Mission Governance Clients](mission-governance-clients.md) — where governance clarification fits +- [Deferred Consent](../workflows/deferred-consent.md) — the broader deferred-response lifecycle +- [Error Handling](error-handling.md) — `AAuthClarificationCancelledException`, `AAuthClarificationLimitException` +- [Missions](missions.md) — the mission model diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md index a1af6db..648c290 100644 --- a/docs/advanced/error-handling.md +++ b/docs/advanced/error-handling.md @@ -229,6 +229,56 @@ catch (TokenVerificationException ex) } ``` +## Mission Termination + +Once a mission is terminated (the user completed it, or the PS revoked it), the PS +refuses governed requests with `403 mission_terminated` (§Mission Status Errors). +The governance clients surface this as a typed exception. + +```csharp +namespace AAuth.Errors; + +public sealed class AAuthMissionTerminatedException : Exception +{ + public const string ErrorCode = "mission_terminated"; + public string? MissionStatus { get; } // e.g. "terminated" +} +``` + +```csharp +try +{ + await governance.Audit.RecordAsync(ps, record); +} +catch (AAuthMissionTerminatedException ex) +{ + // The mission is over — stop acting under it and start a new one if needed. + Console.WriteLine($"Mission terminated ({ex.MissionStatus})."); +} +``` + +On the PS side, emit the canonical body with +`GovernanceEndpoints.MissionTerminated()` — see +[Mission Governance (Server)](../server/mission-governance.md#terminating-a-mission). + +## Clarification Exceptions + +A [clarification chat](clarification-chat.md) can end in two terminal ways: the +agent withdraws, or the round limit is reached. + +```csharp +namespace AAuth.Agent; + +// The agent called ClarificationResponse.Cancel() / ClarificationExchange.CancelAsync() +public sealed class AAuthClarificationCancelledException : Exception { } + +// The exchange exceeded MaxRounds (default ClarificationExchange.DefaultMaxRounds = 5) +public sealed class AAuthClarificationLimitException : Exception +{ + public int MaxRounds { get; } +} +``` + ## Exception Hierarchy | Exception | Thrown By | Meaning | @@ -239,6 +289,9 @@ catch (TokenVerificationException ex) | `AAuthInteractionDeniedException` | `DeferredPoller` / `ChallengeHandler` | User denied | | `AAuthInteractionTimeoutException` | `DeferredPoller` / `ChallengeHandler` | Polling timed out | | `PollingErrorException` | `DeferredPoller` | PS returned terminal error during polling | +| `AAuthMissionTerminatedException` | `AuditClient` / `InteractionClient` | Mission terminated (`403 mission_terminated`) | +| `AAuthClarificationCancelledException` | `ClarificationExchange` | Agent withdrew during clarification | +| `AAuthClarificationLimitException` | `ClarificationExchange` | Clarification round limit reached | ## Server-Side Error Emission @@ -259,3 +312,5 @@ return Results.Json( - [Verification Middleware](../server/verification-middleware.md) — automatic Signature-Error emission - [Deferred Consent](../workflows/deferred-consent.md) — polling lifecycle - [Configuration Reference](../reference/configuration.md) — timeout and retry settings +- [Mission Governance Clients](mission-governance-clients.md) — where mission/clarification errors arise +- [Clarification Chat](clarification-chat.md) — the clarification exchange diff --git a/docs/advanced/mission-governance-clients.md b/docs/advanced/mission-governance-clients.md new file mode 100644 index 0000000..229f7ef --- /dev/null +++ b/docs/advanced/mission-governance-clients.md @@ -0,0 +1,211 @@ +# Mission Governance Clients + +> [Mission Lifecycle](https://explorer.aauth.dev/missions/lifecycle) + +## Overview + +The mission lifecycle is driven by four agent-side clients that talk to the +Person Server's governance endpoints (§PS Governance Endpoints): + +- `MissionClient` — propose a mission and receive the approved blob. +- `PermissionClient` — ask whether a local action (a tool) is allowed. +- `AuditClient` — report actions the agent has performed. +- `InteractionClient` — reach the user to relay an interaction, ask a question, or close out the mission. + +`AAuthGovernanceClient` bundles all four over a single signed channel. Every +governance request is signed with the agent identity, so the supplied +`HttpClient` must be wired with an `AAuthSigningHandler` carrying the agent +token. The easiest way to get a correctly wired client is +`AAuthClientBuilder.BuildGovernance()`. + +For the mission model itself (the blob, `s256`, the `AAuth-Mission` header), see +[Missions](missions.md). For the PS side of these endpoints, see +[Mission Governance (Server)](../server/mission-governance.md). + +## Building the facade + +```csharp +using AAuth.Agent.Governance; + +// Requires an explicit signing mode; BuildGovernance throws otherwise. +AAuthGovernanceClient governance = new AAuthClientBuilder(key) + .UseJwt(agentToken) + .BuildGovernance(); + +MissionClient missions = governance.Mission; +PermissionClient permissions = governance.Permission; +AuditClient audit = governance.Audit; +InteractionClient interaction = governance.Interaction; +``` + +You can also construct it directly from an already-signed channel: + +```csharp +var governance = new AAuthGovernanceClient(signedClient, metadataClient); +``` + +## Proposing a mission + +The agent sends a Markdown `Description` of its intent plus the tools it wants +pre-approved. The PS may approve all tools, a subset, or none, and may run a +[clarification chat](clarification-chat.md) before approving. + +```csharp +var proposal = new MissionProposal("Book a table for four near the office on Friday.") +{ + Tools = new[] + { + new MissionTool("calendar.read", "Check the user's Friday schedule."), + new MissionTool("email.send", "Send the confirmation to the group."), + }, +}; + +Mission mission = await governance.Mission.ProposeAsync( + personServer: "https://ps.example", + proposal: proposal, + options: new GovernanceOptions + { + OnClarificationRequired = async (requirement, ct) => + ClarificationResponse.Respond("Friday dinner, around 7pm, four people."), + }); + +// mission.ApprovedTools may be a subset of what was proposed. +Console.WriteLine($"Mission {mission.S256} approved by {mission.Approver}."); +``` + +`ProposeAsync` stores the approval body verbatim, computes its `s256`, and +verifies it against the `AAuth-Mission` response header before returning. A +mismatch throws `InvalidOperationException`. + +## Requesting permission for a tool + +Tools are the actions the agent runs itself. Use the mission overload: when the +action matches a pre-approved tool the call resolves locally without a PS +round-trip; otherwise it goes to the PS, which may grant, deny, or prompt the +user (§Permission Endpoint). + +```csharp +PermissionResult result = await governance.Permission.RequestAsync( + personServer: "https://ps.example", + action: "email.send", + mission: mission, + description: "Send the booking confirmation to the four guests."); + +if (result.IsGranted) +{ + // Pre-approved tool → granted locally with reason + // "Pre-approved tool on the active mission." + SendEmail(); +} +else +{ + Console.WriteLine($"Denied: {result.Reason}"); +} +``` + +For an action not on the mission, the PS evaluates it against the mission log and +may prompt the user. Supply `OnInteractionRequired` / `OnClarificationRequired` +via `GovernanceOptions` to participate in any deferral. + +```csharp +var request = new PermissionRequest("files.delete") +{ + Description = "Remove the stale draft the user mentioned.", + Parameters = new JsonObject { ["path"] = "/drafts/old.md" }, + Mission = new MissionClaim(mission.Approver, mission.S256), +}; + +PermissionResult outcome = await governance.Permission.RequestAsync( + "https://ps.example", request); +``` + +## Recording an audit entry + +Auditing happens after the fact and always requires a mission. It is +fire-and-forget — the PS acknowledges with `201 Created`. A terminated mission +surfaces as `AAuthMissionTerminatedException` (see +[Error Handling](error-handling.md#mission-termination)). + +```csharp +await governance.Audit.RecordAsync( + personServer: "https://ps.example", + record: new AuditRecord( + Mission: new MissionClaim(mission.Approver, mission.S256), + Action: "email.send") + { + Description = "Sent booking confirmation to 4 recipients.", + Result = new JsonObject { ["messageId"] = "msg-8842" }, + }); +``` + +## Reaching the user + +The interaction endpoint is how the agent reaches the user through the PS: +relay a resource interaction it cannot satisfy itself, forward a payment, ask a +question, or propose mission completion. Each request type resolves to a typed +`InteractionResult`. + +```csharp +var missionClaim = new MissionClaim(mission.Approver, mission.S256); + +// Ask the user a clarifying question mid-mission. +string? answer = await governance.Interaction.AskQuestionAsync( + "https://ps.example", + question: "Window seat or booth?", + mission: missionClaim); + +// Relay a resource interaction (e.g. a payment-style confirmation URL + code). +await governance.Interaction.RelayInteractionAsync( + "https://ps.example", + url: "https://resource.example/confirm/abc", + code: "4821", + description: "Confirm the reservation.", + mission: missionClaim); + +// Propose completion; true when the user accepted and the PS terminated the mission. +bool done = await governance.Interaction.ProposeCompletionAsync( + "https://ps.example", + summary: "Booked Table 12 for four at 7pm Friday and emailed the group.", + mission: missionClaim); +``` + +`SendAsync` returns an `InteractionResult` whose populated fields depend on the +type: `question` fills `Answer`, `completion` fills `Terminated`, and +`interaction`/`payment` resolve once the user completes. + +## A full lifecycle + +```csharp +var governance = new AAuthClientBuilder(key).UseJwt(agentToken).BuildGovernance(); +const string ps = "https://ps.example"; + +// 1. Propose → approve +var mission = await governance.Mission.ProposeAsync(ps, + new MissionProposal("Tidy the user's reading list.") + { + Tools = new[] { new MissionTool("bookmarks.archive") }, + }); +var claim = new MissionClaim(mission.Approver, mission.S256); + +// 2. Permission for a pre-approved tool → granted silently +var perm = await governance.Permission.RequestAsync(ps, "bookmarks.archive", mission); + +// 3. Do the work, then audit it +await governance.Audit.RecordAsync(ps, + new AuditRecord(claim, "bookmarks.archive") + { + Result = new JsonObject { ["archived"] = 12 }, + }); + +// 4. Close the mission out +bool terminated = await governance.Interaction.ProposeCompletionAsync( + ps, "Archived 12 stale bookmarks.", claim); +``` + +## Further reading + +- [Missions](missions.md) — the mission model and binding chain +- [Clarification Chat](clarification-chat.md) — answering PS follow-ups during approval +- [Mission Governance (Server)](../server/mission-governance.md) — the PS-side seams +- [Mission-Governed Access](../workflows/mission-governed-access.md) — end-to-end walkthrough +- [Dependency Injection](../reference/dependency-injection.md#governance) — registering the governance clients diff --git a/docs/advanced/missions.md b/docs/advanced/missions.md index 396b4e4..3123618 100644 --- a/docs/advanced/missions.md +++ b/docs/advanced/missions.md @@ -4,138 +4,200 @@ ## Overview -A mission is a structured, multi-step authorization negotiation between agent and resource. Unlike a single challenge/response, missions allow the resource to declare requirements progressively and the agent to fulfill them over multiple round-trips. - -## Mission +A mission is an **optional governance layer** that scopes everything an agent +does over a period of work to a single, user-approved intent. The agent +proposes a mission — a Markdown **description** of what it intends to accomplish, +plus an optional list of **tools** it wants to use — and the Person Server (PS) +approves it (§Mission Creation, §Mission Approval). + +Once approved, the mission becomes the **context the PS evaluates every later +request against**. The PS is the contextual policy point: it judges each token +request and permission request against the mission's natural-language intent and +the running mission log, granting silently when a request fits, prompting the +user when it does not, and refusing once the mission is terminated. + +Missions are orthogonal to the underlying access flows. Existing no-mission +flows remain valid; a mission is "a further restriction applied by the PS" +(§Rationale). For the agent-side governance clients that drive the lifecycle, +see [Mission Governance Clients](mission-governance-clients.md); for the PS-side +seams, see [Mission Governance (Server)](../server/mission-governance.md). + +## The mission blob + +An approved mission is a **blob** — the exact JSON body the PS returns from its +`mission_endpoint`. The agent stores those bytes verbatim so the mission's +identity (`s256`) stays verifiable. ```csharp namespace AAuth.Agent; public sealed class Mission { - public required string Id { get; init; } - public required string Status { get; init; } // "pending", "approved", "denied", "completed" - public JsonArray? Requirements { get; init; } // outstanding requirements - public string? Description { get; init; } // human-readable description - public string? StatusUrl { get; init; } // poll for status changes - public string? InteractionUrl { get; init; } // user-facing approval page - - public static Mission FromJson(JsonObject json); + public required string Approver { get; init; } // HTTPS URL of the PS that approved it + public required string Agent { get; init; } // aauth:local@domain the mission is for + public required DateTimeOffset ApprovedAt { get; init; } // approval timestamp (keeps s256 unique) + public required string Description { get; init; } // Markdown intent + public IReadOnlyList ApprovedTools { get; init; } // pre-approved tools (may be a subset) + public IReadOnlyList Capabilities { get; init; } // capabilities the PS provides for the session + public required string S256 { get; init; } // base64url(SHA-256(blob)) — the identity + public ReadOnlyMemory RawBytes { get; init; } // verbatim approval body bytes + + public MissionState State { get; init; } = MissionState.Active; + + public static Mission FromApprovalBytes(ReadOnlySpan body); // parse + compute s256 + public bool VerifyS256(string expected); // constant-time compare + public static string ComputeS256(ReadOnlySpan body); } + +public enum MissionState { Active, Terminated } + +public sealed record MissionTool(string Name, string? Description = null); ``` -## AAuthMissionHeader +### Identity: `s256` -Resources propose missions via the `AAuth-Mission` response header: +The mission's identity is its `s256`: the base64url-encoded SHA-256 hash of the +exact approval body bytes (§Mission Approval). Because the hash is computed over +the bytes as received, the agent must **never re-serialize** the blob — it keeps +`RawBytes` and recomputes from those when verifying. ```csharp -public static class AAuthMissionHeader +// Build a Mission from the bytes the PS returned and verify the header's s256. +var mission = Mission.FromApprovalBytes(approvalBodyBytes); + +if (!mission.VerifyS256(headerS256)) { - public const string Name = "AAuth-Mission"; - public static string Format(string missionId); + throw new InvalidOperationException("Mission s256 mismatch."); } ``` -## Mission Lifecycle +### Two states -```mermaid -sequenceDiagram - participant Agent - participant Resource - Agent->>Resource: GET /data (signed) - Resource-->>Agent: 401 + AAuth-Mission: mission-123 - Note over Agent: Parse mission from response body - Agent->>Resource: GET /data (signed, AAuth-Mission: mission-123) - Resource-->>Agent: 200 OK (mission complete) -``` +A mission is either `Active` or `Terminated` (§Mission Management). There is no +`pending`/`denied`/`completed` ladder: approval produces an active mission, and +the PS moves it to terminated on completion or revocation. After termination the +PS answers governed requests with `mission_terminated` (see +[Error Handling](error-handling.md#mission-termination)). -## Parsing a Mission Response +### Tools vs scopes -```csharp -var response = await client.GetAsync("https://resource.example/data"); +A mission governs two kinds of authority **asymmetrically** — the central idea: + +- **Tools are *declared*.** A tool is an action the agent runs itself (a tool + call, file write, sending a message) — no resource is involved. The PS cannot + observe a local action, so the mission names tools up front: `ApprovedTools` + are pre-approved and resolve at the permission endpoint without a PS + round-trip; any other action is referred to the user (§Permission Endpoint). +- **Scopes are *evaluated*, never declared.** A scope authorizes access to a + remote resource, carried in an auth token through the challenge → exchange → + retry pattern (§Scopes). A mission proposal contains **no scopes**. When the + agent later exchanges a resource token, the PS judges the requested scope + against the mission's description: if it fits, it is granted silently and + remembered for the rest of the mission; otherwise the user is prompted. + +See [Protocol Concepts → Governance](../concepts.md) for the full discussion. -if (response.StatusCode == HttpStatusCode.Unauthorized) +## The `AAuth-Mission` header + +The agent declares its mission context on outbound requests with the structured +`AAuth-Mission` header, carrying the `approver` and `s256` (§Call Chaining). The +mission content never leaves the PS — only the pointer travels. + +```csharp +public static class AAuthMissionHeader { - var missionHeader = response.Headers.GetValues(AAuthMissionHeader.Name).FirstOrDefault(); - if (missionHeader is not null) - { - var body = await response.Content.ReadFromJsonAsync(); - var mission = Mission.FromJson(body!); - - Console.WriteLine($"Mission: {mission.Id}"); - Console.WriteLine($"Status: {mission.Status}"); - Console.WriteLine($"Requirements: {mission.Requirements}"); - } + public const string Name = "AAuth-Mission"; + + // Produces: approver="https://ps.example"; s256="dBjf..." + public static string FormatStructured(string approver, string s256); + public static bool TryParseStructured(string? value, out string? approver, out string? s256); } ``` -## Sending a Mission Header - -When continuing a mission, include the mission ID: - ```csharp var request = new HttpRequestMessage(HttpMethod.Get, "https://resource.example/data"); -request.Headers.Add(AAuthMissionHeader.Name, AAuthMissionHeader.Format(missionId)); +request.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); var response = await signedClient.SendAsync(request); ``` -## Server-Side: Proposing a Mission +## The binding chain + +The mission travels end to end as a `MissionClaim` — `{ approver, s256 }` — +embedded in tokens (§Resource Token Structure, §Auth Token Structure): ```csharp -app.MapGet("/data", (HttpContext context) => +namespace AAuth.Tokens; + +public sealed record MissionClaim(string Approver, string S256) { - var missionId = context.Request.Headers[AAuthMissionHeader.Name].FirstOrDefault(); - - if (missionId is null) - { - // Propose a new mission - var newMission = new { id = Guid.NewGuid().ToString(), status = "pending", - requirements = new[] { new { type = "auth_token", scope = "read" } } }; - context.Response.Headers.Append(AAuthMissionHeader.Name, - AAuthMissionHeader.Format(newMission.id)); - return Results.Json(newMission, statusCode: 401); - } - - // Mission in progress — check if requirements are met - return Results.Ok("Access granted"); -}); + public JsonObject ToJsonObject(); + public static MissionClaim? FromPayload(JsonObject? payload); +} ``` -## Further Reading +The chain is: -- [PS-Asserted Access](../workflows/ps-asserted-access.md) — single-exchange alternative -- [Deferred Consent](../workflows/deferred-consent.md) — user approval patterns -- [Call Chaining](../workflows/call-chaining.md) — multi-hop delegation with mission forwarding - -## Auto-Forwarding in Call Chains +```mermaid +sequenceDiagram + participant Agent + participant PS as Person Server + participant Resource as Mission-aware Resource + + Agent->>PS: POST mission_endpoint (propose) + PS-->>Agent: 200 mission blob + AAuth-Mission: approver, s256 + Note over Agent: store RawBytes, verify s256 + + Agent->>Resource: GET /data (signed, AAuth-Mission: approver, s256) + Resource-->>Agent: 401 + resource token (mission claim copied in) + Agent->>PS: POST token_endpoint (resource token) + Note over PS: evaluate requested scope vs mission intent + PS-->>Agent: auth token (mission claim echoed) + Agent->>Resource: GET /data (signed, auth token) + Resource-->>Agent: 200 OK +``` -When an intermediary resource calls downstream resources within a mission context, it must forward the `AAuth-Mission` header so the PS can evaluate the downstream request against the mission scope. The SDK handles this automatically via `MissionForwardingHandler`. +A **mission-aware resource** copies the mission object from the `AAuth-Mission` +header into the resource token it issues, so the mission context reaches the PS +even when the resource is not the approver (§Terminology). Enable it with +`ChallengeOptions.MissionAware` — see +[Challenge Middleware](../server/challenge-middleware.md#mission-aware-resources). -### Automatic (via WithCallChaining) +## Forwarding a mission in a call chain -When `WithCallChaining` is configured, mission forwarding is enabled automatically: +When an intermediary resource calls downstream resources within a mission +context, it must forward the `AAuth-Mission` header so the downstream PS can +evaluate against the same mission. The SDK does this automatically via +`MissionForwardingHandler`, which reads `mission.approver` and `mission.s256` +from the upstream auth token and sets the structured header on every downstream +request. ```csharp using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(refreshFunc) + .UseJwt(agentToken) .WithCallChaining(httpContext) // also enables mission forwarding .Build(); -// If the upstream auth token contains mission.approver + mission.s256, -// all downstream requests include AAuth-Mission header automatically. +// If the upstream auth token carries mission.approver + mission.s256, +// downstream requests include AAuth-Mission automatically. await client.GetAsync("https://downstream.example"); ``` -### How It Works - -The `MissionForwardingHandler` reads the upstream auth token's claims: -- `mission.approver` — the PS that governs the mission -- `mission.s256` — the mission content hash +The handler formats the structured header on every downstream request: -It formats these into a structured `AAuth-Mission` header on every downstream request: - -``` +```text AAuth-Mission: approver="https://ps.example"; s256="abc123..." ``` -This ensures the PS receiving the downstream exchange has full mission context for policy evaluation, enabling governed multi-hop access per §Call Chaining. +This gives the PS receiving the downstream exchange full mission context for +policy evaluation, enabling governed multi-hop access (§Call Chaining). See +[Call Chaining](../workflows/call-chaining.md) for the full multi-hop flow. + +## Further reading + +- [Mission Governance Clients](mission-governance-clients.md) — propose, request permission, audit, interact +- [Clarification Chat](clarification-chat.md) — answering the PS's follow-up questions during approval +- [Mission Governance (Server)](../server/mission-governance.md) — the PS-side policy seams +- [Mission-Governed Access](../workflows/mission-governed-access.md) — an end-to-end walkthrough +- [Error Handling](error-handling.md#mission-termination) — `mission_terminated` diff --git a/docs/concepts.md b/docs/concepts.md index 13b22b9..4534328 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -51,7 +51,7 @@ The two kinds of authority a mission governs are handled **asymmetrically**, and In short: **a mission lists the tools the agent may run locally, but it does not list scopes — the PS decides, per request, whether a requested resource scope fits the mission's intent.** Scopes and AS policy stay enforced by the resource and its Access Server; the mission is "a further restriction applied by the PS" (§Rationale). -See [Missions](https://explorer.aauth.dev/missions/compare). +See [Missions](https://explorer.aauth.dev/missions/compare). For the SDK surface, see [Missions](advanced/missions.md), [Mission Governance Clients](advanced/mission-governance-clients.md), and [Mission Governance (Server)](server/mission-governance.md). ## Token Types diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 2c9f15f..6e5beda 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -381,3 +381,41 @@ var client = new AAuthClientBuilder(key) - Passes `upstream_token` in exchange POST body - Inserts `MissionForwardingHandler` to propagate `AAuth-Mission` headers - Handles the full 401 → exchange → retry cycle + +## Governance + +### Agent side: the governance client + +The mission governance clients (mission, permission, audit, interaction) are built +from `AAuthClientBuilder`, which wires the signed channel for you. There is no +dedicated DI extension — register the facade as a singleton or build it where you +need it. + +```csharp +builder.Services.AddSingleton(sp => + new AAuthClientBuilder(agentKey) + .UseJwt(agentToken) + .BuildGovernance()); // AAuthGovernanceClient +``` + +`BuildGovernance()` requires an explicit signing mode and throws +`InvalidOperationException` otherwise. See +[Mission Governance Clients](../advanced/mission-governance-clients.md). + +### Person Server side: the governance seams + +`AddAAuthGovernance()` registers the in-memory mission storage seams as +singletons. It uses `TryAdd`, so register durable implementations first to +override them. The policy and user-channel seams (`IPermissionDecider`, +`IAuditSink`, `IInteractionRelay`) are always supplied by the PS. + +```csharp +builder.Services.AddAAuthGovernance(); // InMemoryMissionStore + InMemoryMissionLog + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +See [Mission Governance (Server)](../server/mission-governance.md) for the seams +and the decision model. diff --git a/docs/server/challenge-middleware.md b/docs/server/challenge-middleware.md index bed7d34..0c89e5d 100644 --- a/docs/server/challenge-middleware.md +++ b/docs/server/challenge-middleware.md @@ -63,9 +63,35 @@ public sealed class ChallengeOptions // Allowed Signature-Key schemes (null = allow all) public IReadOnlySet? AllowedSignatureKeySchemes { get; init; } + + // When true, copy the AAuth-Mission header's mission object into the + // issued resource token so the mission context flows to the PS (default false) + public bool MissionAware { get; init; } } ``` +## Mission-Aware Resources + +Set `MissionAware = true` to make the resource carry mission context forward. When +a challenged request includes a valid `AAuth-Mission` header, the issued resource +token includes the mission object (`approver` + `s256`), so the mission reaches the +PS even when the resource is not the approver (§Terminology). When `false` (the +default) the header is ignored. + +```csharp +app.UseAAuthChallenge(new ChallengeOptions +{ + AccessMode = AAuthAccessMode.RequireAuthToken, + ResourceSigningKey = resourceKey, + ResourceIdentifier = resourceUrl, + MissionAware = true, // copy AAuth-Mission into the resource token +}); +``` + +See [Missions](../advanced/missions.md#the-binding-chain) for how the mission +claim threads through the tokens, and +[Token Issuance](token-issuance.md#mission-claims) for the claim itself. + ## Typical Pipeline ```csharp diff --git a/docs/server/mission-governance.md b/docs/server/mission-governance.md new file mode 100644 index 0000000..ce7b6b9 --- /dev/null +++ b/docs/server/mission-governance.md @@ -0,0 +1,235 @@ +# Mission Governance (Server) + +> [Mission Lifecycle](https://explorer.aauth.dev/missions/lifecycle) + +## Overview + +The Person Server is the contextual policy point for missions. It approves +missions, then evaluates every later request against the mission's +natural-language intent and the running mission log (§PS Governance Endpoints). +The SDK supplies the request/response parsing, the storage and log seams, and the +decision vocabulary; the PS owns the policy and the user channel. + +This split is deliberate. A mission is a Markdown statement of intent, not a +machine-checkable rule set. The PS decides each request in context — the SDK +never tries to evaluate the mission for you. See +[Missions](../advanced/missions.md) for the agent-side model and the +`AAuth-Mission` header, and +[Mission Governance Clients](../advanced/mission-governance-clients.md) for the +calls the PS answers. + +## Registering the seams + +`AddAAuthGovernance` registers the in-memory storage defaults. It uses `TryAdd`, +so a PS can register durable implementations first and keep the rest. + +```csharp +using Microsoft.Extensions.DependencyInjection; + +builder.Services.AddAAuthGovernance(); // InMemoryMissionStore + InMemoryMissionLog + +// The policy and user-channel seams are supplied by the PS: +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +| Seam | Default | Who owns it | +|------|---------|-------------| +| `IMissionStore` | `InMemoryMissionStore` | SDK default; swap for durable storage | +| `IMissionLog` | `InMemoryMissionLog` | SDK default; swap for durable storage | +| `IPermissionDecider` | none | PS supplies policy | +| `IAuditSink` | none | PS supplies storage/alerting | +| `IInteractionRelay` | none | PS supplies the user channel | + +## Parsing requests + +`GovernanceEndpoints` maps request bodies to the shared DTOs and emits the +canonical `mission_terminated` response, so endpoints avoid hand-rolled parsing. + +```csharp +using AAuth.Server.Governance; + +app.MapPost("/aauth/permission", async (HttpContext ctx, IPermissionDecider decider, + IMissionStore store, IMissionLog log) => +{ + var body = await ctx.Request.ReadFromJsonAsync(); + PermissionRequest request = GovernanceEndpoints.ParsePermission(body!); + + StoredMission? mission = request.Mission is { } claim + ? await store.GetAsync(claim.S256) + : null; + + if (mission is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); // 403 mission_terminated + } + + var entries = mission is null + ? Array.Empty() + : await log.ReadAsync(mission.S256); + + var decision = await decider.DecideAsync( + new PermissionDecisionContext(request, mission, entries)); + + return decision.Outcome switch + { + PermissionOutcome.Granted => Results.Json(new { permission = "granted" }), + PermissionOutcome.Denied => Results.Json(new { permission = "denied", reason = decision.Message }), + _ => Results.Accepted(), // Prompt → defer to the user channel + }; +}); +``` + +The parsers throw `FormatException` on a missing required field +(`ParsePermission` needs `action`, `ParseAudit` needs `mission` + `action`, +`ParseInteraction` needs a valid `type`, `ParseMissionProposal` needs +`description`). + +## Persisting missions: `IMissionStore` + +A mission is stored as its verbatim approval bytes plus its lifecycle state, so +the `s256` stays verifiable. + +```csharp +public sealed record StoredMission(string S256, string Approver, string Agent, ReadOnlyMemory Blob) +{ + public MissionState State { get; init; } = MissionState.Active; +} + +public interface IMissionStore +{ + Task SaveAsync(StoredMission mission, CancellationToken ct = default); + Task GetAsync(string s256, CancellationToken ct = default); + Task SetStateAsync(string s256, MissionState state, CancellationToken ct = default); // e.g. on completion/revocation +} +``` + +## The mission log: `IMissionLog` + +The log is the ordered record of what the agent did and what the PS decided. The +PS reads it to judge whether each new request is consistent with the mission, and +to resolve repeat requests silently via prior consent (§Mission Log, §Agent Token +Request). + +```csharp +public enum MissionLogEntryKind { Token, Permission, Audit, Interaction, Clarification } + +public sealed record MissionLogEntry(string S256, MissionLogEntryKind Kind, DateTimeOffset Timestamp) +{ + public string? Resource { get; init; } // for token entries — prior-consent lookup + public string? Scope { get; init; } // for token entries — prior-consent lookup + public string? Action { get; init; } // for permission/audit entries + public bool? Granted { get; init; } // governance decision + public string? Detail { get; init; } // justification or clarification text +} + +public interface IMissionLog +{ + Task AppendAsync(MissionLogEntry entry, CancellationToken ct = default); + Task> ReadAsync(string s256, CancellationToken ct = default); + Task HasPriorConsentAsync(string s256, string resource, string scope, CancellationToken ct = default); +} +``` + +## The decision: three gates + +When the agent requests an auth token or a permission, the PS reaches one of +three outcomes (§Permission Endpoint, §Agent Token Request). The SDK supplies the +outcome and reason enums; the PS supplies the policy in `IPermissionDecider`. + +```csharp +public enum PermissionOutcome { Granted, Denied, Prompt } +public enum PermissionDecisionReason { InScope, PriorConsent, ApprovedTool, OutOfScope } + +public sealed record PermissionDecision( + PermissionOutcome Outcome, + PermissionDecisionReason Reason, + string? Message = null); +``` + +The gates, in order: + +1. **Granted silently** when the request fits the mission — a pre-approved tool + (`ApprovedTool`), a scope within the mission's intent (`InScope`), or a + repeat of something the user already consented to (`PriorConsent`). +2. **Prompt the user** when the action is outside known scope (`OutOfScope`) — + the PS defers and reaches the user before deciding. +3. **Denied** only on an explicit user denial, or refused with `403 + mission_terminated` once the mission is terminated. + +```csharp +public sealed class MyPermissionDecider : IPermissionDecider +{ + private readonly IMissionLog _log; + public MyPermissionDecider(IMissionLog log) => _log = log; + + public async Task DecideAsync( + PermissionDecisionContext context, CancellationToken ct = default) + { + var mission = context.Mission; + if (mission is null) + { + return new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope); + } + + // Pre-approved tool → granted silently. + var blob = Mission.FromApprovalBytes(mission.Blob.Span); + if (blob.ApprovedTools.Any(t => t.Name == context.Request.Action)) + { + return new PermissionDecision(PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool); + } + + // Otherwise it is the PS's contextual judgement — here, prompt the user. + return new PermissionDecision( + PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope, + "This action was not part of the approved mission."); + } +} +``` + +## Audit and interaction sinks + +The audit sink records what the agent reports and MAY alert the user or revoke +the mission; the interaction relay reaches the user for the PS. + +```csharp +public interface IAuditSink +{ + Task RecordAsync(AuditRecord record, CancellationToken ct = default); +} + +public sealed record InteractionRelayResult +{ + public string? Answer { get; init; } // for question + public bool? Accepted { get; init; } // for completion — true terminates the mission + public bool Pending { get; init; } // defer + let the agent poll +} + +public interface IInteractionRelay +{ + Task RelayAsync(InteractionRequest request, CancellationToken ct = default); +} +``` + +## Terminating a mission + +When a mission is terminated, the PS moves it to `MissionState.Terminated` and +answers governed requests with the canonical error (§Mission Status Errors). The +agent's `AuditClient` / `InteractionClient` surface this as +`AAuthMissionTerminatedException`. + +```csharp +await store.SetStateAsync(s256, MissionState.Terminated); + +// Canonical 403 response body: { "error": "mission_terminated", "mission_status": "terminated" } +return GovernanceEndpoints.MissionTerminated(); +``` + +## Further reading + +- [Missions](../advanced/missions.md) — the mission model and `AAuth-Mission` header +- [Mission Governance Clients](../advanced/mission-governance-clients.md) — the agent calls the PS answers +- [Mission-Governed Access](../workflows/mission-governed-access.md) — end-to-end walkthrough +- [Token Issuance](token-issuance.md#mission-claims) — emitting the mission claim in tokens +- [Dependency Injection](../reference/dependency-injection.md#governance) — registering the seams diff --git a/docs/server/token-issuance.md b/docs/server/token-issuance.md index 49e9e3e..1d00cc2 100644 --- a/docs/server/token-issuance.md +++ b/docs/server/token-issuance.md @@ -174,7 +174,37 @@ issued auth token only from the verified payload. The shipped [`samples/MockPersonServer`](../../samples/MockPersonServer/) `/token` handler follows exactly this pattern. +## Mission Claims + +When a request is governed by a mission, the mission travels through the tokens as +a `mission` claim — `{ approver, s256 }` — never the mission content itself +(§Resource Token Structure, §Auth Token Structure). The SDK models it with +`MissionClaim`: + +```csharp +namespace AAuth.Tokens; + +public sealed record MissionClaim(string Approver, string S256) +{ + public JsonObject ToJsonObject(); + public static MissionClaim? FromPayload(JsonObject? payload); +} +``` + +A mission-aware resource copies the mission object from the `AAuth-Mission` +request header into the resource token it issues, so the mission context reaches +the PS even when the resource is not the approver. Enable it with +`ChallengeOptions.MissionAware` — see +[Challenge Middleware](challenge-middleware.md#mission-aware-resources). The PS +echoes the same claim into the auth token it mints. When verifying a presented +resource token the recipient MAY constrain `mission.approver` via +`expectedApprover` (check 7 above). + +For the full PS-side evaluation of mission context, see +[Mission Governance (Server)](mission-governance.md). + ## Further Reading - [Verification Middleware](verification-middleware.md) — signature verification before token logic - [Replay Detection](replay-detection.md) — using `jti` to prevent reuse +- [Mission Governance (Server)](mission-governance.md) — evaluating mission context at the PS diff --git a/docs/workflows/call-chaining.md b/docs/workflows/call-chaining.md index 9bce880..cddc689 100644 --- a/docs/workflows/call-chaining.md +++ b/docs/workflows/call-chaining.md @@ -349,3 +349,4 @@ bool valid = ActChainBuilder.ValidateChain(nestedAct, maxDepth: 10); - [Interaction Chaining](../advanced/interaction-chaining.md) — propagating a downstream consent requirement back up the chain when a hop needs human approval (the multi-actor, human-in-the-loop variant of this workflow). - [Deferred Consent](deferred-consent.md) — agent-side handling of a `202` interaction requirement. +- [Missions](../advanced/missions.md#forwarding-a-mission-in-a-call-chain) — how `MissionForwardingHandler` carries the `AAuth-Mission` header across hops. diff --git a/docs/workflows/mission-governed-access.md b/docs/workflows/mission-governed-access.md new file mode 100644 index 0000000..bf5b585 --- /dev/null +++ b/docs/workflows/mission-governed-access.md @@ -0,0 +1,158 @@ +# Mission-Governed Access + +> [Mission Lifecycle](https://explorer.aauth.dev/missions/lifecycle) + +## Scenario + +An agent runs a multi-step task on the user's behalf, governed by a single +approved mission. Throughout the task it mixes two kinds of authority: + +- **Resource access** (scopes) — reading and writing remote APIs, carried in auth + tokens through the usual challenge → exchange → retry pattern. +- **Local actions** (tools) — things the agent does itself, gated at the PS + permission endpoint. + +The PS evaluates each step against the mission's intent: fitting requests resolve +silently, out-of-mission requests prompt the user, and once the mission is +terminated everything is refused. This walkthrough ties together +[Missions](../advanced/missions.md), +[Mission Governance Clients](../advanced/mission-governance-clients.md), and +[Mission Governance (Server)](../server/mission-governance.md). + +## The flow at a glance + +```mermaid +sequenceDiagram + participant Agent + participant PS as Person Server + participant Resource as Mission-aware Resource + + Agent->>PS: 1. propose mission + PS-->>Agent: mission blob + AAuth-Mission (approver, s256) + + Agent->>Resource: 2. GET /data (signed, AAuth-Mission) + Resource-->>Agent: 401 + resource token (mission copied in) + Agent->>PS: 3. exchange resource token + Note over PS: scope fits intent → grant silently + PS-->>Agent: auth token (mission echoed) + Agent->>Resource: GET /data (auth token) → 200 + + Agent->>PS: 4. permission: out-of-mission tool + Note over PS: out of scope → prompt user + PS-->>Agent: granted / denied + + Agent->>PS: 5. audit the action + Agent->>PS: 6. propose completion + Note over PS: user accepts → mission terminated +``` + +## 1. Propose the mission + +The agent states its intent and the tools it wants pre-approved. The PS may run a +[clarification chat](../advanced/clarification-chat.md) before approving. + +```csharp +var governance = new AAuthClientBuilder(key).UseJwt(agentToken).BuildGovernance(); +const string ps = "https://ps.example"; + +Mission mission = await governance.Mission.ProposeAsync(ps, + new MissionProposal("Reconcile this month's expense receipts and email a summary.") + { + Tools = new[] { new MissionTool("email.send", "Email the reconciliation summary.") }, + }); + +var missionClaim = new MissionClaim(mission.Approver, mission.S256); +``` + +## 2–3. Access a resource (scope evaluated in context) + +Resource access uses the ordinary access flow with the `AAuth-Mission` header +added. A mission-aware resource copies the mission into its resource token, so the +PS sees the mission when it evaluates the requested scope. If the scope fits the +mission's intent, the PS grants the auth token silently and remembers the decision +for the rest of the mission. + +```csharp +using var client = new AAuthClientBuilder(key) + .UseJwt(agentToken) + .WithChallengeHandling(ps) + .Build(); + +var request = new HttpRequestMessage(HttpMethod.Get, "https://expenses.example/receipts"); +request.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); + +// Challenge → exchange → retry happens transparently; the PS judged the scope +// against the mission intent during the exchange. +var response = await client.SendAsync(request); +``` + +A later request for a scope the PS has not seen and that does not fit the intent +is deferred to the user (gate 2). See +[Token Issuance](../server/token-issuance.md#mission-claims) for how the resource +and PS carry the mission claim through the tokens. + +## 4. Request permission for a local tool + +A tool is an action the agent runs itself. Pre-approved tools resolve locally; any +other action goes to the PS, which prompts the user when it is out of mission. + +```csharp +// Pre-approved tool → granted silently. +var send = await governance.Permission.RequestAsync(ps, "email.send", mission); + +// Out-of-mission tool → the PS prompts the user (gate 3). +var delete = await governance.Permission.RequestAsync(ps, "files.delete", mission, + description: "Remove the duplicate receipt the user flagged."); + +if (!delete.IsGranted) +{ + Console.WriteLine($"Not allowed: {delete.Reason}"); +} +``` + +## 5. Audit what happened + +After acting, the agent reports it. Auditing always carries the mission and is +fire-and-forget. + +```csharp +await governance.Audit.RecordAsync(ps, + new AuditRecord(missionClaim, "email.send") + { + Description = "Emailed the reconciliation summary to the user.", + Result = new JsonObject { ["recipients"] = 1 }, + }); +``` + +## 6. Close the mission out + +When the work is done the agent proposes completion. The user accepts the summary, +and the PS terminates the mission. + +```csharp +bool terminated = await governance.Interaction.ProposeCompletionAsync( + ps, + summary: "Reconciled 24 receipts (2 duplicates removed) and emailed the summary.", + mission: missionClaim); +``` + +After termination, any further governed request returns `403 mission_terminated`, +surfaced to the agent as `AAuthMissionTerminatedException` (see +[Error Handling](../advanced/error-handling.md#mission-termination)). + +## The binding chain + +Across all of these steps the mission travels as the same `{ approver, s256 }` +pair: declared on requests via the `AAuth-Mission` header, copied into the +resource token by a mission-aware resource, and echoed into the auth token by the +PS. The mission content never leaves the PS — only the pointer and its hash do. + +## Further reading + +- [Missions](../advanced/missions.md) — the mission model and binding chain +- [Mission Governance Clients](../advanced/mission-governance-clients.md) — the agent-side clients +- [Mission Governance (Server)](../server/mission-governance.md) — the PS-side seams and three gates +- [Clarification Chat](../advanced/clarification-chat.md) — refining intent during approval +- [Call Chaining](call-chaining.md) — forwarding a mission across hops From 47ce1efa84b826e9993209efdab0968cd5e5c39f Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 17:33:23 +0000 Subject: [PATCH 12/24] docs(samples): document mission governance in sample READMEs - samples/README.md: add MissionAgent to the index (Nine -> Ten) and a mission Quick Start (make demo-mission / agent-mission). - MockPersonServer/README.md: add an "Agent governance (missions)" section covering AddAAuthGovernance + the four governance endpoints, three-gate consent, and mission-aware resource token copying (cites the spec's protocol exchange diagram + Resource Token Verification). - GuidedTour/README.md: document the Mission (PS-Governed) mode (Six -> Seven flows) and add Mission/Federated to the Mode config enum. - Annotate the call-chain samples (Orchestrator Program.cs, SampleApp CallChain.razor) that mission governance is optional and orthogonal to call chaining and is intentionally omitted from the demo; the samples follow the spec's "no mission, iss is a PS" path (Section: Call Chaining). - Update the implementation plan: tick the Sample READMEs DoD, correct the inaccurate "MockPersonServer done in 6a" annotation, and record the user decision to defer the Orchestrator mission-governed hop. Phase 6c of missions/PS governance. --- .../implementation-plan.md | 23 ++++++++++++--- samples/GuidedTour/README.md | 11 ++++++-- samples/MockPersonServer/README.md | 28 +++++++++++++++++++ samples/Orchestrator/Program.cs | 9 ++++++ samples/README.md | 14 +++++++++- .../Components/Pages/CallChain.razor | 16 ++++++++++- 6 files changed, 93 insertions(+), 8 deletions(-) diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index 0584b3f..40c3833 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -462,8 +462,14 @@ rewrite of existing flows. `samples/MissionAgent/` CLI; the 12-row Consent-Matrix .NET integration test. - **6b — Blazor + e2e (DONE):** SampleApp `Mission.razor` (+ Home link); GuidedTour `TourMode.Mission` (+ snippets, sequence diagram); the two Playwright specs. - - **6c — Glue (PENDING):** Orchestrator mission hop; `make demo-mission` / `e2e-mission`; - READMEs + `tests/e2e/package.json` script. + - **6c — Glue (DONE, except deferred hop):** `make demo-mission` / `agent-mission` + (pulled into 6a); READMEs updated (samples index + MockPersonServer governance + section + GuidedTour Mission mode); call-chain samples annotated that mission + governance is optional/orthogonal and omitted (§Call Chaining no-mission path). + **Deferred (2026-06-06, user decision):** the Orchestrator mission-governed + downstream hop is NOT implemented; instead the call-chain samples carry comments + explaining missions are optional and left out of the demo. The existing + Orchestrator already follows the spec's valid "no mission, `iss` is a PS" path. - **Deterministic consent scripting (agreed 2026-06-05 — option A):** the integration test drives mission-approval / token / permission outcomes by extending the existing unsigned `/admin/*` demo pattern (e.g. @@ -519,7 +525,10 @@ mission-log **decision reason**. - [x] The PS decision reason (in-scope / prior consent / `approved_tools` / out-of-scope) is visible in both samples so the contrast between prompted and silent gates is observable. _(6b)_ -- [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining). _(6c)_ +- [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining). + _(6c — DEFERRED by user decision 2026-06-06; call-chain samples instead carry + comments noting mission governance is optional/orthogonal and omitted. The + Orchestrator follows the spec's valid "no mission, `iss` is a PS" path.)_ - [x] `make demo-mission` boots the mission demo; existing `make demo` unchanged. _(pulled forward from 6c; also added `agent-mission` runner)_ - [x] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the @@ -531,7 +540,13 @@ mission-log **decision reason**. recorded decision reason. _(6a — `MissionAgentFlowTests`, 12/12)_ - [x] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally. _(CLI integration 12/12; Blazor e2e full suite 29 passed / 1 skipped locally; CI not separately run)_ -- [ ] Sample READMEs updated. _(MissionAgent + MockPersonServer done in 6a; others in 6c)_ +- [x] Sample READMEs updated. _(MissionAgent README shipped in 6a; in 6c: + samples/README.md index adds MissionAgent + mission Quick Start, + MockPersonServer README adds the governance-endpoints section, + GuidedTour README adds Mission mode; call-chain samples (Orchestrator + Program.cs, SampleApp CallChain.razor) annotated that mission governance + is optional/orthogonal and intentionally omitted, per §Call Chaining + no-mission path)_ #### Phase 6a additions (spec-driven, beyond the original file list) diff --git a/samples/GuidedTour/README.md b/samples/GuidedTour/README.md index 4da5347..9061f46 100644 --- a/samples/GuidedTour/README.md +++ b/samples/GuidedTour/README.md @@ -13,7 +13,7 @@ hop. A swim-lane sequence diagram across up to four actors — **Agent**, **Orchestrator**, **Resource**, **Person Server** — with a payload inspector on the right that decodes each JWT and shows the canonical -RFC 9421 signature base for every signed request. Six flows are +RFC 9421 signature base for every signed request. Seven flows are available, switchable at runtime from the topbar **Mode** picker: * **Bootstrap** (2–3 steps) — generate the agent's signing key and build @@ -37,6 +37,13 @@ available, switchable at runtime from the topbar **Mode** picker: Keycloak login URL. Requires an Access Server URL (`AccessServerUrl`); run it with `make demo-tour-keycloak` (Keycloak) or `make demo-tour` (stub AS, no Docker). +* **Mission (PS-Governed)** (20 steps; three prompts) — the optional, + orthogonal **agent governance** layer (§Agent Governance). The agent + proposes a human-approved mission, then asks the PS for permission on + each action, records audit, and relays interactions — the PS is the + contextual policy point. A mission-aware Resource copies the + `AAuth-Mission` claim into its resource token. Requires a Person Server + URL; drive the same flow from the CLI with `make demo-mission`. When `PersonServerUrl` is empty in `appsettings.json`, the picker locks to Identity-based (the three-party options are disabled). You can also set @@ -188,5 +195,5 @@ with **Run step**). | `GuidedTour:PersonServerUrl` | `http://localhost:5100` | PS base URL. Set empty to lock the picker to identity-based mode. | | `GuidedTour:AgentProviderUrl` | `http://localhost:5301` | AP base URL. When set, bootstrap enrols with the real AP instead of self-signing. | | `GuidedTour:AgentId` | `aauth:tour-agent@ap.example` | Value placed in the agent token's `sub`. | -| `GuidedTour:Mode` | `Bootstrap` | Default flow on startup. `Bootstrap`, `Identity`, `Autonomous` (Direct Grant), `Deferred`, or `CallChain`. The topbar picker overrides this at runtime. | +| `GuidedTour:Mode` | `Bootstrap` | Default flow on startup. `Bootstrap`, `Identity`, `Autonomous` (Direct Grant), `Deferred`, `CallChain`, `Federated`, or `Mission`. The topbar picker overrides this at runtime. | diff --git a/samples/MockPersonServer/README.md b/samples/MockPersonServer/README.md index 4481d2e..f2d484c 100644 --- a/samples/MockPersonServer/README.md +++ b/samples/MockPersonServer/README.md @@ -51,6 +51,34 @@ The PS only federates to Access Servers listed in `MockPersonServer:TrustedAccessServers`; any other `aud` is rejected with `untrusted_access_server` (403). +## Agent governance (missions) + +Beyond minting tokens, this PS doubles as the **contextual policy point** for +the optional, orthogonal agent-governance layer (§Agent Governance). Governance +is wired with a single call \u2014 `builder.Services.AddAAuthGovernance()` \u2014 which +registers an in-memory mission store and log; the sample then supplies the +policy and user-channel seams (`IPermissionDecider`, `IAuditSink`, +`IInteractionRelay`) plus a deterministic consent script that stands in for a +real user-consent screen. + +It serves the four governance endpoints from the protocol exchange diagram: + +| Endpoint | Spec | Purpose | +|---|---|---| +| `POST /mission` | §Mission Creation | The agent proposes a mission in natural language; the PS stores the approval bytes verbatim, computes `s256`, and returns the `AAuth-Mission` header (`approver`, `s256`). | +| `POST /permission` | §Permission Endpoint | The agent asks whether an action is allowed. Pre-approved tools on the active mission short-circuit to *granted*; everything else runs the three-gate decision (in-scope / prior consent / prompt the user). | +| `POST /audit` | §Audit Endpoint | The agent reports an action it took; the PS appends it to the mission log (fire-and-forget). | +| `POST /mission-interaction` | §Interaction Endpoint | The agent relays a question, payment, or completion proposal to the user through the PS. | + +Pending governance interactions resolve via `POST /mission-pending/{id}`, +mirroring the deferred-consent `/pending/{id}` poll used by `POST /token`. + +A **mission-aware resource** copies the mission object (`approver`, `s256`) from +the `AAuth-Mission` header into the resource token it issues (§Resource Token +Verification, Terminology: *mission-aware resource*); the PS then has full +mission context when it evaluates each downstream hop. Try it end-to-end with +the [MissionAgent](../MissionAgent/README.md) CLI (`make demo-mission`). + ## Run ```bash diff --git a/samples/Orchestrator/Program.cs b/samples/Orchestrator/Program.cs index 6bed927..b8abadf 100644 --- a/samples/Orchestrator/Program.cs +++ b/samples/Orchestrator/Program.cs @@ -118,6 +118,15 @@ async Task RunChainAsync(HttpContext ctx, string upstreamToken) { // Self-issued agent token (iss = orchestratorUrl) satisfies §Upstream Token // Verification step 3 — the PS can match upstream_token.aud against iss. + // + // Mission governance is OPTIONAL and orthogonal to call chaining + // (AAuth §Agent Governance). This demo intentionally leaves it out: the + // upstream auth token carries no `mission.approver`, so the downstream + // exchange follows §Call Chaining's "No mission, iss is a PS" path — the + // PS evaluates the hop without mission context. If a mission WERE present, + // WithCallChaining would auto-forward the `AAuth-Mission` header (via + // MissionForwardingHandler) and route to mission.approver instead; no + // change to this handler would be needed. using var downstream = AAuthClientBuilder.SelfIssuing(orchestratorKey) .As(orchestratorUrl, agentId) .WithKid(OrchestratorKid) diff --git a/samples/README.md b/samples/README.md index fb200f0..c135c2b 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,11 +1,12 @@ # Samples -Nine sample applications demonstrating AAuth flows end-to-end. +Ten sample applications demonstrating AAuth flows end-to-end. | Sample | Port | Description | |--------|------|-------------| | [WhoAmI](WhoAmI/) | 5000 | ASP.NET Core resource server — isolated per-mode pipelines (`/` index, `/hwk`, `/jkt-jwt`, `/jwks-uri`, `/jwt`, `/jwt/admin`, `/jwt/roles`, `/federated`) | | [Orchestrator](Orchestrator/) | 5200 | Intermediate service — call chaining with nested `act` delegation | +| [MissionAgent](MissionAgent/) | — | CLI agent — drives the optional, orthogonal **agent governance** layer: proposes a mission, asks per-action permission, records audit, and relays interactions through a PS (§Agent Governance) | | [MockPersonServer](MockPersonServer/) | 5100 | Reference Person Server — verifies exchanges, mints auth tokens, federates to an Access Server. **Sample only — not part of the AAuth SDK.** | | [MockAgentProvider](MockAgentProvider/) | 5301 | Reference Agent Provider — issues agent tokens, hosts JWKS. **Sample only — not part of the AAuth SDK.** | | [MockAccessServer](MockAccessServer/) | 5500 | Reference Access Server — the fourth party in federated access; evaluates policy (stub or Keycloak) and mints `aa-auth+jwt` (`dwk=aauth-access.json`). **Sample only — not part of the AAuth SDK.** | @@ -37,6 +38,17 @@ in as `demo`/`demo` (admin) or `guest`/`guest` (limited). See [Federated Access](../docs/workflows/federated-access.md) and the [Mock Access Server README](MockAccessServer/README.md). +For the optional **agent governance** layer — an agent operating under a +human-approved mission, with the PS as the contextual policy point — use the +mission stack (§Agent Governance is orthogonal to the access modes above): + +```bash +make demo-mission # AP + PS + WhoAmI for the MissionAgent CLI +make agent-mission # drive it from another terminal +``` + +See the [MissionAgent README](MissionAgent/README.md). + ## Running Individually ### WhoAmI (Resource Server) diff --git a/samples/SampleApp/Components/Pages/CallChain.razor b/samples/SampleApp/Components/Pages/CallChain.razor index 36f1dce..edd83ad 100644 --- a/samples/SampleApp/Components/Pages/CallChain.razor +++ b/samples/SampleApp/Components/Pages/CallChain.razor @@ -25,6 +25,18 @@ then receives the final chained result. +

+ + Note: mission-based agent governance is + optional and orthogonal to call chaining (AAuth §Agent Governance) and is + intentionally left out of this demo. With no mission.approver + in the upstream token, each hop follows §Call Chaining's + “no mission, iss is a PS” path. If a mission were present, + WithCallChaining would auto-forward the AAuth-Mission + header and route to the mission's PS — no code change here. + +

+
Agent (Client) Code
@@ -65,7 +77,9 @@ async Task<IResult> RunChainAsync(HttpContext ctx, string upstream) .As(orchestratorUrl, agentId) .WithKid(kid) .WithPersonServer(psUrl) - .WithCallChaining(upstream) // relay upstream token + .WithCallChaining(upstream) // forwards upstream token + // (+ AAuth-Mission if a mission + // were present — omitted here) .WithChallengeHandling(opts => { opts.OnInteractionRequired = (i, _) => From 663c547d8f4345f475fa64b67b8c44fec29dfb06 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 17:52:44 +0000 Subject: [PATCH 13/24] refactor(missions): remove non-spec capability value; Phase 8 review - Remove out-of-spec `AAuthCapabilitiesHeader.Capabilities.Mission` constant; the spec defines exactly three capability values (interaction, clarification, payment) and the constant had no production usages (Section: AAuth-Capabilities Request Header). - Fix stale "14.1" spec citations to the named AAuth-Capabilities anchor across the header, signing handler, and conformance tests. - Add an untrusted-Markdown sanitization remark to Mission.Description so consumers know to sanitize before rendering (Section: Markdown). - Record the Phase 8 multi-subagent review (R1-R7) in research and tick the Phase 8 DoD; capture deferred findings with rationale (capabilities body param, lenient audit codes, test-coverage gaps, dev-grade store growth). Post-remediation gate: build 0/0, unit 383, conformance 425, mission e2e 4/4. Phase 8 of missions/PS governance. --- .../implementation-plan.md | 18 +++++-- .../research.md | 48 +++++++++++++++++++ src/AAuth/Agent/AAuthCapabilitiesHeader.cs | 6 +-- src/AAuth/Agent/Mission.cs | 6 ++- src/AAuth/HttpSig/AAuthSigningHandler.cs | 2 +- .../HttpSignatures/CapabilitiesHeaderTests.cs | 18 +++---- 6 files changed, 78 insertions(+), 20 deletions(-) diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md index 40c3833..69a7c00 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md @@ -770,11 +770,19 @@ dedicated subagent per set, then remediate findings. ### Definition of Done -- [ ] Each review subagent produces severity-graded findings with spec citations. -- [ ] All critical/high findings remediated or explicitly deferred (with rationale). -- [ ] Full solution builds; `AAuth.Tests` + `AAuth.Conformance` green. -- [ ] e2e mission flow green. -- [ ] research.md updated with any spec/behavior corrections discovered. +- [x] Each review subagent produces severity-graded findings with spec citations. + _(R1–R7 run 2026-06-06; R7 PASS, R1–R6 PASS-WITH-NITS — see research Phase 8.)_ +- [x] All critical/high findings remediated or explicitly deferred (with rationale). + _(Remediated: removed non-spec `Capabilities.Mission`, fixed `§14.1` + citations, added untrusted-Markdown remark to `Mission.Description`. + Deferred with rationale: R3 pre-existing `capabilities` body param — + flagged to user; R4 lenient audit status codes; R2/R4 test-coverage gaps; + R5 dev-grade in-memory store growth.)_ +- [x] Full solution builds; `AAuth.Tests` + `AAuth.Conformance` green. + _(Post-remediation: build 0/0, unit 383, conformance 425.)_ +- [x] e2e mission flow green. _(guided-tour + sample-app mission specs: 4 passed.)_ +- [x] research.md updated with any spec/behavior corrections discovered. + _(Phase 8 section added.)_ --- diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md index a1b5918..1e733ae 100644 --- a/.agent/plans/2026-06-05-missions-ps-governance/research.md +++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md @@ -826,3 +826,51 @@ implementation plan. focused on PS/server seams). D11: clarification chat gets its own `docs/advanced/clarification-chat.md`; all smaller touch-ups land in Phase 7. +### Phase 8 — multi-subagent review (2026-06-06, complete) + +Seven review subagents (R1–R7), one per change set, produced severity-graded +findings with spec citations. Baseline before remediation: build 0/0, unit 383, +conformance 425, mission e2e 4/4. Verdicts: R7 PASS; R1–R6 PASS-WITH-NITS. + +**Spec corrections / remediations applied:** + +- **R1 (mission model) — non-spec capability value removed.** `AAuthCapabilities + Header.Capabilities.Mission = "mission"` was an out-of-spec fourth capability + value. §AAuth-Capabilities Request Header (~L1766) defines exactly three: + `interaction`, `clarification`, `payment`. The constant had **zero production + usages** (only an arbitrary-token `Union` dedup test). Removed the constant; + updated the test to use a real value; fixed stale `§14.1` citations to + `§AAuth-Capabilities` across `AAuthCapabilitiesHeader.cs`, + `AAuthSigningHandler.cs`, and `CapabilitiesHeaderTests.cs`. +- **R1 / R3 / R7 — untrusted-Markdown hardening.** Added an XML-doc remark to + `Mission.Description` stating it is server-supplied untrusted content that + consumers MUST sanitize before rendering (§Markdown ~L192). The SDK already + never renders it; this is defense-in-depth guidance for consumers. (Blazor + samples already never render the description — confirmed by R6.) + +**Findings explicitly deferred (with rationale), NOT auto-remediated:** + +- **R3 — `capabilities` array in the PS token-request body.** §AAuth-Capabilities + (~L1776) says the *header* "is not used on requests to PS endpoints — the PS + learns the agent's capabilities through the mission approval flow." The SDK + sends a `capabilities` **body** parameter (not the header) on the token + request. This is **pre-existing** behavior (commits `d8cf70a` / `1f235a4`, + "live interop fixes"), validated against `person.hello.coop`, and fills a real + gap: for the non-mission deferred-consent flow the PS otherwise has no way to + know the agent can handle a `202` interaction redirect. It is a draft-02 + token-endpoint extension, outside the mission change sets. Decision: flag to + the user as a spec-alignment question; do not change unilaterally (removing it + risks breaking deferred consent + live interop). +- **R4 — `AuditClient` accepts `200`/`204` as well as `201`.** §Audit Endpoint + specifies `201 Created`. Accepting other 2xx is lenient for a fire-and-forget + audit call; low risk, kept as-is. +- **R2 / R4 — minor test-coverage gaps** (aauth-mission anti-double-coverage + edge case; Interaction/Payment relay deferred-poll path). Implementations are + correct; gaps noted for a future test pass, not blocking. +- **R5 — unbounded growth of the in-memory store/log.** By design: the SDK + defaults are documented dev-grade; production PSes register durable stores via + the `TryAdd` seams. + +**Post-remediation gate:** build 0/0, unit 383, conformance 425, mission e2e 4/4 +— all green. + diff --git a/src/AAuth/Agent/AAuthCapabilitiesHeader.cs b/src/AAuth/Agent/AAuthCapabilitiesHeader.cs index b167c06..61eb449 100644 --- a/src/AAuth/Agent/AAuthCapabilitiesHeader.cs +++ b/src/AAuth/Agent/AAuthCapabilitiesHeader.cs @@ -6,7 +6,8 @@ namespace AAuth.Agent; /// /// Models the AAuth-Capabilities header that agents send on outbound -/// requests to declare what flows they support (§14.1). +/// requests to declare what flows they support (§AAuth-Capabilities Request +/// Header). The spec defines exactly three capability values. /// public static class AAuthCapabilitiesHeader { @@ -24,9 +25,6 @@ public static class Capabilities /// Agent can handle payment-required flows (402). public const string Payment = "payment"; - - /// Agent can handle mission flows. - public const string Mission = "mission"; } /// Format the header value from a set of capabilities. diff --git a/src/AAuth/Agent/Mission.cs b/src/AAuth/Agent/Mission.cs index 0e4a4dc..b8e585f 100644 --- a/src/AAuth/Agent/Mission.cs +++ b/src/AAuth/Agent/Mission.cs @@ -31,7 +31,11 @@ public sealed class Mission /// When the mission was approved (ensures the is globally unique). public required DateTimeOffset ApprovedAt { get; init; } - /// Markdown string describing the approved mission scope. + /// + /// Markdown string describing the approved mission scope. This is + /// server-supplied, untrusted content: consumers MUST sanitize it before + /// rendering it to a user (§Markdown). + /// public required string Description { get; init; } /// diff --git a/src/AAuth/HttpSig/AAuthSigningHandler.cs b/src/AAuth/HttpSig/AAuthSigningHandler.cs index 30873b0..7bb8f08 100644 --- a/src/AAuth/HttpSig/AAuthSigningHandler.cs +++ b/src/AAuth/HttpSig/AAuthSigningHandler.cs @@ -67,7 +67,7 @@ public static readonly HttpRequestOptionsKey> AdditionalCo /// /// Optional capabilities to declare on outbound requests via the - /// AAuth-Capabilities header (§14.1). When set, the header is + /// AAuth-Capabilities header (§AAuth-Capabilities). When set, the header is /// emitted on every signed request. /// public IReadOnlyList? Capabilities { get; init; } diff --git a/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs b/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs index 7b5c094..9c45e65 100644 --- a/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs +++ b/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs @@ -9,18 +9,18 @@ namespace AAuth.Conformance.HttpSignatures; /// -/// Conformance tests for AAuth-Capabilities header (§14.1). +/// Conformance tests for AAuth-Capabilities header (§AAuth-Capabilities). /// public class CapabilitiesHeaderTests { - [Fact(DisplayName = "§14.1 — AAuth-Capabilities header formats correctly")] + [Fact(DisplayName = "§AAuth-Capabilities — AAuth-Capabilities header formats correctly")] public void Format_MultipleCapabilities() { var value = AAuthCapabilitiesHeader.Format("interaction", "clarification", "payment"); Assert.Equal("interaction, clarification, payment", value); } - [Fact(DisplayName = "§14.1 — AAuth-Capabilities header parses correctly")] + [Fact(DisplayName = "§AAuth-Capabilities — AAuth-Capabilities header parses correctly")] public void Parse_MultipleCapabilities() { var caps = AAuthCapabilitiesHeader.Parse("interaction, clarification, payment"); @@ -30,7 +30,7 @@ public void Parse_MultipleCapabilities() Assert.Contains("payment", caps); } - [Fact(DisplayName = "§14.1 — AAuth-Capabilities empty value parses to empty list")] + [Fact(DisplayName = "§AAuth-Capabilities — AAuth-Capabilities empty value parses to empty list")] public void Parse_Empty() { var caps = AAuthCapabilitiesHeader.Parse(""); @@ -52,20 +52,20 @@ public void Union_Deduplicates() { var union = AAuthCapabilitiesHeader.Union( missionCapabilities: new[] { "interaction", "payment" }, - agentCapabilities: new[] { "payment", "mission" }); + agentCapabilities: new[] { "payment", "clarification" }); - Assert.Equal(new[] { "interaction", "payment", "mission" }, union.ToArray()); + Assert.Equal(new[] { "interaction", "payment", "clarification" }, union.ToArray()); } [Fact(DisplayName = "§Mission Approval — Union tolerates null sources")] public void Union_ToleratesNulls() { Assert.Equal(new[] { "interaction" }, AAuthCapabilitiesHeader.Union(new[] { "interaction" }, null).ToArray()); - Assert.Equal(new[] { "mission" }, AAuthCapabilitiesHeader.Union(null, new[] { "mission" }).ToArray()); + Assert.Equal(new[] { "clarification" }, AAuthCapabilitiesHeader.Union(null, new[] { "clarification" }).ToArray()); Assert.Empty(AAuthCapabilitiesHeader.Union(null, null)); } - [Fact(DisplayName = "§14.1 — AAuthSigningHandler emits Capabilities header when configured")] + [Fact(DisplayName = "§AAuth-Capabilities — AAuthSigningHandler emits Capabilities header when configured")] public void SigningHandler_EmitsCapabilities() { var key = AAuthKey.Generate(); @@ -95,7 +95,7 @@ public void SigningHandler_EmitsCapabilities() Assert.Contains("mission", headerValue); } - [Fact(DisplayName = "§14.1 — AAuthSigningHandler does not emit Capabilities header when not configured")] + [Fact(DisplayName = "§AAuth-Capabilities — AAuthSigningHandler does not emit Capabilities header when not configured")] public void SigningHandler_NoCapabilities_NoHeader() { var key = AAuthKey.Generate(); From ec741c2789adda9b165a23db5fb664120988a9b8 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 20:27:31 +0000 Subject: [PATCH 14/24] feat(missions): add DI-friendly governance API surface (Phase 1) Introduce the first-pass mission governance API alongside the existing per-call surface (additive; no flows break, full suite green). Agent side: - MissionSession auto-threads the mission claim + bound Person Server across permission/audit/interaction calls - AAuthGovernanceClient gains a bound variant, a Create(...) factory, a PersonServer property, and ProposeMissionAsync(...) -> MissionSession - AAuthClientBuilder.BuildGovernance(GovernanceOptions?) binds the PS URL - AddAAuthGovernanceClient(...) registers the client for DI Resource side: - AddAAuthGovernance() also registers conservative default seams (DefaultPermissionDecider/DefaultAuditSink/DefaultInteractionRelay) via TryAdd - MapAAuthGovernance(...) maps the permission/audit/interaction endpoints from the seams; AAuthGovernancePipelineOptions controls routes Satisfies the construction triad (factory + builder + DI). Adds unit and conformance coverage. Mission-creation mapping and deferred consent are deferred to Phase 2 (see plan DEV-1/DEV-2). --- .../implementation-plan.md | 482 ++++++++++++++++++ .../issues-and-deviations.md | 52 ++ .../research.md | 352 +++++++++++++ src/AAuth/AAuthClientBuilder.cs | 14 +- .../Agent/Governance/AAuthGovernanceClient.cs | 81 ++- src/AAuth/Agent/Governance/MissionSession.cs | 135 +++++ ...hGovernanceApplicationBuilderExtensions.cs | 217 ++++++++ ...rnanceClientServiceCollectionExtensions.cs | 55 ++ ...thGovernanceServiceCollectionExtensions.cs | 17 +- .../AAuthGovernancePipelineOptions.cs | 39 ++ .../Server/Governance/DefaultAuditSink.cs | 34 ++ .../Governance/DefaultInteractionRelay.cs | 28 + .../Governance/DefaultPermissionDecider.cs | 39 ++ .../Missions/GovernanceClientBuilderTests.cs | 248 +++++++++ .../Missions/GovernanceEndpointMapperTests.cs | 171 +++++++ .../AAuthGovernanceDITests.cs | 72 +++ 16 files changed, 2029 insertions(+), 7 deletions(-) create mode 100644 .agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md create mode 100644 .agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md create mode 100644 .agent/plans/2026-06-06-mission-api-refactor/research.md create mode 100644 src/AAuth/Agent/Governance/MissionSession.cs create mode 100644 src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs create mode 100644 src/AAuth/DependencyInjection/AAuthGovernanceClientServiceCollectionExtensions.cs create mode 100644 src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs create mode 100644 src/AAuth/Server/Governance/DefaultAuditSink.cs create mode 100644 src/AAuth/Server/Governance/DefaultInteractionRelay.cs create mode 100644 src/AAuth/Server/Governance/DefaultPermissionDecider.cs create mode 100644 tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs create mode 100644 tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs create mode 100644 tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md new file mode 100644 index 0000000..86d6fa3 --- /dev/null +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -0,0 +1,482 @@ +# Mission API Refactor — Implementation Plan + +## Overview + +Streamline the AAuth .NET SDK's mission/governance surface into an API that can be +constructed three ways — **static factories**, **fluent builders**, and +**DI registration** — for both agent (client) and resource (PS) sides, update every +mission sample and doc to the new surface, add a combined **clarification + +mission + call-chain** SampleApp example, and land two small spec-hardening fixes. + +The API surface is built in **two passes**: a first pass (Phase 1) that gets a +working end-to-end surface, then a consistency pass (Phase 2) that learns from the +first and fine-tunes naming/shape to match the conventions already used elsewhere in +the SDK. The closing phases independently audit samples, docs, and spec compliance. + +See [research.md](research.md) for the full current-state, pain-point, and gap +inventory (Parts A–G) and the recorded design decisions (Open Design Choices). +Significant issues and spec deviations are logged in +[issues-and-deviations.md](issues-and-deviations.md). Every phase below cites the +governing spec section. + +> **R3 (Rich Resource Requests)** is out of scope here — tracked in its own +> initiative, `.agent/plans/2026-06-06-r3-rich-resource-requests/`. + +## Working Agreement (2026-06-06) + +Directives captured from the user for this initiative: + +- **Two-pass API design.** Phase 1 does a first pass at the surface; Phase 2 learns + from it and fine-tunes for consistency with existing SDK patterns. +- **Construction triad.** Support **static factories**, **fluent builders**, and + **DI-friendly** registration for the mission/governance API. +- **No regressions.** None of the existing flows may break — old behavior is + preserved; only the API shape changes. +- **New sample has an e2e spec.** The combined SampleApp page ships with a Playwright + spec. +- **Closing audits use subagents.** The final sample and doc validation phases each + use a dedicated subagent to surface inconsistencies and readability issues + (especially docs, GuidedTour code snippets, and SampleApp). +- **Independent spec reviewer is the last phase.** A separate reviewer subagent + validates every change against the AAuth spec for 100% compliance. Fix the SDK + where it is not spec compliant. +- **Deviation tracking.** Significant issues/deviations are recorded in + [issues-and-deviations.md](issues-and-deviations.md); research is always updated + with new findings. +- **Gated execution.** Ask the user for permission before starting each phase. Do + **not** commit until the user says so. Surface major decisions at the end for + input; refactoring afterward is acceptable. + +## Context + +- **Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` — §Agent Governance, + §Mission Creation/Approval, §Permission Endpoint, §Audit Endpoint, §Interaction + Endpoint, §Clarification Chat, §Call Chaining, §AAuth-Capabilities, §Person Server + Metadata, §Agent Token Request. +- **Upcoming:** `aauth-spec/upcoming-changes-02.md` (F1 capabilities body — already + correct; F5/F6 wait on draft-02). +- **Branch:** `feat/missions-ps-governance` (continue). +- **Sequencing:** Phase 1 API first pass (agent + resource) → Phase 2 API + consistency pass → Phase 3 spec hardening → Phase 4 sample migration → Phase 5 new + combined sample + e2e → Phase 6 docs → Phase 7 samples audit (subagent) → Phase 8 + docs audit (subagent) → Phase 9 independent spec-compliance review (subagent). + +## Cross-Cutting Decisions + +The SDK is pre-1.0 and backward compatibility is **not** a concern (confirmed +2026-06-06). The mission API is a **breaking refactor**: low-level signatures change +and all call-sites are updated in place — no `[Obsolete]` shim, no dual surface. + +- **DC1 — Breaking refactor (no shim).** Replace the low-level mission client/ + resource surface; migrate all callers (research Part C inventory). +- **DC2 — Both client + resource ergonomics.** Address agent pain points + (PT-A1…A6) and resource pain points (PT-R1…R4). +- **DC3 — Align with existing conventions.** Fluent builder like + `AAuthClientBuilder`; DI like `AddAAuthDiscovery`; app-builder mapper like + `MapAAuthResource` (research Part B). +- **DC4 — One combined sample page.** Clarification + mission + call-chain in a + single SampleApp page reusing Orchestrator + WhoAmI as hops. +- **DC5 — Construction triad.** Every primary entry point is reachable via a static + factory, a fluent builder, and DI registration, mirroring how `AAuthClient`, + `AddAAuthAgent`, and `MapAAuthResource` already coexist (research Part B). +- **DC6 — No regressions.** All existing mission/clarification/call-chain flows keep + working; the four mission e2e specs stay green throughout. +- **DC7 — Independent closing review.** Sample audit, doc audit, and spec-compliance + review are separate final phases, each driven by a dedicated subagent; findings + are adjudicated against spec text and logged in `issues-and-deviations.md`. + +--- + +## Phase 1 — API surface: first pass (agent + resource) + +**Goal:** Get a working end-to-end mission/governance surface across **both** the +agent (client) and resource (PS) sides in one pass. Naming and shape need not be +final here — Phase 2 refines them. Fixes the agent pain points (PT-A1…A6) and the +resource pain points (PT-R1…R4), and lands the construction triad (DC5). + +**Spec:** §Agent Governance (governance clients call PS endpoints); §Mission +Creation/Approval (mission carried as `{approver, s256}`); §Permission/Audit/ +Interaction Endpoints (per-call mission claim, request/response shapes, +`mission_terminated` 403); §Clarification Chat (deferred 202 handling); §Person +Server Metadata (endpoint advertisement). + +### Approach — agent side + +- **Bind the PS once.** `AAuthClientBuilder.WithPersonServer(...)` already exists; + `BuildGovernance()` returns an `AAuthGovernanceClient` bound to that PS so + per-call `personServer` params are removed (PT-A2). +- **Mission session auto-threads the claim.** `Mission.ProposeAsync(...)` returns a + `MissionSession` that wraps the approved `Mission` + the bound client and exposes + `Permission`/`Audit`/`Interaction` calls that inject `{approver, s256}` + automatically (PT-A1, PT-A5). +- **Construction triad (DC5).** Static factory (`AAuthGovernanceClient.Create(...)`), + fluent builder (`AAuthClientBuilder…BuildGovernance(...)`), and DI + (`AddAAuthGovernanceClient(name, Action)` mirroring `AddAAuthAgent`) + (PT-A4). +- **Default callbacks.** Bound `GovernanceOptions` defaults set once on the builder, + overridable per call (PT-A6). + +### Approach — resource side + +- **`MapAAuthGovernance(...)`** app-builder mapper (mirrors `MapAAuthResource`) maps + `/mission`, `/permission`, `/audit`, `/mission-interaction` and their pending/poll + routes, using `GovernanceEndpoints` parsers + the registered seams (PT-R1, PT-R2). +- **Default no-op seams** registered via `TryAdd` in `AddAAuthGovernance(configure?)` + so a PS overrides only what it needs (PT-R3). +- **Resource governance builder** for mission-aware challenge config, replacing the + bare `ChallengeOptions.MissionAware` bool (PT-R4). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Agent/Governance/AAuthGovernanceClient.cs` | **Modify** — bind PS URL + default `GovernanceOptions`; drop per-call PS param; add `Create(...)` factory | +| `src/AAuth/Agent/Governance/MissionSession.cs` | **New** — mission-scoped facade auto-threading the claim | +| `src/AAuth/Agent/Governance/MissionClient.cs` | **Modify** — `ProposeAsync(proposal, options?)` → `MissionSession` | +| `src/AAuth/Agent/Governance/PermissionClient.cs` | **Modify** — drop PS param; mission injected by session | +| `src/AAuth/Agent/Governance/AuditClient.cs` | **Modify** — drop PS param; mission injected | +| `src/AAuth/Agent/Governance/InteractionClient.cs` | **Modify** — drop PS param; mission injected | +| `src/AAuth/AAuthClientBuilder.cs` | **Modify** — `BuildGovernance()` binds PS + default options | +| `src/AAuth/DependencyInjection/AAuthGovernanceClientServiceCollectionExtensions.cs` | **New** — `AddAAuthGovernanceClient(...)` | +| `src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs` | **New** — `MapAAuthGovernance(Action?)` | +| `src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs` | **Modify** — `AddAAuthGovernance(configure?)` + default no-op `IPermissionDecider`/`IAuditSink`/`IInteractionRelay` | +| `src/AAuth/Server/Governance/GovernanceEndpoints.cs` | **Modify** — promote per-endpoint handlers (carrier-token check, parse, mission lookup, state check) | +| `src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs` | **New** — route prefix + deferred/pending config | +| `src/AAuth/Server/Challenge/ChallengeOptions.cs` | **Modify** — keep `MissionAware`; surface via resource builder | +| `tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs` | **New** | +| `tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs` | **New** | +| `tests/AAuth.Tests/Governance/MissionSessionTests.cs` | **New** | + +### API Surface (illustrative) + +```csharp +// Agent — fluent builder + bound PS + mission session +AAuthGovernanceClient governance = AAuthClientBuilder + .SelfIssuing(key).As(issuer, agentId) + .WithPersonServer(ps) // PS bound once + .WithChallengeHandling() + .BuildGovernance(o => o.MaxClarificationRounds = 3); + +MissionSession mission = await governance.Mission.ProposeAsync( + new MissionProposal("Keep the inbox under control") { Tools = [...] }); + +// claim + PS auto-threaded: +PermissionResult r = await mission.Permission.RequestAsync("send_email"); +await mission.Audit.RecordAsync("send_email", result: ...); +bool done = await mission.ProposeCompletionAsync("Inbox triaged."); + +// Resource — DI + mapper +builder.Services.AddAAuthGovernance(o => o.UseInMemoryStores()); +builder.Services.AddSingleton(); +app.MapAAuthGovernance(); // maps the 4 endpoints + pending polls +``` + +### Implementation Decisions + +- DC1: no shim; `MissionSession` replaces manual `MissionClaim` extraction. +- DC3: mapper follows the `MapAAuthResource` precedent; seams keep policy in the PS. +- The bound `AAuthGovernanceClient` remains usable mission-lessly for permission + requests that carry no mission (§Permission Endpoint — mission optional). +- Default `IInteractionRelay` returns `Pending` (no user channel) so a bare PS still + compiles and behaves predictably. + +### Definition of Done + +- [x] `BuildGovernance()` binds the PS URL and default `GovernanceOptions`. +- [x] `MissionSession` injects `{approver, s256}` into permission/audit/interaction. +- [ ] Per-call `personServer` parameters removed from the governance clients. _(deferred to Phase 2/4 — first pass is additive, see D1)_ +- [x] Client reachable via static factory, fluent builder, and `AddAAuthGovernanceClient(...)`. +- [~] `MapAAuthGovernance()` maps mission/permission/audit/interaction + poll routes. _(permission/audit/interaction mapped; mission-creation + poll deferred, see DEV-2)_ +- [x] `AddAAuthGovernance(configure?)` registers default no-op seams via `TryAdd`. +- [~] `mission_terminated` 403 + carrier-token checks centralized in the mapper. _(403 termination centralized; carrier-token checks pending Phase 2)_ +- [x] New unit + conformance tests pass; full suite green (build 0/0). + + +--- + +## Phase 2 — API surface: consistency pass + +**Goal:** Learn from the Phase 1 first pass and fine-tune the surface so it reads +consistently with the conventions already used elsewhere in the SDK. No new +capability — naming, shape, and ergonomics only. Confirms the construction triad +(DC5) behaves uniformly across agent and resource sides. + +**Spec:** as cited in Phase 1 (no new spec surface; shape alignment only). + +### Approach + +- **Convention diff.** Compare the Phase 1 surface against `AAuthClientBuilder`, + `AddAAuthDiscovery`/`AddAAuthAgent`, and `MapAAuthResource` (research Part B); + list naming/return-type/option-pattern divergences before changing anything. +- **Normalize the triad.** Ensure the static factory, fluent builder, and DI + registration share parameter names, option types, and defaults so the three paths + are interchangeable and predictable (DC5). +- **Tighten names/return types.** Align method names, async suffixes, option-bag + shapes (`Action` vs records), and nullability with the rest of the SDK. +- **Symmetry check.** Agent-side and resource-side builders/options use the same + vocabulary (e.g. `With…` / `Use…` / `Map…`) as their non-mission counterparts. +- **D1 — remove the transitional dual surface.** Drop the per-call `personServer` + parameters from `MissionClient` / `PermissionClient` / `AuditClient` / + `InteractionClient`; the bound client + `MissionSession` become the only path + (with sample migration completing in Phase 4). Reaches DC1's no-shim end state. +- **D2 — keep `MissionSession` flat.** Confirm flat methods + (`RequestPermissionAsync`, `RecordAuditAsync`, `AskQuestionAsync`, + `ProposeCompletionAsync`); no nested facades. +- **D4 — typed tool/action POCO (replace bare strings).** Introduce a small POCO + for the invoked tool/action so callers pass a value object instead of a `string`. + Today `action` is a bare `string` on `PermissionRequest`, `AuditRecord`, + `MissionSession.RequestPermissionAsync/RecordAuditAsync`, and `PermissionClient`. + **Decision (2026-06-06): reuse the existing `MissionTool`** — the spec defines + `action` as a tool name and `approved_tools` as `{name, description}` objects, + which is exactly `MissionTool(Name, Description?)`; one type spans propose → + approve → invoke. Serialize the `action` JSON field from `MissionTool.Name`. Add + an implicit `string → MissionTool` conversion so terse call sites (`"WebSearch"`) + still compile. Note the two distinct descriptions: `MissionTool.Description` + (static, mirrors `approved_tools[].description`) vs `PermissionRequest.Description` + (per-call markdown). Update the `DefaultPermissionDecider` match (action vs + `ApprovedTools`) to compare by `Name`. +- **D3 — promote PS mission machinery into the SDK (closes DEV-1/DEV-2).** Move the + approval-blob builder out of the sample into the SDK and add an + `IMissionApprover` seam so `MapAAuthGovernance` can map mission creation; add a + deferred/pending consent abstraction so a `Prompt` outcome returns a 202 deferred + response instead of a denial. `DefaultPermissionDecider`/relay remain conservative + no-ops but the deferred path becomes available to PS implementers. + +### Files + +| File | Action | +|------|--------| +| Phase 1 source files | **Modify** — rename/reshape per the convention diff | +| `src/AAuth/Agent/Governance/*Client.cs` | **Modify** — remove per-call `personServer` params (D1) | +| `src/AAuth/Agent/MissionTool.cs` + `PermissionRequest`/`AuditRecord`/`MissionSession` | **Modify** — accept the tool/action POCO; implicit `string` conversion (D4) | +| `src/AAuth/Server/Governance/*` (approval builder, `IMissionApprover`, pending/deferred seam) | **Add/Modify** — promote from sample (D3) | +| `src/AAuth/.../MapAAuthGovernance` | **Modify** — map mission creation + deferred 202 (D3) | +| `samples/MockPersonServer/*` | **Modify** — consume promoted SDK pieces where it reduces sample-local code | +| `tests/AAuth.Conformance/Missions/*` | **Modify** — update to the finalized names; cover mission-create + deferred | +| `tests/AAuth.Tests/Governance/*` | **Modify** — update to the finalized names | + +### Implementation Decisions + +- DC5: the finalized names from this pass are the public contract migrated in + Phases 4–6; record any rename decisions in research before applying. +- **D1/D2/D3 confirmed by the user (2026-06-06):** additive-then-remove, flat + `MissionSession`, and promote the mission machinery + deferred consent into the + SDK. See [issues-and-deviations.md](issues-and-deviations.md). +- Divergences that cannot be reconciled with existing conventions are logged in + [issues-and-deviations.md](issues-and-deviations.md) with rationale. + +### Definition of Done + +- [ ] Convention diff recorded in research (Part B / Open Design Choices). +- [ ] Factory, builder, and DI paths share names/options/defaults across both sides. +- [ ] Public names align with `AAuthClientBuilder` / `AddAAuth*` / `MapAAuth*`. +- [ ] Per-call `personServer` params removed; bound client is the only path (D1). +- [ ] Tool/action passed as a POCO (implicit `string` for terse call sites) (D4). +- [ ] Mission creation mapped by `MapAAuthGovernance` via `IMissionApprover` (D3, DEV-2). +- [ ] `Prompt` outcome returns a deferred 202 via the pending-consent seam (D3, DEV-1). +- [ ] All Phase 1 tests updated and green; full suite green (build 0/0). + + +--- + +## Phase 3 — Spec hardening (F3 + F4) + +**Goal:** Tighten audit response handling and add `device` validation. + +**Spec:** §Audit Endpoint (PS returns `201 Created`); §Agent Token Request +(`device` MUST be UTF-8 printable, ≤64 chars). + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Agent/Governance/AuditClient.cs` | **Modify** — accept only `201 Created` (F3) | +| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — validate `device` (printable ASCII 32–126, ≤64) (F4) | +| `tests/AAuth.Conformance/Missions/AuditResponseTests.cs` | **New/Modify** | +| `tests/AAuth.Conformance/TokenExchange/DeviceValidationTests.cs` | **New** | + +### Definition of Done + +- [ ] `AuditClient` rejects non-201 acknowledgments. +- [ ] `device` rejects control chars and lengths > 64 with a clear exception. +- [ ] Tests cover boundary cases; full suite green. + +--- + +## Phase 4 — Migrate mission samples to the new API + +**Goal:** Update all mission sample call-sites (research Part C) to the Phase 1–2 +surface. No behavior change; API shape only. + +**Spec:** as cited in Phases 1–2. + +### Files + +| File | Action | +|------|--------| +| `samples/MissionAgent/Program.cs` | **Modify** — `MissionSession`, bound PS, no manual `MissionClaim` | +| `samples/MockPersonServer/Program.cs` | **Modify** — `AddAAuthGovernance(...)` + `MapAAuthGovernance()`; remove hand-wired endpoints | +| `samples/MockPersonServer/MissionGovernance.cs` | **Modify** — seams unchanged or trimmed to new defaults | +| `samples/SampleApp/Components/Pages/Mission.razor` | **Modify** — new client surface | +| `samples/GuidedTour/TourSession.cs` | **Modify** — new client surface; step plan preserved | +| `samples/WhoAmI/Program.cs` | **Modify** — resource governance builder for mission-aware challenge | + +### Definition of Done + +- [ ] All mission samples build and run against the new API. +- [ ] Mission e2e specs (4) pass unchanged in behavior. +- [ ] No leftover manual `MissionClaim`/PS-URL threading in samples. + +--- + +## Phase 5 — New combined SampleApp example (clarification + mission + call-chain) + +**Goal:** Add one SampleApp page demonstrating a clarification round during mission +approval, then a mission-governed multi-hop call chain. Fills the two sample gaps +(research Part D). + +**Spec:** §Clarification Chat (202 + `AAuth-Requirement: clarification`; bounded +rounds; untrusted text → sanitize); §Call Chaining (mission present → forward +`AAuth-Mission` each hop; per-hop PS re-evaluation; `act` nesting). + +### Files + +| File | Action | +|------|--------| +| `samples/SampleApp/Components/Pages/MissionCallChain.razor` | **New** — combined flow page | +| `samples/SampleApp/Components/Pages/Home.razor` | **Modify** — add card/link | +| `samples/MockPersonServer/MissionGovernance.cs` | **Modify** — script a clarification round during mission approval | +| `samples/Orchestrator/Program.cs` | **Modify (if needed)** — ensure mission forwarding hop is exercised | +| `tests/e2e/` (Playwright spec) | **New** — combined-flow spec | + +### Implementation Decisions + +- DC4: single page; Orchestrator + WhoAmI as downstream hops. +- Clarification text is rendered only after sanitization (untrusted input). + +### Definition of Done + +- [ ] Page shows a clarification round (respond/update/cancel) during mission approval. +- [ ] Mission is forwarded through the orchestrator; downstream hop is governed. +- [ ] Mission log/trail surfaced in the UI. +- [ ] New Playwright spec passes; full backend stack boots via webServer array. + +--- + +## Phase 6 — Docs update + +**Goal:** Bring mission docs to the new API. + +**Spec:** as cited above. + +### Files + +| File | Action | +|------|--------| +| `docs/advanced/missions.md` | **Modify** — new surface | +| `docs/advanced/mission-governance-clients.md` | **Modify** — `MissionSession` lifecycle | +| `docs/advanced/clarification-chat.md` | **Modify** — link the new combined sample | +| `docs/server/mission-governance.md` | **Modify** — `MapAAuthGovernance()` + default seams | +| `docs/server/challenge-middleware.md` | **Modify** — resource governance builder | +| `docs/workflows/mission-governed-access.md` | **Modify** — updated walkthrough | + +### Definition of Done + +- [ ] All mission docs reflect the new API; code blocks compile against the surface. + +--- + +## Phase 7 — Samples consistency audit (subagent) + +**Goal:** With fresh eyes, validate that **every** sample uses the new API surface +and reads cleanly. A dedicated subagent surfaces inconsistencies, leftover old-API +usage, and readability problems across the sample projects; findings are adjudicated +and remediated. + +**Spec:** as cited in Phases 1–5 (the audit confirms samples match those citations). + +### Approach + +- **Subagent sweep.** Launch an exploration subagent scoped to `samples/**` to list + every mission/clarification/call-chain call-site, flag any that still use the old + surface, manual `MissionClaim` threading, repeated PS URLs, or inconsistent + construction styles, and rank readability issues. +- **Adjudicate + remediate.** Triage findings against the finalized Phase 2 surface; + fix in place. Log anything that turns out to be a genuine SDK gap or deviation in + [issues-and-deviations.md](issues-and-deviations.md). + +### Definition of Done + +- [ ] Subagent report captured; each finding marked fixed / deferred / not-an-issue. +- [ ] No sample retains old-API mission usage or manual claim/PS threading. +- [ ] Significant issues logged in `issues-and-deviations.md`; research updated. +- [ ] All samples build/run; mission + combined e2e specs green. + +--- + +## Phase 8 — Docs & code-snippet consistency audit (subagent) + +**Goal:** Validate that all docs and embedded code snippets — especially GuidedTour +snippets and SampleApp walkthroughs — use the new API and read cleanly. A dedicated +subagent surfaces inconsistencies; findings are adjudicated and remediated. + +**Spec:** as cited in Phases 1–6 (the audit confirms docs match those citations). + +### Approach + +- **Subagent sweep.** Launch an exploration subagent scoped to `docs/**` plus + GuidedTour/SampleApp snippet sources to flag old-API code blocks, stale prose, + broken cross-links, and readability issues. +- **Adjudicate + remediate.** Update docs/snippets to the finalized surface; verify + code blocks compile against it. Log SDK gaps/deviations in + [issues-and-deviations.md](issues-and-deviations.md). + +### Definition of Done + +- [ ] Subagent report captured; each finding marked fixed / deferred / not-an-issue. +- [ ] All mission docs + GuidedTour/SampleApp snippets reflect the new API. +- [ ] Code blocks compile against the surface; cross-links resolve. +- [ ] Significant issues logged in `issues-and-deviations.md`; research updated. + +--- + +## Phase 9 — Independent spec-compliance review (subagent) + +**Goal:** A separate reviewer subagent independently validates **each change** in +this initiative against the AAuth spec to confirm 100% compliance. Where the SDK is +found non-compliant, fix it (DC6 still holds — fixes must not break existing flows). + +**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (all cited sections); +`aauth-spec/upcoming-changes-02.md` for F1/F5/F6 context. + +### Approach + +- **Independent review.** Launch a reviewer subagent that walks the diff of this + initiative phase by phase and checks every behavior against the governing spec + section, with no assumption that prior phases were correct. +- **Adjudicate against spec text.** Each flagged item is confirmed against the spec + before any change; genuine non-compliance is fixed in the SDK, deviations that are + intentional or pending draft-02 are documented. +- **Final ledger.** All significant findings recorded in + [issues-and-deviations.md](issues-and-deviations.md); research updated with the + closing compliance summary. + +### Definition of Done + +- [ ] Reviewer subagent report captured; every finding adjudicated against spec text. +- [ ] SDK non-compliance fixed without breaking existing flows (DC6). +- [ ] `dotnet build AAuth.slnx` 0/0; unit + conformance + mission/combined e2e green. +- [ ] `issues-and-deviations.md` finalized; research updated; plan DoD ticked. +- [ ] Major open decisions surfaced to the user for input. + +--- + +## Out of Scope + +| Item | Reason | +|------|--------| +| R3 (Rich Resource Requests) — models, RFC 8785 hasher, `r3_*` token claims, AS/MM fetch, resource enforcement | Split into its own initiative — `.agent/plans/2026-06-06-r3-rich-resource-requests/` | +| Implementing AS or MM as production SDK roles | Out of scope per mission research; mock servers only | +| Mission lifecycle beyond active/terminated (suspend/resume/revoke) | Deferred to companion spec (§Mission Management) | +| `user_unreachable` (F5) and `prompt` finalization (F6) | Pending draft-02 publication | +| Payment settlement protocols (x402/MPP) | External; SDK only surfaces 402 + details | diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md new file mode 100644 index 0000000..c08e98e --- /dev/null +++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md @@ -0,0 +1,52 @@ +# Mission API Refactor — Issues & Deviations Log + +Significant issues, spec deviations, and decisions surfaced while implementing the +[implementation-plan.md](implementation-plan.md). Research findings stay in +[research.md](research.md); this file is the running ledger of **problems and +deviations** (not routine progress). + +## How to use this log + +- Add an entry whenever a phase surfaces a real issue, a deviation from the AAuth + spec, or a design call that the user may want to revisit. +- Keep each entry short: what, where, why, and the disposition. +- `Status` values: `open`, `fixed`, `deferred`, `intentional`, `needs-decision`. +- Cite the governing spec section for anything spec-related. + +## Spec references + +- AAuth Protocol — `aauth-spec/draft-hardt-oauth-aauth-protocol.md` +- Upcoming changes — `aauth-spec/upcoming-changes-02.md` + +## Open decisions for the user + +These judgment calls were made during Phase 1. **All confirmed by the user on +2026-06-06**; the fixes are folded into the Phase 2 consistency pass. + +- **D1 — Additive first pass, then remove (DECIDED).** Phase 1 adds the new surface + *alongside* the existing per-call-PS methods so the solution keeps building 0/0 + and no flow breaks (DC6). **Phase 2 removes** the per-call `personServer` methods + (sample migration completes in Phase 4), reaching DC1's "no dual surface" end + state. +- **D2 — Flat `MissionSession` methods (DECIDED).** Keep the flat methods + (`RequestPermissionAsync`, `RecordAuditAsync`, `AskQuestionAsync`, + `ProposeCompletionAsync`); no nested facades. +- **D3 — Promote PS mission machinery into the SDK (DECIDED, Phase 2).** Move the + approval-blob builder into the SDK + add an `IMissionApprover` seam so + `MapAAuthGovernance` can map mission creation; add a pending/deferred-consent + abstraction so a `Prompt` outcome returns a 202 deferred response. Closes DEV-1 + and DEV-2. + +## Deviation entries + +| ID | Phase | Area | Summary | Spec § | Status | +|----|-------|------|---------|--------|--------| +| DEV-1 | 1 | Resource mapper | `MapAAuthGovernance` resolves `PermissionOutcome.Prompt` as a denial — no built-in deferred (202) user-consent channel. The MockPersonServer keeps its custom interactive/pending endpoints until a deferred-flow design lands. | §Permission Endpoint (deferred consent) | scheduled (Phase 2, D3) | +| DEV-2 | 1 | Resource mapper | Mission-creation endpoint not mapped by `MapAAuthGovernance`; approval-blob building + proposal approval stay PS-side (`MissionApproval` is sample-local). Promote a reusable approval-blob builder + an `IMissionApprover` seam into the SDK. | §Mission Creation, §Mission Approval | scheduled (Phase 2, D3) | +| DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. | §Interaction Endpoint | intentional | + +## Notes + +- Known spec-alignment findings from research (F1–F6) are tracked in + [research.md](research.md) Part F; only deviations discovered **during + implementation** are logged here. diff --git a/.agent/plans/2026-06-06-mission-api-refactor/research.md b/.agent/plans/2026-06-06-mission-api-refactor/research.md new file mode 100644 index 0000000..d5ac489 --- /dev/null +++ b/.agent/plans/2026-06-06-mission-api-refactor/research.md @@ -0,0 +1,352 @@ +# Mission API Refactor — Research + +## Problem Statement + +The AAuth .NET SDK (`src/AAuth/`) has a **functionally complete** mission/governance +surface — `MissionClient`, `PermissionClient`, `AuditClient`, `InteractionClient` +on the agent side; `IMissionStore`/`IMissionLog`/`IPermissionDecider`/`IAuditSink`/ +`IInteractionRelay` seams plus `GovernanceEndpoints` parsers on the server side. +What it lacks is **ergonomic polish**: missions are threaded by hand through every +call, the Person Server (PS) URL is repeated on every method, there is no DI- or +fluent-builder surface for either client or resource, and the PS must hand-wire +~310+ lines of endpoint boilerplate. + +This document captures the current state, the spec models, the gap inventories, +and the open design choices for three work streams: + +1. **Streamline mission APIs** for both client and resource — DI-friendly, fluent + builders, aligned with existing SDK conventions. + 1.1 **Update mission code samples** (docs, GuidedTour, SampleApp, MissionAgent, + MockPersonServer, WhoAmI) to the new surface. +2. **New SampleApp example** combining **clarification chat** + **call chain with + mission**. + +It contains **no** implementation steps or task lists — those live in +[implementation-plan.md](implementation-plan.md) once design choices are settled. + +> **R3 (Rich Resource Requests)** was originally scoped here as a third work +> stream and has been split into its own initiative — +> `.agent/plans/2026-06-06-r3-rich-resource-requests/`. + +> **Update (2026-06):** The user refined the delivery approach. The API surface is +> now built in **two passes** — Phase 1 first pass (agent + resource), Phase 2 +> consistency pass that aligns naming/shape with existing SDK conventions. The +> surface must support **static factories, fluent builders, and DI registration** +> (construction triad). No existing flow may break. The closing phases are +> independent audits driven by subagents: a samples audit, a docs/code-snippet +> audit (especially GuidedTour + SampleApp), and a final independent +> spec-compliance review that validates every change against the AAuth spec and +> fixes the SDK where non-compliant. Significant issues/deviations are logged in +> [issues-and-deviations.md](issues-and-deviations.md). Execution is gated: +> permission is requested before each phase, nothing is committed until the user +> approves, and major decisions are surfaced at the end. See +> [implementation-plan.md](implementation-plan.md) "Working Agreement". + +> **Update (2026-06) — Phase 1 first-pass landed (additive).** New SDK surface, +> all additive so the solution stays green (build 0/0; unit 387, conformance 440): +> - **Agent:** `MissionSession` ([../../../src/AAuth/Agent/Governance/MissionSession.cs](../../../src/AAuth/Agent/Governance/MissionSession.cs)) +> auto-threads the mission claim + bound PS; `AAuthGovernanceClient` gains a +> bound variant, a `Create(...)` factory, a `PersonServer` property, and +> `ProposeMissionAsync(...) → MissionSession`; `AAuthClientBuilder.BuildGovernance(GovernanceOptions?)` +> binds the `WithPersonServer` URL; DI via `AddAAuthGovernanceClient(...)`. +> - **Resource:** `AddAAuthGovernance()` now also registers conservative default +> seams (`DefaultPermissionDecider`/`DefaultAuditSink`/`DefaultInteractionRelay`) +> via `TryAdd`; new `MapAAuthGovernance(...)` maps permission/audit/interaction +> from the seams; `AAuthGovernancePipelineOptions` controls routes. +> - **Construction triad (DC5)** satisfied: static factory + fluent builder + DI. +> - **Open items** logged in [issues-and-deviations.md](issues-and-deviations.md): +> DEV-1 (mapper `Prompt`→denied, no deferred channel), DEV-2 (mission-creation +> not mapped — `MissionApproval` still sample-local), DEV-3 (no-op relay), and +> transitional decisions D1–D3 for user confirmation. + +## Source Documents + +| Document | Location | Relevant Sections | +|----------|----------|-------------------| +| AAuth Protocol | `aauth-spec/draft-hardt-oauth-aauth-protocol.md` | §Agent Governance; §Mission Creation/Approval; §Permission Endpoint; §Audit Endpoint; §Interaction Endpoint; §Clarification Chat; §Call Chaining; §AAuth-Capabilities; §Resource Token; §Auth Token; §Person Server Metadata | +| Upcoming changes | `aauth-spec/upcoming-changes-02.md` | `capabilities` in PS token body; `user_unreachable` terminal error; `prompt` param | + +--- + +## Part A — Current Mission/Governance Surface & Ergonomic Friction + +### A.1 Agent-side clients (verified signatures) + +| Type | File | Shape | +|------|------|-------| +| `AAuthGovernanceClient` | [src/AAuth/Agent/Governance/AAuthGovernanceClient.cs](../../../src/AAuth/Agent/Governance/AAuthGovernanceClient.cs) | Facade: `Mission`/`Permission`/`Audit`/`Interaction`; ctor `(HttpClient signedClient, MetadataClient metadata)` | +| `MissionClient` | [src/AAuth/Agent/Governance/MissionClient.cs](../../../src/AAuth/Agent/Governance/MissionClient.cs) | `ProposeAsync(personServer, MissionProposal, GovernanceOptions?, CT)` | +| `PermissionClient` | [src/AAuth/Agent/Governance/PermissionClient.cs](../../../src/AAuth/Agent/Governance/PermissionClient.cs) | `RequestAsync(personServer, PermissionRequest, [Mission?], GovernanceOptions?, CT)` | +| `AuditClient` | [src/AAuth/Agent/Governance/AuditClient.cs](../../../src/AAuth/Agent/Governance/AuditClient.cs) | `RecordAsync(personServer, AuditRecord, CT)` | +| `InteractionClient` | [src/AAuth/Agent/Governance/InteractionClient.cs](../../../src/AAuth/Agent/Governance/InteractionClient.cs) | `SendAsync`/`RelayInteractionAsync`/`RelayPaymentAsync`/`AskQuestionAsync`/`ProposeCompletionAsync` | +| Builder entry | [src/AAuth/AAuthClientBuilder.cs](../../../src/AAuth/AAuthClientBuilder.cs) | `.BuildGovernance()` → `AAuthGovernanceClient` (one-shot; requires explicit signing mode) | + +### A.2 Agent-side pain points + +- **PT-A1 — Manual mission threading.** After `ProposeAsync` returns a `Mission`, + the caller must hand-extract `new MissionClaim(mission.Approver, mission.S256)` + and set it on every `PermissionRequest.Mission` / `AuditRecord` (positional) / + `InteractionRequest.Mission`. Spec ref: mission travels as `{approver, s256}` + only (§Mission Approval; §AAuth-Mission Request Header). +- **PT-A2 — Repeated `personServer` URL.** Every `*Async` method re-takes the PS + URL; nothing binds it to the governance client. +- **PT-A3 — No fluent governance builder.** `BuildGovernance()` is a terminal + one-shot. No way to chain default PS, default `GovernanceOptions`, or callbacks + the way `AAuthClientBuilder` does for the main client. +- **PT-A4 — No DI registration.** `AddAAuthAgent(...)` exists for plain clients, + but there is no `AddAAuthGovernanceClient(...)`. Callers wire a factory by hand. +- **PT-A5 — Inconsistent request construction.** `AuditRecord(mission, action)` + positional vs. `PermissionRequest(action){ Mission = ... }` init vs. + `InteractionRequest(type){ Mission = ... }` — no common factory/builder. +- **PT-A6 — Callbacks lack mission context.** `GovernanceOptions.OnInteractionRequired` + / `OnClarificationRequired` receive only the requirement, not the mission; the + caller must close over it. + +### A.3 Resource/PS-side surface (verified) + +- DI: [AddAAuthGovernance()](../../../src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs) + registers only `IMissionStore`/`IMissionLog` (in-memory, via `TryAddSingleton`). +- Seams (PS implements): `IPermissionDecider`, `IAuditSink`, `IInteractionRelay`. +- Parsers: [GovernanceEndpoints](../../../src/AAuth/Server/Governance/GovernanceEndpoints.cs) + static `ParsePermission`/`ParseAudit`/`ParseInteraction`/`ParseMissionProposal` + + `MissionTerminated()` 403 helper. +- Mission-aware resource: [ChallengeOptions.MissionAware](../../../src/AAuth/Server/Challenge/ChallengeOptions.cs) + copies the `AAuth-Mission` header claim into the resource token. + +### A.4 Resource/PS-side pain points + +- **PT-R1 — ~310+ lines of endpoint boilerplate.** MockPersonServer hand-wires + `/mission` (~127), `/permission` (~84), `/audit` (~33), `/mission-interaction` + (~66), plus pending/poll endpoints (+200). No `MapAAuthGovernance()` mapper. +- **PT-R2 — Duplicated per-endpoint plumbing.** Each handler re-checks agent-token + carrier type, parses JSON, extracts agent id, handles `FormatException`, looks up + the mission, checks `MissionState`, and selects status codes. +- **PT-R3 — Scattered seam registration.** `AddAAuthGovernance()` registers storage + only; the three policy/relay seams are registered separately with no defaults + (no no-op fallback). +- **PT-R4 — No resource-side fluent builder.** `ChallengeOptions.MissionAware` is a + bare bool; there is no fluent equivalent to the agent's `AAuthClientBuilder`. + +--- + +## Part B — DI & Fluent-Builder Conventions to Align With + +The refactor must mirror existing SDK conventions, not invent new ones. + +### B.1 Fluent builder pattern ([AAuthClientBuilder.cs](../../../src/AAuth/AAuthClientBuilder.cs)) + +- Static factories: `Bootstrap`, `From`, `SelfIssuing`, `Enrolled`. +- Signing modes: `.UseHwk()`, `.UseJwt(...)`, `.UseJwksUri(...)`, `.UseJktJwt(...)`, + `.UseProvider(...)`. +- Config chain: `.WithCapabilities()`, `.WithChallengeHandling(...)`, + `.WithCallChaining(...)`, `.WithPersonServer(...)`, `.WithTokenRefresh(...)`, + `.WithInteractionHandling(...)`. +- Terminals: `.Build()` → `HttpClient`; `.BuildHandler()` → `HttpMessageHandler`; + `.BuildGovernance()` → `AAuthGovernanceClient`. +- Sub-builders (`SelfIssuingBuilder`, `EnrolledBuilder`, `BootstrapBuilder`) bridge + back to the main builder via an internal `ToBuilder()` seam. + +### B.2 DI extension conventions + +- All in `Microsoft.Extensions.DependencyInjection` namespace, under + `src/AAuth/DependencyInjection/`. +- Shape: `AddXxx(Action configure)` → new options → `configure?.Invoke` → + register. DI options are **mutable** (`get; set;`, sealed). +- Seams registered with `TryAdd*` so consumers can override. +- App-builder extensions (`UseAAuthVerification`, `UseAAuthChallenge`, + `MapAAuthWellKnown`, `MapAAuthResource`, `UseAAuthIntermediary`) live in the + `Microsoft.AspNetCore.Builder` namespace, same folder. + +### B.3 Options conventions + +- **DI options** (mutable `get; set;`): `AAuthAgentOptions`, `AAuthResourceOptions`, + `AAuthDiscoveryOptions`, `AAuthResourcePipelineOptions`. +- **Middleware options** (init-only `get; init;`): `AAuthVerificationOptions`, + `ChallengeOptions`, `GovernanceOptions`. +- Public-facing types are `sealed`; interfaces `I*`; public SDK classes `AAuth*`; + records for immutable DTOs. + +### B.4 Existing precedent to extend + +`MapAAuthResource(Action?)` already bundles +well-known + verification + challenge in one call — the natural template for a new +`MapAAuthGovernance(...)` PS mapper (PT-R1). `AddAAuthDiscovery(configure?)` is the +template for an optional-callback DI registration (PT-A4). + +--- + +## Part C — Mission Sample/Doc Call-Site Inventory (Work Stream 1.1) + +Files that exercise the mission/governance surface and would change under an API +refactor. Full per-line table is preserved in the call-site appendix below. + +| File | Role | Notable call-sites | +|------|------|--------------------| +| [samples/MissionAgent/Program.cs](../../../samples/MissionAgent/Program.cs) | Console walkthrough | `Mission.ProposeAsync`, `Permission.RequestAsync`, `Audit.RecordAsync`, `Interaction.AskQuestionAsync`/`ProposeCompletionAsync`, manual `MissionClaim` + `AAuthMissionHeader.FormatStructured` | +| [samples/MockPersonServer/Program.cs](../../../samples/MockPersonServer/Program.cs) | PS endpoints | `AddAAuthGovernance()` + 6 seam registrations; `/mission`, `/mission-create-pending/{id}`, `/permission`, `/permission-pending/{id}`, `/audit`, `/mission-interaction`; mission gate in `/token` | +| [samples/MockPersonServer/MissionGovernance.cs](../../../samples/MockPersonServer/MissionGovernance.cs) | Seam impls | `SamplePermissionDecider`, `SampleAuditSink`, `SampleInteractionRelay`, `MissionPolicyStore`, `MissionConsentScript` | +| [samples/SampleApp/Components/Pages/Mission.razor](../../../samples/SampleApp/Components/Pages/Mission.razor) | Browser 5-gate demo | `ProposeAsync`, two `RequestAsync` variants, `RecordAsync`; `ChallengeForMission` | +| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | Call chain (no mission) | Currently mission-free by design; target for Work Stream 2 | +| [samples/GuidedTour/TourSession.cs](../../../samples/GuidedTour/TourSession.cs) | 20-step mission plan | `MissionPlan`, mission state tracking fields | +| [samples/GuidedTour/Components/Pages/Tour.razor](../../../samples/GuidedTour/Components/Pages/Tour.razor) | Tour UI | Mission mode picker + description | +| [samples/WhoAmI/Program.cs](../../../samples/WhoAmI/Program.cs) | Mission-aware resource | `ChallengeForMission(scope)` with `MissionAware = true`; `/jwt/mission`, `/jwt/mission/elevated` | +| docs/advanced/missions.md | Doc | `Mission`, `MissionClaim`, `AAuthMissionHeader` | +| docs/advanced/mission-governance-clients.md | Doc | Four-client facade + full lifecycle | +| docs/advanced/clarification-chat.md | Doc | `GovernanceOptions.OnClarificationRequired` | +| docs/server/mission-governance.md | Doc | `AddAAuthGovernance()`, seams, parsers | +| docs/server/token-issuance.md | Doc | `MissionClaim`, `AuthTokenBuilder` | +| docs/server/challenge-middleware.md | Doc | `ChallengeOptions.MissionAware` | +| docs/workflows/mission-governed-access.md | Doc | End-to-end walkthrough | + +> Any new convenience surface should be **additive** where possible so these +> call-sites can migrate incrementally; whether to keep the low-level API public is +> an open design choice (see D1 in the original plan — "no shim" was the prior +> decision and may or may not carry forward). + +--- + +## Part D — New SampleApp Example: Clarification Chat + Call-Chain-with-Mission (Work Stream 2) + +### D.1 Current gaps + +- **No end-to-end clarification-chat sample.** `ClarificationExchange` / + `ClarificationResponse.Respond/Update/Cancel` exist and are documented, but no + sample exercises a real multi-round clarification UI. MockPersonServer only + toggles a scripted `RequireTokenClarification` flag. +- **Call chain explicitly omits missions.** [CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) + states mission governance is "optional and orthogonal … intentionally left out + of this demo" (added in commit `47ce1ef`). Multi-hop uses `upstream_token` only. + +### D.2 Spec basis for the combined example + +- **Clarification Chat** (§Clarification Chat): server returns 202 + + `AAuth-Requirement: clarification`; agent responds via respond/update/cancel; the + PS/resource MUST bound rounds (SDK default `MaxClarificationRounds = 5`). The + clarification text is **untrusted** and must be sanitized before display. +- **Call Chaining with mission** (§Call Chaining): when a mission is present, the + intermediary MUST forward the `AAuth-Mission` header on every downstream hop + ([MissionForwardingHandler](../../../src/AAuth/Agent/MissionForwardingHandler.cs), + auto-wired by `.WithCallChaining()`); the PS re-evaluates each hop against the + mission scope + log; the nested delegation is carried in the auth token `act` + claim. + +### D.3 Candidate example shape (to confirm with user) + +A new SampleApp page (e.g. `MissionCallChain.razor`) demonstrating: +1. Agent proposes a mission; PS asks a **clarification question** during approval; + agent responds; mission approved with refined intent (clarification round + surfaced in the UI). +2. Agent calls the Orchestrator with the mission; `AAuth-Mission` is forwarded. +3. Orchestrator's downstream hop to WhoAmI is governed by the mission (in-scope = + silent; out-of-mission = 202 prompt), each hop's token carrying the mission + claim with `act` nesting. +4. Mission log shows the full multi-hop trail. + +> Open: whether this is one combined page or two (clarification-only + +> mission-call-chain). See [Open Design Choices](#open-design-choices). + +--- + +## Part F — Spec-Alignment Findings (verified) + +| ID | Finding | Status | Spec ref | +|----|---------|--------|----------| +| F1 | `capabilities` in PS **token-request body** | ✅ **Correct** — confirmed spec-standard by spec lead (`upcoming-changes-02.md` §1, 2026-05-30). No action. | §AAuth-Capabilities + upcoming-changes-02 §1 | +| F2 | `ServerMetadata` parses `mission`/`permission`/`audit`/`interaction` endpoints | ✅ **Complete** — all four parsed in [ServerMetadata.FromJson](../../../src/AAuth/Discovery/ServerMetadata.cs). (Earlier "not parsed" claim was wrong.) | §Person Server Metadata | +| F3 | `AuditClient` accepts 200/204 in addition to 201 | ⚠️ Over-permissive; spec wants only `201 Created`. Candidate hardening. | §Audit Endpoint response | +| F4 | `device` param: no UTF-8-printable / ≤64-char validation | ⚠️ Missing boundary validation. Candidate hardening. | §Agent Token Request | +| F5 | `user_unreachable` (400, terminal) distinct from `interaction_required` (202) | ⏳ Not yet modeled; pending draft-02. | upcoming-changes-02 §2 | +| F6 | `prompt` token-endpoint body param | ✅ Present on `TokenExchangeRequest`; pending draft-02 finalization. | upcoming-changes-02 §3 | + +> F1/F2 resolve two previously-open questions: the capabilities-body behavior is +> **correct**, and metadata parsing is **already complete**. F3/F4/F5 are small, +> independent spec-hardening candidates that could ride along with this initiative +> or be deferred. + +--- + +## Part G — Anything Else to Include in Research (recommendations) + +Beyond the three work streams, the research/plan should also cover: + +- **Back-compat / migration strategy** for the mission API refactor: additive + convenience layer vs. breaking change to the low-level clients; how the 15+ + call-sites in Part C migrate; conformance/test impact (currently 383 unit + 425 + conformance + 4 mission e2e specs). +- **Testing strategy**: unit coverage for new builder/DI surface; conformance + vectors for the new governance surface; Playwright specs for the new combined + sample (the harness boots the full backend stack via the webServer array). +- **Spec-citation discipline**: every plan phase/change cites a spec section, per + the standing directive. + +> **R3 (Rich Resource Requests)** is tracked in its own initiative — +> `.agent/plans/2026-06-06-r3-rich-resource-requests/` — including its RFC 8785 +> hashing prerequisite, R3↔mission interplay, and security invariants. + +--- + +## Open Design Choices + +> **Decisions (2026-06-06):** 1 → **Breaking refactor** of the low-level mission +> surface (no shim; call-sites updated to the new API). 2 → **Both client + +> resource** ergonomics (incl. `MapAAuthGovernance(...)` PS mapper). 3 → **One +> combined** SampleApp page (clarification + mission + call-chain). 4 → **Include +> both** spec-hardening fixes (F3 audit 201-only, F4 device validation). 5 → +> **Continue on `feat/missions-ps-governance`**. (R3 was split into its own +> initiative.) + +These required user input **before** authoring `implementation-plan.md`. + +1. **Mission API back-compat.** Additive convenience layer over the existing + clients, or a breaking refactor of the low-level surface? (Prior initiative used + a "no shim" stance — confirm whether that carries forward.) +2. **Combined sample shape.** One SampleApp page (clarification + mission + + call-chain) or two separate pages? Reuse Orchestrator/WhoAmI as the downstream + hops? +3. **Resource-side fluent builder.** Introduce a `MapAAuthGovernance(...)` PS + mapper + a resource governance builder (addressing PT-R1…R4), or keep the + refactor agent-side only this round? +4. **Spec-hardening ride-alongs.** Include F3 (audit 201-only) and F4 (device + validation) in this initiative, or defer? F5/F6 wait on draft-02 regardless. +5. **Branch & workflow.** Continue on `feat/missions-ps-governance`, or cut a new + branch for this initiative? (Standing directive: branch per initiative, ask + before commit/push, cite spec per change.) + +--- + +## Out of Scope (unless decided otherwise) + +| Item | Reason | +|------|--------| +| R3 (Rich Resource Requests) — models, RFC 8785 hasher, `r3_*` claims, AS/MM fetch, enforcement | Split into its own initiative — `.agent/plans/2026-06-06-r3-rich-resource-requests/` | +| Implementing an AS or MM as production SDK roles | Out of scope per prior mission research; only mock servers demonstrate these | +| Mission lifecycle beyond active/terminated (suspend/resume/revoke) | Deferred to a companion spec (§Mission Management) | +| Payment settlement protocols (x402/MPP) | External; SDK only surfaces 402 + details | + +--- + +## Call-Site Appendix (per-line detail) + +Detailed line references for the refactor (source: read-only exploration, +2026-06-06). These are stable enough to plan against but should be re-verified at +edit time. + +- **MissionAgent/Program.cs**: governance client + `MissionClaim` (105–119); + `ProposeAsync` (133–142); mission-aware exchange (155–195); `Permission.RequestAsync` + (205–217); `Audit.RecordAsync` (223–228); `AskQuestionAsync` (233–240); + `ProposeCompletionAsync` (246–251); `AAuthMissionHeader.FormatStructured` (355–356). +- **MockPersonServer/Program.cs**: DI (102–108); metadata (179–182); `/mission` + (699–777); `/mission-create-pending/{id}` (779–813); `/permission` (815–897); + `/permission-pending/{id}` (1017–1041); `/audit` (945–965); `/mission-interaction` + (967–1009); mission gate in `/token` (510–570). +- **MockPersonServer/MissionGovernance.cs**: `SamplePermissionDecider` (131–153); + `SampleAuditSink` (156–167); `SampleInteractionRelay` (170–183); `MissionPolicyStore` + (109–129); `MissionConsentScript` (24–94). +- **SampleApp/Components/Pages/Mission.razor**: `ProposeAsync` (59–67, 388–398); + `RequestAsync` (110–120, 461–485); `RecordAsync` (191–198). +- **GuidedTour/TourSession.cs**: mission state fields (59–75); `TotalSteps` (199–203); + `MissionPlan` (256–275). +- **GuidedTour/Components/Pages/Tour.razor**: mission picker + description (~3–65); + polling display (177–190); mission lanes (~274). +- **WhoAmI/Program.cs**: `ChallengeForMission` (132–140); mission endpoints with + `UseWhen` (195–213); scope descriptions (48–61). diff --git a/src/AAuth/AAuthClientBuilder.cs b/src/AAuth/AAuthClientBuilder.cs index 05c77c5..a3732fa 100644 --- a/src/AAuth/AAuthClientBuilder.cs +++ b/src/AAuth/AAuthClientBuilder.cs @@ -399,12 +399,24 @@ public AAuthClientBuilder WithInteractionHandling() /// /// No signing mode was configured. public AAuthGovernanceClient BuildGovernance() + => BuildGovernance(defaultOptions: null); + + /// + /// Build a governance client bound to the Person Server configured via + /// (when present), with default deferred-handling + /// options. A bound client exposes + /// which returns a that auto-threads + /// the mission claim and PS into subsequent calls. Requires an explicit signing mode. + /// + /// Default governance options applied when a call omits its own. + /// No signing mode was configured. + public AAuthGovernanceClient BuildGovernance(Agent.Governance.GovernanceOptions? defaultOptions) { var provider = _provider ?? throw new InvalidOperationException( "BuildGovernance requires an explicit signing mode (UseHwk, UseJwt, UseJwksUri, UseJktJwt, or UseProvider)."); var (signed, metadata) = BuildSignedChannel(provider, _innerHandler ?? new HttpClientHandler()); - return new AAuthGovernanceClient(signed, metadata); + return new AAuthGovernanceClient(signed, metadata, _personServer, defaultOptions); } // Build a signed HttpClient (pinned to the agent identity) plus a metadata diff --git a/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs b/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs index 2b99739..9602a83 100644 --- a/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs +++ b/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs @@ -1,5 +1,7 @@ using System; using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; using AAuth.Discovery; namespace AAuth.Agent.Governance; @@ -8,16 +10,25 @@ namespace AAuth.Agent.Governance; /// Bundles the PS governance clients (mission, permission, audit, interaction) /// over a single signed and a shared /// , so callers don't have to wire each client -/// individually. Build one with -/// or construct it directly. +/// individually. Build one with , +/// the factory, or construct it directly. ///
/// /// The supplied MUST be wired with an /// configured with the agent token, so /// every governance request is signed. +/// +/// When a Person Server is bound (via +/// or the factory), returns a +/// that auto-threads the mission claim and PS into every +/// subsequent call. +/// /// public sealed class AAuthGovernanceClient { + private readonly string? _personServer; + private readonly GovernanceOptions? _defaultOptions; + /// Propose and approve missions at the PS mission_endpoint. public MissionClient Mission { get; } @@ -30,16 +41,82 @@ public sealed class AAuthGovernanceClient /// Reach the user via the PS interaction_endpoint. public InteractionClient Interaction { get; } + /// + /// The Person Server this client is bound to, or when + /// unbound. When bound, is available and the + /// per-call personServer argument can be omitted via a + /// . + /// + public string? PersonServer => _personServer; + /// Create the facade over a signed client and metadata client. /// HttpClient wired with an . /// Metadata client for resolving the PS governance endpoints. public AAuthGovernanceClient(HttpClient signedClient, MetadataClient metadata) + : this(signedClient, metadata, personServer: null, defaultOptions: null) + { + } + + /// + /// Create the facade bound to a Person Server with default governance options. + /// + /// HttpClient wired with an . + /// Metadata client for resolving the PS governance endpoints. + /// The PS URL to bind, or to stay unbound. + /// Default deferred-handling options applied when a call omits its own. + public AAuthGovernanceClient( + HttpClient signedClient, + MetadataClient metadata, + string? personServer, + GovernanceOptions? defaultOptions) { ArgumentNullException.ThrowIfNull(signedClient); ArgumentNullException.ThrowIfNull(metadata); + _personServer = personServer; + _defaultOptions = defaultOptions; Mission = new MissionClient(signedClient, metadata); Permission = new PermissionClient(signedClient, metadata); Audit = new AuditClient(signedClient, metadata); Interaction = new InteractionClient(signedClient, metadata); } + + /// + /// Static factory mirroring the SDK's other Create/Build entry + /// points. Equivalent to the bound constructor. + /// + /// HttpClient wired with an . + /// Metadata client for resolving the PS governance endpoints. + /// The PS URL to bind, or to stay unbound. + /// Default deferred-handling options applied when a call omits its own. + public static AAuthGovernanceClient Create( + HttpClient signedClient, + MetadataClient metadata, + string? personServer = null, + GovernanceOptions? defaultOptions = null) + => new(signedClient, metadata, personServer, defaultOptions); + + /// + /// Propose a mission to the bound Person Server (§Mission Creation, + /// §Mission Approval) and return a that + /// auto-threads the mission claim and PS into subsequent governed calls. + /// + /// No Person Server is bound. + public async Task ProposeMissionAsync( + MissionProposal proposal, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(proposal); + if (string.IsNullOrEmpty(_personServer)) + { + throw new InvalidOperationException( + "ProposeMissionAsync requires a bound Person Server. Bind one via " + + "AAuthClientBuilder.WithPersonServer(...).BuildGovernance() or " + + "AAuthGovernanceClient.Create(signedClient, metadata, personServer)."); + } + + var mission = await Mission.ProposeAsync( + _personServer, proposal, options ?? _defaultOptions, cancellationToken).ConfigureAwait(false); + return new MissionSession(this, _personServer, mission, _defaultOptions); + } } diff --git a/src/AAuth/Agent/Governance/MissionSession.cs b/src/AAuth/Agent/Governance/MissionSession.cs new file mode 100644 index 0000000..d8937e4 --- /dev/null +++ b/src/AAuth/Agent/Governance/MissionSession.cs @@ -0,0 +1,135 @@ +using System; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Tokens; + +namespace AAuth.Agent.Governance; + +/// +/// A mission-scoped facade over an bound to a +/// Person Server. It wraps the approved and auto-threads the +/// mission claim ({approver, s256}) and the bound PS URL into every +/// permission, audit, and interaction call (§Permission Endpoint, §Audit Endpoint, +/// §Interaction Endpoint), so callers never re-supply them. +/// +/// +/// Obtain a session from . +/// The session is the agent's handle for the lifetime of one mission. +/// +public sealed class MissionSession +{ + private readonly AAuthGovernanceClient _governance; + private readonly string _personServer; + private readonly GovernanceOptions? _defaultOptions; + + internal MissionSession( + AAuthGovernanceClient governance, + string personServer, + Mission mission, + GovernanceOptions? defaultOptions) + { + _governance = governance ?? throw new ArgumentNullException(nameof(governance)); + _personServer = personServer ?? throw new ArgumentNullException(nameof(personServer)); + Mission = mission ?? throw new ArgumentNullException(nameof(mission)); + _defaultOptions = defaultOptions; + } + + /// The approved mission this session is scoped to. + public Mission Mission { get; } + + /// The Person Server this session's mission was approved by. + public string PersonServer => _personServer; + + // The mission claim threaded into every governed request. + private MissionClaim Claim => new(Mission.Approver, Mission.S256); + + /// + /// Request permission for within this mission + /// (§Permission Endpoint). Pre-approved tools short-circuit to a grant; any + /// other action is evaluated by the PS. The mission claim and PS are injected. + /// + public Task RequestPermissionAsync( + string action, + string? description = null, + JsonObject? parameters = null, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + => _governance.Permission.RequestAsync( + _personServer, action, Mission, description, parameters, + options ?? _defaultOptions, cancellationToken); + + /// + /// Record an action the agent performed within this mission (§Audit Endpoint). + /// The mission claim and PS are injected. + /// + public Task RecordAuditAsync( + string action, + string? description = null, + JsonObject? parameters = null, + JsonObject? result = null, + CancellationToken cancellationToken = default) + => _governance.Audit.RecordAsync( + _personServer, + new AuditRecord(Claim, action) + { + Description = description, + Parameters = parameters, + Result = result, + }, + cancellationToken); + + /// + /// Ask the user a question within this mission and return the answer + /// (§Interaction Endpoint). The mission claim and PS are injected. + /// + public Task AskQuestionAsync( + string question, + string? description = null, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + => _governance.Interaction.AskQuestionAsync( + _personServer, question, description, Claim, + options ?? _defaultOptions, cancellationToken); + + /// + /// Relay a resource interaction (URL + code) to the user (§Interaction + /// Endpoint). The mission claim and PS are injected. + /// + public Task RelayInteractionAsync( + string url, + string code, + string? description = null, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + => _governance.Interaction.RelayInteractionAsync( + _personServer, url, code, description, Claim, + options ?? _defaultOptions, cancellationToken); + + /// + /// Forward a payment approval (URL + code) to the user (§Interaction + /// Endpoint). The mission claim and PS are injected. + /// + public Task RelayPaymentAsync( + string url, + string code, + string? description = null, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + => _governance.Interaction.RelayPaymentAsync( + _personServer, url, code, description, Claim, + options ?? _defaultOptions, cancellationToken); + + /// + /// Propose mission completion with a summary (§Interaction Endpoint). Returns + /// when the user accepted and the PS terminated the + /// mission. The mission claim and PS are injected. + /// + public Task ProposeCompletionAsync( + string summary, + GovernanceOptions? options = null, + CancellationToken cancellationToken = default) + => _governance.Interaction.ProposeCompletionAsync( + _personServer, summary, Claim, + options ?? _defaultOptions, cancellationToken); +} diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs new file mode 100644 index 0000000..e1d4422 --- /dev/null +++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Server.Governance; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Maps the PS governance endpoints (§Permission Endpoint, §Audit Endpoint, +/// §Interaction Endpoint) onto seam-driven handlers, mirroring +/// MapAAuthResource. The handlers parse the request with +/// , enforce the +/// mission_terminated rule, and delegate the decision to the registered +/// / / +/// seams (registered by AddAAuthGovernance). +/// +/// +/// This first-pass mapper handles the synchronous decision path. A +/// outcome is resolved as a denial because +/// the mapper has no built-in user channel or pending store; a PS that needs an +/// interactive (deferred 202) consent flow should keep custom endpoints or supply +/// a decider that resolves to / +/// synchronously. The mission-creation +/// endpoint is intentionally not mapped here — building and signing the approval +/// blob and approving the proposal is PS-specific policy. +/// +public static class AAuthGovernanceApplicationBuilderExtensions +{ + /// + /// Map the permission, audit, and interaction governance endpoints using the + /// DI-registered seams. Call AddAAuthGovernance(...) first. + /// + /// The endpoint route builder (e.g. the WebApplication). + /// Optional route/path configuration. + /// The endpoint route builder for chaining. + public static IEndpointRouteBuilder MapAAuthGovernance( + this IEndpointRouteBuilder endpoints, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var options = new AAuthGovernancePipelineOptions(); + configure?.Invoke(options); + + endpoints.MapPost(options.Resolve(options.PermissionPath), HandlePermissionAsync); + endpoints.MapPost(options.Resolve(options.AuditPath), HandleAuditAsync); + endpoints.MapPost(options.Resolve(options.InteractionPath), HandleInteractionAsync); + + return endpoints; + } + + private static async Task HandlePermissionAsync( + HttpContext ctx, + IMissionStore missions, + IMissionLog log, + IPermissionDecider decider) + { + var body = await ReadJsonAsync(ctx).ConfigureAwait(false); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + PermissionRequest request; + try + { + request = GovernanceEndpoints.ParsePermission(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + StoredMission? stored = null; + IReadOnlyList history = []; + if (request.Mission is not null) + { + stored = await missions.GetAsync(request.Mission.S256).ConfigureAwait(false); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + history = await log.ReadAsync(request.Mission.S256).ConfigureAwait(false); + } + + var decision = await decider.DecideAsync( + new PermissionDecisionContext(request, stored, history), ctx.RequestAborted).ConfigureAwait(false); + + // First-pass mapper has no user channel: a Prompt resolves as a denial. + var granted = decision.Outcome == PermissionOutcome.Granted; + + if (request.Mission is not null) + { + await log.AppendAsync(new MissionLogEntry( + request.Mission.S256, MissionLogEntryKind.Permission, DateTimeOffset.UtcNow) + { + Action = request.Action, + Granted = granted, + Detail = decision.Reason.ToString(), + }).ConfigureAwait(false); + } + + return Results.Json(new + { + permission = granted ? "granted" : "denied", + reason = decision.Message ?? decision.Reason.ToString(), + }); + } + + private static async Task HandleAuditAsync( + HttpContext ctx, + IMissionStore missions, + IAuditSink sink) + { + var body = await ReadJsonAsync(ctx).ConfigureAwait(false); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + AuditRecord record; + try + { + record = GovernanceEndpoints.ParseAudit(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + var stored = await missions.GetAsync(record.Mission.S256).ConfigureAwait(false); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + + await sink.RecordAsync(record, ctx.RequestAborted).ConfigureAwait(false); + return Results.StatusCode(StatusCodes.Status201Created); + } + + private static async Task HandleInteractionAsync( + HttpContext ctx, + IMissionStore missions, + IMissionLog log, + IInteractionRelay relay) + { + var body = await ReadJsonAsync(ctx).ConfigureAwait(false); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + InteractionRequest request; + try + { + request = GovernanceEndpoints.ParseInteraction(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + if (request.Mission is not null) + { + var stored = await missions.GetAsync(request.Mission.S256).ConfigureAwait(false); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + } + + var result = await relay.RelayAsync(request, ctx.RequestAborted).ConfigureAwait(false); + + if (request.Mission is not null) + { + await log.AppendAsync(new MissionLogEntry( + request.Mission.S256, MissionLogEntryKind.Interaction, DateTimeOffset.UtcNow) + { + Detail = request.Type.ToString(), + }).ConfigureAwait(false); + } + + switch (request.Type) + { + case InteractionType.Question: + return Results.Json(new { answer = result.Answer ?? string.Empty }); + + case InteractionType.Completion: + if (result.Accepted == true && request.Mission is not null) + { + await missions.SetStateAsync(request.Mission.S256, MissionState.Terminated).ConfigureAwait(false); + return Results.Json(new { mission_status = "terminated" }); + } + return Results.Json(new { mission_status = "active" }); + + default: + return Results.Json(new { status = "ok" }); + } + } + + private static async Task ReadJsonAsync(HttpContext ctx) + { + try + { + return await ctx.Request.ReadFromJsonAsync().ConfigureAwait(false); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceClientServiceCollectionExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceClientServiceCollectionExtensions.cs new file mode 100644 index 0000000..a643cbb --- /dev/null +++ b/src/AAuth/DependencyInjection/AAuthGovernanceClientServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using System; +using AAuth; +using AAuth.Agent.Governance; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for registering an via DI, +/// so agents can resolve a configured governance client (mission / permission / +/// audit / interaction) instead of constructing one inline. Mirrors +/// . +/// +public static class AAuthGovernanceClientServiceCollectionExtensions +{ + /// + /// Register a singleton produced by + /// . The factory typically builds the client from a + /// configured via + /// , binding the agent + /// identity, signing mode, and Person Server. + /// + /// The service collection. + /// Factory that builds the governance client from the service provider. + /// The service collection for chaining. + public static IServiceCollection AddAAuthGovernanceClient( + this IServiceCollection services, + Func factory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(factory); + services.AddSingleton(factory); + return services; + } + + /// + /// Register a singleton built from an + /// configured by . + /// The builder MUST set a signing mode; bind a Person Server via + /// to enable mission sessions. + /// + /// The service collection. + /// Configures the client builder (signing mode, PS, etc.). + /// Default governance options applied when a call omits its own. + /// The service collection for chaining. + public static IServiceCollection AddAAuthGovernanceClient( + this IServiceCollection services, + Func configureBuilder, + GovernanceOptions? defaultOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureBuilder); + return services.AddAAuthGovernanceClient( + sp => configureBuilder(sp).BuildGovernance(defaultOptions)); + } +} diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs index 1fcb2a4..b32f877 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs @@ -6,24 +6,33 @@ namespace Microsoft.Extensions.DependencyInjection; /// /// Registers the PS-side governance seams (§PS Governance Endpoints, §Mission -/// Log). The storage seams default to in-memory implementations; the +/// Log). The storage seams default to in-memory implementations and the /// policy/user-channel seams (, /// , -/// ) are supplied by the PS. +/// ) default to conservative +/// no-op implementations, all via TryAdd so a PS overrides only what it needs. /// public static class AAuthGovernanceServiceCollectionExtensions { /// /// Register the default mission storage seams — /// and - /// — as singletons. - /// Uses TryAdd so a PS can register durable implementations first. + /// — plus default no-op + /// policy/user-channel seams (, + /// , + /// ) as singletons. + /// Every seam is registered with TryAdd so a PS overrides only what it + /// needs — register your own + /// (and friends) before or after this call to take over the policy. /// public static IServiceCollection AddAAuthGovernance(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } } diff --git a/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs b/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs new file mode 100644 index 0000000..bc57e4b --- /dev/null +++ b/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs @@ -0,0 +1,39 @@ +using System; + +namespace AAuth.Server.Governance; + +/// +/// Options for the PS governance endpoint mapper (MapAAuthGovernance). +/// Controls the route prefix and the per-endpoint paths so a PS can mount the +/// permission / audit / interaction endpoints where its metadata advertises them +/// (§Person Server Metadata, §PS Governance Endpoints). +/// +public sealed class AAuthGovernancePipelineOptions +{ + /// + /// Route prefix prepended to each endpoint path (default empty). For example, + /// set "/governance" to mount at /governance/permission. + /// + public string RoutePrefix { get; set; } = string.Empty; + + /// The permission endpoint path (§Permission Endpoint). Default /permission. + public string PermissionPath { get; set; } = "/permission"; + + /// The audit endpoint path (§Audit Endpoint). Default /audit. + public string AuditPath { get; set; } = "/audit"; + + /// The interaction endpoint path (§Interaction Endpoint). Default /mission-interaction. + public string InteractionPath { get; set; } = "/mission-interaction"; + + // Compose the prefix with a path, collapsing duplicate slashes at the seam. + internal string Resolve(string path) + { + if (string.IsNullOrEmpty(RoutePrefix)) + { + return path; + } + var prefix = RoutePrefix.TrimEnd('/'); + var suffix = path.StartsWith('/') ? path : "/" + path; + return prefix + suffix; + } +} diff --git a/src/AAuth/Server/Governance/DefaultAuditSink.cs b/src/AAuth/Server/Governance/DefaultAuditSink.cs new file mode 100644 index 0000000..65a14bf --- /dev/null +++ b/src/AAuth/Server/Governance/DefaultAuditSink.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// Default used when a PS registers +/// AddAAuthGovernance without supplying its own sink. It appends the +/// reported action to the mission log (§Audit Endpoint) so the trail is +/// preserved. A PS that needs anomaly detection or alerting should override it. +/// +public sealed class DefaultAuditSink : IAuditSink +{ + private readonly IMissionLog _log; + + /// Create the default sink over the registered mission log. + public DefaultAuditSink(IMissionLog log) + => _log = log ?? throw new ArgumentNullException(nameof(log)); + + /// + public Task RecordAsync(AuditRecord record, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(record); + return _log.AppendAsync( + new MissionLogEntry(record.Mission.S256, MissionLogEntryKind.Audit, DateTimeOffset.UtcNow) + { + Action = record.Action, + Detail = record.Description, + }, + ct); + } +} diff --git a/src/AAuth/Server/Governance/DefaultInteractionRelay.cs b/src/AAuth/Server/Governance/DefaultInteractionRelay.cs new file mode 100644 index 0000000..7db6521 --- /dev/null +++ b/src/AAuth/Server/Governance/DefaultInteractionRelay.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// Default used when a PS registers +/// AddAAuthGovernance without supplying its own user channel. It has no way +/// to reach the user, so it returns a benign, non-pending result: questions get an +/// empty answer and completion proposals are treated as not accepted (the mission +/// stays active). A PS that can reach the user MUST override this (§Interaction +/// Endpoint). +/// +public sealed class DefaultInteractionRelay : IInteractionRelay +{ + /// + public Task RelayAsync(InteractionRequest request, CancellationToken ct = default) + { + System.ArgumentNullException.ThrowIfNull(request); + return Task.FromResult(request.Type switch + { + InteractionType.Question => new InteractionRelayResult { Answer = string.Empty }, + InteractionType.Completion => new InteractionRelayResult { Accepted = false }, + _ => new InteractionRelayResult { Pending = false }, + }); + } +} diff --git a/src/AAuth/Server/Governance/DefaultPermissionDecider.cs b/src/AAuth/Server/Governance/DefaultPermissionDecider.cs new file mode 100644 index 0000000..524c540 --- /dev/null +++ b/src/AAuth/Server/Governance/DefaultPermissionDecider.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// Default used when a PS registers +/// AddAAuthGovernance without supplying its own policy. It grants a +/// request only when the action is a pre-approved tool on the bound mission +/// (§Permission Endpoint — pre-approved tools resolve without prompting); +/// every other action is left to the user via . +/// A real PS should override this with its own policy. +/// +public sealed class DefaultPermissionDecider : IPermissionDecider +{ + /// + public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default) + { + System.ArgumentNullException.ThrowIfNull(context); + + if (context.Mission is not null) + { + var mission = Mission.FromApprovalBytes(context.Mission.Blob.Span); + foreach (var tool in mission.ApprovedTools) + { + if (string.Equals(tool.Name, context.Request.Action, System.StringComparison.Ordinal)) + { + return Task.FromResult(new PermissionDecision( + PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool)); + } + } + } + + return Task.FromResult(new PermissionDecision( + PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope)); + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs new file mode 100644 index 0000000..4025f59 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Crypto; +using AAuth.Discovery; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the bound governance client and +/// (AAuth protocol §Mission Creation, §Mission Approval, §Permission Endpoint, +/// §Audit Endpoint, §Interaction Endpoint). A client bound to a Person Server +/// exposes , which returns a +/// session that auto-threads the mission claim ({approver, s256}) and PS +/// into every subsequent governed call. +/// +public class GovernanceClientBuilderTests +{ + private const string Ps = "http://localhost:5557"; + + private static AAuthGovernanceClient BuildBound(SessionHandler handler) + => AAuthGovernanceClient.Create( + new HttpClient(handler) { BaseAddress = new Uri(Ps) }, + new MetadataClient(new HttpClient(handler)), + personServer: Ps); + + [Fact(DisplayName = "§Mission Creation — Create factory binds the Person Server")] + public void Create_BindsPersonServer() + { + var client = BuildBound(new SessionHandler()); + Assert.Equal(Ps, client.PersonServer); + } + + [Fact(DisplayName = "§Mission Creation — BuildGovernance binds WithPersonServer URL")] + public void BuildGovernance_BindsPersonServer() + { + var client = new AAuthClientBuilder(AAuthKey.Generate()) + .UseHwk() + .WithPersonServer(Ps) + .WithInnerHandler(new SessionHandler()) + .BuildGovernance(); + + Assert.Equal(Ps, client.PersonServer); + } + + [Fact(DisplayName = "§Mission Creation — ProposeMissionAsync requires a bound PS")] + public async Task ProposeMissionAsync_Unbound_Throws() + { + var unbound = new AAuthGovernanceClient( + new HttpClient(new SessionHandler()) { BaseAddress = new Uri(Ps) }, + new MetadataClient(new HttpClient(new SessionHandler()))); + + var ex = await Assert.ThrowsAsync( + () => unbound.ProposeMissionAsync(new MissionProposal("# Plan"))); + Assert.Contains("bound Person Server", ex.Message); + } + + [Fact(DisplayName = "§Mission Approval — ProposeMissionAsync returns a session over the approved mission")] + public async Task ProposeMissionAsync_ReturnsSession() + { + var client = BuildBound(new SessionHandler()); + + var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip") + { + Tools = new[] { new MissionTool("WebSearch", "Search the web") }, + }); + + Assert.Equal(Ps, session.PersonServer); + Assert.Equal("aauth:assistant@agent.example", session.Mission.Agent); + Assert.NotEmpty(session.Mission.S256); + } + + [Fact(DisplayName = "§Permission Endpoint — session threads the mission claim into permission requests")] + public async Task Session_Permission_ThreadsMissionClaim() + { + var handler = new SessionHandler(); + var client = BuildBound(handler); + var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")); + + var result = await session.RequestPermissionAsync("SendEmail"); + + Assert.True(result.IsGranted); + Assert.Equal(session.Mission.S256, (string?)handler.LastPermissionBody?["mission"]?["s256"]); + Assert.Equal(Ps, (string?)handler.LastPermissionBody?["mission"]?["approver"]); + } + + [Fact(DisplayName = "§Permission Endpoint — a pre-approved tool short-circuits to granted")] + public async Task Session_Permission_PreApprovedTool_ShortCircuits() + { + var handler = new SessionHandler(); + var client = BuildBound(handler); + var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip") + { + Tools = new[] { new MissionTool("WebSearch", "Search the web") }, + }); + + var result = await session.RequestPermissionAsync("WebSearch"); + + Assert.True(result.IsGranted); + // Pre-approved tools never reach the PS permission endpoint. + Assert.Null(handler.LastPermissionBody); + } + + [Fact(DisplayName = "§Audit Endpoint — session threads the mission claim into audit records")] + public async Task Session_Audit_ThreadsMissionClaim() + { + var handler = new SessionHandler(); + var client = BuildBound(handler); + var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")); + + await session.RecordAuditAsync("WebSearch", description: "Looked up flights"); + + Assert.Equal(session.Mission.S256, (string?)handler.LastAuditBody?["mission"]?["s256"]); + Assert.Equal("WebSearch", (string?)handler.LastAuditBody?["action"]); + } + + [Fact(DisplayName = "§Interaction Endpoint — session asks a question and returns the answer")] + public async Task Session_AskQuestion_ReturnsAnswer() + { + var handler = new SessionHandler(); + var client = BuildBound(handler); + var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")); + + var answer = await session.AskQuestionAsync("Refundable option?"); + + Assert.Equal("Yes, go ahead.", answer); + Assert.Equal(session.Mission.S256, (string?)handler.LastInteractionBody?["mission"]?["s256"]); + } + + [Fact(DisplayName = "§Interaction Endpoint — session proposes completion and observes termination")] + public async Task Session_ProposeCompletion_Terminates() + { + var handler = new SessionHandler(); + var client = BuildBound(handler); + var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")); + + var terminated = await session.ProposeCompletionAsync("All booked."); + + Assert.True(terminated); + Assert.Equal("completion", (string?)handler.LastInteractionBody?["type"]); + } + + /// PS mock that serves the governance endpoints and captures request bodies. + private sealed class SessionHandler : HttpMessageHandler + { + public JsonObject? LastPermissionBody { get; private set; } + public JsonObject? LastAuditBody { get; private set; } + public JsonObject? LastInteractionBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + + if (path == "/.well-known/aauth-person.json") + { + return Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = Ps, + ["jwks_uri"] = Ps + "/jwks", + ["token_endpoint"] = Ps + "/token", + ["mission_endpoint"] = Ps + "/mission", + ["permission_endpoint"] = Ps + "/permission", + ["audit_endpoint"] = Ps + "/audit", + ["interaction_endpoint"] = Ps + "/interaction", + }); + } + + switch (path) + { + case "/mission": + { + var proposal = await ReadBody(request, ct); + var description = (string?)proposal?["description"] ?? "# Mission"; + var tools = proposal?["tools"] as JsonArray ?? new JsonArray(); + var blob = new JsonObject + { + ["approver"] = Ps, + ["agent"] = "aauth:assistant@agent.example", + ["approved_at"] = "2026-04-07T14:30:00Z", + ["description"] = description, + ["approved_tools"] = tools.DeepClone(), + }; + var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); + var s256 = Base64UrlEncoder.Encode(SHA256.HashData(bytes)); + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes), + }; + resp.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + resp.Headers.TryAddWithoutValidation( + "AAuth-Mission", $"approver=\"{Ps}\"; s256=\"{s256}\""); + return resp; + } + + case "/permission": + LastPermissionBody = await ReadBody(request, ct); + return Json(HttpStatusCode.OK, new JsonObject { ["permission"] = "granted" }); + + case "/audit": + LastAuditBody = await ReadBody(request, ct); + return new HttpResponseMessage(HttpStatusCode.Created); + + case "/interaction": + { + LastInteractionBody = await ReadBody(request, ct); + var type = (string?)LastInteractionBody?["type"]; + return type switch + { + "question" => Json(HttpStatusCode.OK, new JsonObject { ["answer"] = "Yes, go ahead." }), + "completion" => Json(HttpStatusCode.OK, new JsonObject { ["mission_status"] = "terminated" }), + _ => new HttpResponseMessage(HttpStatusCode.OK), + }; + } + + default: + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } + + private static async Task ReadBody(HttpRequestMessage request, CancellationToken ct) + { + if (request.Content is null) + { + return null; + } + var raw = await request.Content.ReadAsStringAsync(ct); + return string.IsNullOrWhiteSpace(raw) ? null : JsonNode.Parse(raw) as JsonObject; + } + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) + => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs new file mode 100644 index 0000000..e2ad879 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Server.Governance; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the PS governance endpoint mapper +/// (MapAAuthGovernance) over a real in-process host (AAuth protocol +/// §Permission Endpoint, §Audit Endpoint, §Interaction Endpoint, §Mission Status +/// Errors). The mapper drives the registered seams; AddAAuthGovernance +/// supplies conservative defaults. +/// +public class GovernanceEndpointMapperTests : IAsyncLifetime +{ + private const string Ps = "https://ps.example"; + private const string Approver = Ps; + + private IHost? _host; + private string _missionS256 = string.Empty; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddAAuthGovernance(); + builder.Services.AddRouting(); + + var app = builder.Build(); + app.MapAAuthGovernance(); + + // Seed an active mission with one pre-approved tool ("WebSearch"). + var store = app.Services.GetRequiredService(); + var (blob, s256) = BuildMission("aauth:assistant@agent.example", "WebSearch"); + _missionS256 = s256; + await store.SaveAsync(new StoredMission(s256, Approver, "aauth:assistant@agent.example", blob)); + + await app.StartAsync(); + _host = app; + } + + public async Task DisposeAsync() + { + if (_host is not null) { await _host.StopAsync(); _host.Dispose(); } + } + + private HttpClient Client() => _host!.GetTestServer().CreateClient(); + + private JsonObject MissionClaim() => new() + { + ["approver"] = Approver, + ["s256"] = _missionS256, + }; + + [Fact(DisplayName = "§Permission Endpoint — a pre-approved tool is granted by the default decider")] + public async Task Permission_ApprovedTool_Granted() + { + using var client = Client(); + var body = new JsonObject { ["action"] = "WebSearch", ["mission"] = MissionClaim() }; + + var response = await client.PostAsync("https://localhost/permission", JsonContent(body)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJson(response); + Assert.Equal("granted", (string?)json?["permission"]); + } + + [Fact(DisplayName = "§Permission Endpoint — an out-of-scope action is denied (no user channel in the mapper)")] + public async Task Permission_OutOfScope_Denied() + { + using var client = Client(); + var body = new JsonObject { ["action"] = "SendEmail", ["mission"] = MissionClaim() }; + + var response = await client.PostAsync("https://localhost/permission", JsonContent(body)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJson(response); + Assert.Equal("denied", (string?)json?["permission"]); + } + + [Fact(DisplayName = "§Permission Endpoint — missing action is a 400")] + public async Task Permission_MissingAction_BadRequest() + { + using var client = Client(); + var body = new JsonObject { ["mission"] = MissionClaim() }; + + var response = await client.PostAsync("https://localhost/permission", JsonContent(body)); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact(DisplayName = "§Audit Endpoint — a valid record is acknowledged with 201 Created")] + public async Task Audit_Valid_Created() + { + using var client = Client(); + var body = new JsonObject { ["mission"] = MissionClaim(), ["action"] = "WebSearch" }; + + var response = await client.PostAsync("https://localhost/audit", JsonContent(body)); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact(DisplayName = "§Interaction Endpoint — a question returns an answer field")] + public async Task Interaction_Question_ReturnsAnswer() + { + using var client = Client(); + var body = new JsonObject + { + ["type"] = "question", + ["question"] = "Refundable?", + ["mission"] = MissionClaim(), + }; + + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJson(response); + Assert.NotNull(json?["answer"]); + } + + [Fact(DisplayName = "§Mission Status Errors — permission on a terminated mission is 403 mission_terminated")] + public async Task Permission_TerminatedMission_Forbidden() + { + // Terminate the seeded mission, then request permission under it. + var store = _host!.Services.GetRequiredService(); + await store.SetStateAsync(_missionS256, MissionState.Terminated); + + using var client = Client(); + var body = new JsonObject { ["action"] = "WebSearch", ["mission"] = MissionClaim() }; + + var response = await client.PostAsync("https://localhost/permission", JsonContent(body)); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var json = await ReadJson(response); + Assert.Equal("mission_terminated", (string?)json?["error"]); + + // Restore active state so test ordering does not affect other cases. + await store.SetStateAsync(_missionS256, MissionState.Active); + } + + private static (byte[] Blob, string S256) BuildMission(string agent, params string[] approvedTools) + { + var tools = new JsonArray(); + foreach (var name in approvedTools) + { + tools.Add(new JsonObject { ["name"] = name }); + } + var blob = new JsonObject + { + ["approver"] = Approver, + ["agent"] = agent, + ["approved_at"] = "2026-04-07T14:30:00Z", + ["description"] = "# Plan a trip", + ["approved_tools"] = tools, + }; + var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); + return (bytes, Mission.ComputeS256(bytes)); + } + + private static StringContent JsonContent(JsonObject body) + => new(body.ToJsonString(), Encoding.UTF8, "application/json"); + + private static async Task ReadJson(HttpResponseMessage response) + => JsonNode.Parse(await response.Content.ReadAsStringAsync()) as JsonObject; +} diff --git a/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs new file mode 100644 index 0000000..adfe681 --- /dev/null +++ b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs @@ -0,0 +1,72 @@ +using System.Linq; +using System.Threading.Tasks; +using AAuth.Agent.Governance; +using AAuth.Server.Governance; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AAuth.Tests.DependencyInjection; + +/// +/// Unit tests for AddAAuthGovernance (§PS Governance Endpoints): the call +/// registers in-memory storage seams plus conservative default policy/user-channel +/// seams, all via TryAdd so a PS overrides only what it needs. +/// +public class AAuthGovernanceDITests +{ + [Fact] + public void AddAAuthGovernance_RegistersStorageAndDefaultSeams() + { + var services = new ServiceCollection(); + services.AddAAuthGovernance(); + var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void AddAAuthGovernance_DoesNotOverrideCustomSeams() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddAAuthGovernance(); + var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public async Task DefaultPermissionDecider_PromptsForUnknownAction() + { + var decider = new DefaultPermissionDecider(); + var context = new PermissionDecisionContext( + new PermissionRequest("SendEmail"), Mission: null, Log: System.Array.Empty()); + + var decision = await decider.DecideAsync(context); + + Assert.Equal(PermissionOutcome.Prompt, decision.Outcome); + Assert.Equal(PermissionDecisionReason.OutOfScope, decision.Reason); + } + + [Fact] + public async Task DefaultInteractionRelay_HasNoUserChannel() + { + var relay = new DefaultInteractionRelay(); + + var question = await relay.RelayAsync(new InteractionRequest(InteractionType.Question)); + Assert.Equal(string.Empty, question.Answer); + + var completion = await relay.RelayAsync(new InteractionRequest(InteractionType.Completion)); + Assert.False(completion.Accepted); + } + + private sealed class CustomDecider : IPermissionDecider + { + public Task DecideAsync(PermissionDecisionContext context, System.Threading.CancellationToken ct = default) + => Task.FromResult(new PermissionDecision(PermissionOutcome.Denied, PermissionDecisionReason.OutOfScope)); + } +} From 29f1feca098d03adc705257b0580640f871a9b12 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sat, 6 Jun 2026 21:16:54 +0000 Subject: [PATCH 15/24] feat(missions): bind governance clients and map mission creation + deferred consent (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the mission-api-refactor finalizes the DI-friendly governance surface and promotes the PS mission machinery into the SDK. D1 — remove per-call personServer: the bound governance client is now the only path. AAuthGovernanceClient/MissionClient/PermissionClient/AuditClient/ InteractionClient/MissionSession take the person server at construction; all consumers (tests + samples) migrated. D4 — MissionAction POCO with an implicit string conversion for terse call sites. D3a — promote mission creation into the SDK (closes DEV-2): add MissionApprovalBuilder (canonical approval-blob builder) and an IMissionApprover/DefaultMissionApprover seam. MapAAuthGovernance now maps the mission endpoint, verifies the agent token, runs the approver, persists the StoredMission, and emits the AAuth-Mission header. MockPersonServer reuses MissionApprovalBuilder. D3b — deferred consent (closes DEV-1): add opt-in IDeferredConsentStore / InMemoryDeferredConsentStore + AddAAuthDeferredConsent(). When registered, the mapper parks a Prompt outcome and answers 202 + poll route for both mission and permission; the store is opt-in so the existing Prompt->denied default is preserved. The interactive browser page stays a sample concern. Tests: unit 387 passed, conformance 448 passed (+8 mapper tests), mission e2e green; full solution builds 0/0. --- .../implementation-plan.md | 43 +-- .../issues-and-deviations.md | 4 +- .../research.md | 30 +- samples/GuidedTour/CodeSnippets.cs | 20 +- samples/MissionAgent/Program.cs | 35 +-- samples/MockPersonServer/MissionGovernance.cs | 44 +-- samples/MockPersonServer/Program.cs | 8 +- .../SampleApp/Components/Pages/Mission.razor | 15 +- src/AAuth/AAuthClientBuilder.cs | 14 +- .../Agent/Governance/AAuthGovernanceClient.cs | 60 ++-- src/AAuth/Agent/Governance/AuditClient.cs | 21 +- src/AAuth/Agent/Governance/AuditRecord.cs | 9 +- .../Agent/Governance/InteractionClient.cs | 35 ++- src/AAuth/Agent/Governance/MissionClient.cs | 22 +- src/AAuth/Agent/Governance/MissionSession.cs | 15 +- .../Agent/Governance/PermissionClient.cs | 29 +- .../Agent/Governance/PermissionRequest.cs | 9 +- src/AAuth/Agent/MissionAction.cs | 28 ++ ...hGovernanceApplicationBuilderExtensions.cs | 236 +++++++++++++- ...thGovernanceServiceCollectionExtensions.cs | 18 ++ .../AAuthGovernancePipelineOptions.cs | 27 ++ .../Server/Governance/DefaultAuditSink.cs | 2 +- .../Governance/DefaultMissionApprover.cs | 20 ++ .../Governance/DefaultPermissionDecider.cs | 2 +- .../Governance/IDeferredConsentStore.cs | 74 +++++ .../Server/Governance/IMissionApprover.cs | 75 +++++ .../InMemoryDeferredConsentStore.cs | 55 ++++ .../Governance/MissionApprovalBuilder.cs | 64 ++++ .../Missions/GovernanceClientBuilderTests.cs | 13 +- .../Missions/GovernanceClientTests.cs | 44 +-- .../GovernanceDeferredConsentMapperTests.cs | 290 ++++++++++++++++++ .../Missions/GovernanceFacadeTests.cs | 14 +- .../Integration/MissionAgentFlowTests.cs | 14 +- 33 files changed, 1110 insertions(+), 279 deletions(-) create mode 100644 src/AAuth/Agent/MissionAction.cs create mode 100644 src/AAuth/Server/Governance/DefaultMissionApprover.cs create mode 100644 src/AAuth/Server/Governance/IDeferredConsentStore.cs create mode 100644 src/AAuth/Server/Governance/IMissionApprover.cs create mode 100644 src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs create mode 100644 src/AAuth/Server/Governance/MissionApprovalBuilder.cs create mode 100644 tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md index 86d6fa3..f7bdd8c 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -184,9 +184,9 @@ app.MapAAuthGovernance(); // maps the 4 endpoints + pending polls - [x] `BuildGovernance()` binds the PS URL and default `GovernanceOptions`. - [x] `MissionSession` injects `{approver, s256}` into permission/audit/interaction. -- [ ] Per-call `personServer` parameters removed from the governance clients. _(deferred to Phase 2/4 — first pass is additive, see D1)_ +- [x] Per-call `personServer` parameters removed from the governance clients. _(done in Phase 2, D1 — bound client is the only path)_ - [x] Client reachable via static factory, fluent builder, and `AddAAuthGovernanceClient(...)`. -- [~] `MapAAuthGovernance()` maps mission/permission/audit/interaction + poll routes. _(permission/audit/interaction mapped; mission-creation + poll deferred, see DEV-2)_ +- [x] `MapAAuthGovernance()` maps mission/permission/audit/interaction + poll routes. _(mission-creation via `IMissionApprover`; deferred 202 + poll via `IDeferredConsentStore`, Phase 2 D3)_ - [x] `AddAAuthGovernance(configure?)` registers default no-op seams via `TryAdd`. - [~] `mission_terminated` 403 + carrier-token checks centralized in the mapper. _(403 termination centralized; carrier-token checks pending Phase 2)_ - [x] New unit + conformance tests pass; full suite green (build 0/0). @@ -222,19 +222,20 @@ capability — naming, shape, and ergonomics only. Confirms the construction tri - **D2 — keep `MissionSession` flat.** Confirm flat methods (`RequestPermissionAsync`, `RecordAuditAsync`, `AskQuestionAsync`, `ProposeCompletionAsync`); no nested facades. -- **D4 — typed tool/action POCO (replace bare strings).** Introduce a small POCO - for the invoked tool/action so callers pass a value object instead of a `string`. - Today `action` is a bare `string` on `PermissionRequest`, `AuditRecord`, - `MissionSession.RequestPermissionAsync/RecordAuditAsync`, and `PermissionClient`. - **Decision (2026-06-06): reuse the existing `MissionTool`** — the spec defines - `action` as a tool name and `approved_tools` as `{name, description}` objects, - which is exactly `MissionTool(Name, Description?)`; one type spans propose → - approve → invoke. Serialize the `action` JSON field from `MissionTool.Name`. Add - an implicit `string → MissionTool` conversion so terse call sites (`"WebSearch"`) - still compile. Note the two distinct descriptions: `MissionTool.Description` - (static, mirrors `approved_tools[].description`) vs `PermissionRequest.Description` - (per-call markdown). Update the `DefaultPermissionDecider` match (action vs - `ApprovedTools`) to compare by `Name`. +- **D4 — typed action POCO (replace bare strings).** Introduce a small + `MissionAction` POCO for the invoked action so callers pass a value object + instead of a `string`. Today `action` is a bare `string` on `PermissionRequest`, + `AuditRecord`, `MissionSession.RequestPermissionAsync/RecordAuditAsync`, and + `PermissionClient`. **Decision (2026-06-06):** model the *invocation* as a + distinct `MissionAction` rather than reusing `MissionTool` — the spec's `action` + is broader than a tool (covers file writes, message sends), and a dedicated type + avoids the redundant `MissionTool.Description` on the invocation path. Named + `MissionAction` (not bare `Action`) to avoid the `System.Action` clash. Keep + `MissionTool` as the *catalog* entry (proposal / `approved_tools`); `MissionAction` + is the *specific invocation*. Serialize the wire `action` field from + `MissionAction.Name`; add an implicit `string → MissionAction` conversion so terse + call sites (`"WebSearch"`) still compile. Update the `DefaultPermissionDecider` + match to compare `MissionAction.Name` against `ApprovedTools[].Name`. - **D3 — promote PS mission machinery into the SDK (closes DEV-1/DEV-2).** Move the approval-blob builder out of the sample into the SDK and add an `IMissionApprover` seam so `MapAAuthGovernance` can map mission creation; add a @@ -248,7 +249,7 @@ capability — naming, shape, and ergonomics only. Confirms the construction tri |------|--------| | Phase 1 source files | **Modify** — rename/reshape per the convention diff | | `src/AAuth/Agent/Governance/*Client.cs` | **Modify** — remove per-call `personServer` params (D1) | -| `src/AAuth/Agent/MissionTool.cs` + `PermissionRequest`/`AuditRecord`/`MissionSession` | **Modify** — accept the tool/action POCO; implicit `string` conversion (D4) | +| `src/AAuth/Agent/MissionAction.cs` (new) + `PermissionRequest`/`AuditRecord`/`MissionSession`/`PermissionClient` | **Add/Modify** — accept `MissionAction`; implicit `string` conversion (D4) | | `src/AAuth/Server/Governance/*` (approval builder, `IMissionApprover`, pending/deferred seam) | **Add/Modify** — promote from sample (D3) | | `src/AAuth/.../MapAAuthGovernance` | **Modify** — map mission creation + deferred 202 (D3) | | `samples/MockPersonServer/*` | **Modify** — consume promoted SDK pieces where it reduces sample-local code | @@ -270,11 +271,11 @@ capability — naming, shape, and ergonomics only. Confirms the construction tri - [ ] Convention diff recorded in research (Part B / Open Design Choices). - [ ] Factory, builder, and DI paths share names/options/defaults across both sides. - [ ] Public names align with `AAuthClientBuilder` / `AddAAuth*` / `MapAAuth*`. -- [ ] Per-call `personServer` params removed; bound client is the only path (D1). -- [ ] Tool/action passed as a POCO (implicit `string` for terse call sites) (D4). -- [ ] Mission creation mapped by `MapAAuthGovernance` via `IMissionApprover` (D3, DEV-2). -- [ ] `Prompt` outcome returns a deferred 202 via the pending-consent seam (D3, DEV-1). -- [ ] All Phase 1 tests updated and green; full suite green (build 0/0). +- [x] Per-call `personServer` params removed; bound client is the only path (D1). +- [x] Action passed as a `MissionAction` POCO (implicit `string` for terse call sites) (D4). +- [x] Mission creation mapped by `MapAAuthGovernance` via `IMissionApprover` (D3, DEV-2). +- [x] `Prompt` outcome returns a deferred 202 via the pending-consent seam (D3, DEV-1). +- [x] All Phase 1 tests updated and green; full suite green (build 0/0). --- diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md index c08e98e..d76a164 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md @@ -41,8 +41,8 @@ These judgment calls were made during Phase 1. **All confirmed by the user on | ID | Phase | Area | Summary | Spec § | Status | |----|-------|------|---------|--------|--------| -| DEV-1 | 1 | Resource mapper | `MapAAuthGovernance` resolves `PermissionOutcome.Prompt` as a denial — no built-in deferred (202) user-consent channel. The MockPersonServer keeps its custom interactive/pending endpoints until a deferred-flow design lands. | §Permission Endpoint (deferred consent) | scheduled (Phase 2, D3) | -| DEV-2 | 1 | Resource mapper | Mission-creation endpoint not mapped by `MapAAuthGovernance`; approval-blob building + proposal approval stay PS-side (`MissionApproval` is sample-local). Promote a reusable approval-blob builder + an `IMissionApprover` seam into the SDK. | §Mission Creation, §Mission Approval | scheduled (Phase 2, D3) | +| DEV-1 | 1 | Resource mapper | `MapAAuthGovernance` resolved `PermissionOutcome.Prompt` as a denial — no built-in deferred (202) user-consent channel. | §Permission Endpoint (deferred consent) | resolved (Phase 2, D3 — `IDeferredConsentStore` seam + `AddAAuthDeferredConsent()`; mapper parks `Prompt` and answers 202 + poll route. Store is opt-in so the existing `Prompt`→denied default is preserved. Interactive browser page stays a sample concern.) | +| DEV-2 | 1 | Resource mapper | Mission-creation endpoint not mapped by `MapAAuthGovernance`; approval-blob building + proposal approval stayed PS-side (`MissionApproval` was sample-local). | §Mission Creation, §Mission Approval | resolved (Phase 2, D3 — `MissionApprovalBuilder` + `IMissionApprover`/`DefaultMissionApprover` promoted into the SDK; `MapAAuthGovernance` maps the mission endpoint, persists the `StoredMission`, and emits the `AAuth-Mission` header. MockPersonServer now uses `MissionApprovalBuilder`.) | | DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. | §Interaction Endpoint | intentional | ## Notes diff --git a/.agent/plans/2026-06-06-mission-api-refactor/research.md b/.agent/plans/2026-06-06-mission-api-refactor/research.md index d5ac489..86447d1 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/research.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/research.md @@ -54,10 +54,15 @@ It contains **no** implementation steps or task lists — those live in > via `TryAdd`; new `MapAAuthGovernance(...)` maps permission/audit/interaction > from the seams; `AAuthGovernancePipelineOptions` controls routes. > - **Construction triad (DC5)** satisfied: static factory + fluent builder + DI. +> - **Phase 2 D3 (closes DEV-1/DEV-2):** `MissionApprovalBuilder` + an +> `IMissionApprover`/`DefaultMissionApprover` seam promoted into the SDK so +> `MapAAuthGovernance` maps mission creation (persists the `StoredMission` and +> emits the `AAuth-Mission` header). An opt-in `IDeferredConsentStore` seam +> (`AddAAuthDeferredConsent()`) lets the mapper park a `Prompt` outcome and +> answer 202 + poll route; the interactive browser page stays a sample concern. > - **Open items** logged in [issues-and-deviations.md](issues-and-deviations.md): -> DEV-1 (mapper `Prompt`→denied, no deferred channel), DEV-2 (mission-creation -> not mapped — `MissionApproval` still sample-local), DEV-3 (no-op relay), and -> transitional decisions D1–D3 for user confirmation. +> DEV-3 (no-op relay) remains intentional; DEV-1 and DEV-2 are resolved in +> Phase 2 D3. ## Source Documents @@ -173,6 +178,25 @@ well-known + verification + challenge in one call — the natural template for a `MapAAuthGovernance(...)` PS mapper (PT-R1). `AddAAuthDiscovery(configure?)` is the template for an optional-callback DI registration (PT-A4). +### B.5 — Phase 2 convention diff (verified 2026-06) + +> **Update (2026-06) — Phase 1 surface vs. SDK conventions.** Comparing the +> committed Phase 1 surface against B.1–B.4: +> +> | Divergence | Phase 1 state | Convention | Phase 2 action | +> |---|---|---|---| +> | Per-call `personServer` | Every governed method takes `string personServer` as its first arg | Builder binds context once (`.WithPersonServer`); other clients don't re-take it per call | **D1** — bind the PS into `AAuthGovernanceClient` + sub-clients at construction; drop the param. Bound client becomes the only path. | +> | Bare `string action` | `PermissionRequest`/`AuditRecord`/`MissionSession`/`PermissionClient` pass `action` as `string` | DTOs are records/POCOs (`MissionTool`, `MissionClaim`) | **D4** — new `MissionAction` record + implicit `string` conversion. | +> | Unbound construction allowed | `AAuthGovernanceClient` has an unbound ctor + nullable `PersonServer` | `BuildGovernance()`/`Create()` are the blessed entry points | Make PS required on the bound path; keep `Create(...)` factory + `BuildGovernance(...)` + `AddAAuthGovernanceClient(...)` as the triad. | +> | Mapper coverage | Maps permission/audit/interaction only | `MapAAuthResource` bundles the whole pipeline | **D3** — add mission-creation (`IMissionApprover`) + deferred-consent (Prompt→202) so `MapAAuthGovernance` is a complete PS pipeline. | +> +> **Sequencing note:** D1 removes a surface consumed by `MissionAgent/Program.cs`, +> `GuidedTour/CodeSnippets.cs`, and the conformance tests. To honor DC6 (build 0/0 +> after every phase), those governance call sites are migrated to the bound +> `MissionSession` surface **within Phase 2** (the structural sample work remains +> Phase 4/5). Names already match conventions (`With*`/`Create`/`Add*`/`Map*`); no +> renames of the Phase 1 public entry points are required. + --- ## Part C — Mission Sample/Doc Call-Site Inventory (Work Stream 1.1) diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 693c920..f061745 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -245,9 +245,9 @@ internal static class CodeSnippets """; public const string MissionPropose = """ - var governance = new AAuthGovernanceClient(signedClient, metadata); - var mission = await governance.Mission.ProposeAsync( - "https://ps.example", + var governance = new AAuthGovernanceClient( + signedClient, metadata, "https://ps.example"); + var session = await governance.ProposeMissionAsync( new MissionProposal("Triage the user's inbox…") { Tools = @@ -257,6 +257,7 @@ internal static class CodeSnippets }, }, new GovernanceOptions { OnInteractionRequired = SurfaceToUser }); + var mission = session.Mission; // session auto-threads the claim + PS // SDK POSTs /mission → 202; SurfaceToUser shows the consent link, // then the client polls until the user approves. """; @@ -328,17 +329,15 @@ internal static class CodeSnippets public const string MissionPreApproved = """ // Pre-approved tools never hit the network — the SDK short-circuits. - var result = await governance.Permission.RequestAsync( - "https://ps.example", "send_email", mission); + var result = await session.RequestPermissionAsync("send_email"); // result.IsGranted == true (no PS call: send_email ∈ mission.ApprovedTools) """; public const string MissionPermissionPrompt = """ // delete_inbox is NOT pre-approved → the PS prompts the user. - var result = await governance.Permission.RequestAsync( - "https://ps.example", - new PermissionRequest("delete_inbox") { Mission = missionClaim }, - new GovernanceOptions { OnInteractionRequired = SurfaceToUser }); + var result = await session.RequestPermissionAsync( + "delete_inbox", + options: new GovernanceOptions { OnInteractionRequired = SurfaceToUser }); // SDK POSTs /permission → 202; surfaces the link; polls for the decision. """; @@ -348,8 +347,7 @@ internal static class CodeSnippets if (!result.IsGranted) throw new InvalidOperationException(result.Reason); // user denied // On grant: run delete_inbox, then report it to the audit_endpoint. - await governance.Audit.RecordAsync("https://ps.example", - new AuditRecord(missionClaim, "delete_inbox")); + await session.RecordAuditAsync("delete_inbox"); """; public const string MissionInspect = """ diff --git a/samples/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs index 9769368..179fe57 100644 --- a/samples/MissionAgent/Program.cs +++ b/samples/MissionAgent/Program.cs @@ -147,7 +147,7 @@ var agentHandler = new AAuthSigningHandler(key, () => agentToken) { InnerHandler = new HttpClientHandler() }; var signedClient = new HttpClient(agentHandler) { Timeout = Timeout.InfiniteTimeSpan }; var metadata = new MetadataClient(new HttpClient()); -var governance = new AAuthGovernanceClient(signedClient, metadata); +var governance = new AAuthGovernanceClient(signedClient, metadata, personServer); var exchange = new TokenExchangeClient(signedClient, metadata); // Tell the mock PS how to resolve prompts. Interactive mode holds each prompt @@ -184,7 +184,7 @@ await ScriptAsync(new JsonObject // the agent quotes on every later request to bind it to this mission. In // interactive mode the PS shows a browser consent screen here; in --auto mode it // resolves the approval itself. -var mission = await governance.Mission.ProposeAsync(personServer, new MissionProposal( +var session = await governance.ProposeMissionAsync(new MissionProposal( "Help the user keep their inbox under control for the next hour.") { Tools = new[] @@ -193,7 +193,9 @@ await ScriptAsync(new JsonObject new MissionTool("summarize", "Summarize a thread"), }, }, GovernanceFor("Approve this mission and its tools")); -var missionClaim = new MissionClaim(mission.Approver, mission.S256); +// The session wraps the approved mission and auto-threads its claim +// (approver + s256) and the bound PS into every later governed call. +var mission = session.Mission; Console.WriteLine($" description : {mission.Description}"); Console.WriteLine($" approved by : {mission.Approver}"); Console.WriteLine($" approved tools : {string.Join(", ", mission.ApprovedTools.Select(t => t.Name))}"); @@ -259,41 +261,34 @@ await ScriptAsync(new JsonObject Section("6. Request a permission for a pre-approved tool — silent"); // `send_email` is an approved tool, so the SDK short-circuits to granted // without ever calling the PS (§Permission Endpoint). -var preApproved = await governance.Permission.RequestAsync(personServer, "send_email", mission); +var preApproved = await session.RequestPermissionAsync("send_email"); Console.WriteLine($" send_email : {(preApproved.IsGranted ? "granted" : "denied")} ({preApproved.Reason})"); Section("7. Request a permission for a NON-pre-approved tool"); // `delete_inbox` is not an approved tool, so the PS is consulted and the user -// is prompted to decide. -var adHoc = await governance.Permission.RequestAsync( - personServer, - new PermissionRequest("delete_inbox") { Mission = missionClaim }, - GovernanceFor("Permission to permanently delete the inbox")); +// is prompted to decide. The session threads the mission claim automatically. +var adHoc = await session.RequestPermissionAsync( + "delete_inbox", + options: GovernanceFor("Permission to permanently delete the inbox")); Console.WriteLine($" delete_inbox : {(adHoc.IsGranted ? "granted" : "denied")} ({adHoc.Reason})"); Section("8. Report an action to the audit endpoint"); // After acting, the agent records what it did under the mission (§Audit Endpoint). -await governance.Audit.RecordAsync(personServer, new AuditRecord(missionClaim, "send_email") -{ - Description = "Sent a reply to the design-review thread.", - Result = new JsonObject { ["status"] = "success" }, -}); +await session.RecordAuditAsync("send_email", + description: "Sent a reply to the design-review thread.", + result: new JsonObject { ["status"] = "success" }); Console.WriteLine(" recorded send_email = success"); Section("9. Ask the user a question"); -var answer = await governance.Interaction.AskQuestionAsync( - personServer, +var answer = await session.AskQuestionAsync( "Want me to keep going for another hour?", description: "The mission's hour is nearly up.", - mission: missionClaim, options: GovernanceFor("A question from your agent")); Console.WriteLine($" user answered : {answer ?? "(no answer)"}"); Section("10. Propose mission completion (terminates the mission)"); -var terminated = await governance.Interaction.ProposeCompletionAsync( - personServer, +var terminated = await session.ProposeCompletionAsync( "Inbox triaged: 12 read, 3 replied, 1 deleted.", - missionClaim, GovernanceFor("Your agent says the mission is done")); Console.WriteLine($" mission ended : {terminated}"); diff --git a/samples/MockPersonServer/MissionGovernance.cs b/samples/MockPersonServer/MissionGovernance.cs index aae3f18..198bce6 100644 --- a/samples/MockPersonServer/MissionGovernance.cs +++ b/samples/MockPersonServer/MissionGovernance.cs @@ -149,46 +149,6 @@ public bool IsInScope(string s256, string resource, string scope) private sealed record MissionPolicy(string Description, HashSet Tools, HashSet InScope); } -/// -/// Builds the verbatim mission approval blob (§Mission Approval). The bytes are -/// returned exactly as they will be sent so the s256 the PS advertises in -/// the AAuth-Mission header matches what the agent computes. -/// -public static class MissionApproval -{ - /// Build the approval blob bytes and their s256 identity. - public static (byte[] Blob, string S256) Build( - string approver, - string agent, - MissionProposal proposal, - IReadOnlyList approvedTools, - DateTimeOffset approvedAt) - { - var tools = new JsonArray(); - foreach (var tool in approvedTools) - { - var obj = new JsonObject { ["name"] = tool.Name }; - if (!string.IsNullOrEmpty(tool.Description)) - { - obj["description"] = tool.Description; - } - tools.Add(obj); - } - - var blob = new JsonObject - { - ["approver"] = approver, - ["agent"] = agent, - ["approved_at"] = approvedAt.ToString("o"), - ["description"] = proposal.Description, - ["approved_tools"] = tools, - }; - - var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); - return (bytes, Mission.ComputeS256(bytes)); - } -} - /// /// The PS's permission policy (§Permission Endpoint): a pre-approved tool is /// granted silently; any other action falls to the user, whose scripted decision @@ -206,7 +166,7 @@ public SamplePermissionDecider(MissionPolicyStore policy) public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default) { - var action = context.Request.Action; + var action = context.Request.Action.Name; // §Permission Endpoint: a pre-approved tool resolves without prompting. if (context.Mission is not null && _policy.IsApprovedTool(context.Mission.S256, action)) @@ -236,7 +196,7 @@ public Task RecordAsync(AuditRecord record, CancellationToken ct = default) => _log.AppendAsync( new MissionLogEntry(record.Mission.S256, MissionLogEntryKind.Audit, DateTimeOffset.UtcNow) { - Action = record.Action, + Action = record.Action.Name, Detail = record.Description, }, ct); diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index ef77f51..dd2e69c 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -767,7 +767,7 @@ await missionLog.AppendAsync(new MissionLogEntry( // The demo approves every proposed tool; a real PS would let the user prune them. var approvedTools = proposal.Tools; - var (blob, s256) = MissionApproval.Build(psIssuer, agentId, proposal, approvedTools, DateTimeOffset.UtcNow); + var (blob, s256) = MissionApprovalBuilder.Build(psIssuer, agentId, proposal, approvedTools, DateTimeOffset.UtcNow); await missions.SaveAsync(new StoredMission(s256, psIssuer, agentId, blob)); policy.Record(s256, proposal.Description, approvedTools, script.InScopeSnapshot()); @@ -813,7 +813,7 @@ await missionLog.AppendAsync(new MissionLogEntry( var proposal = entry.Proposal!; // The demo approves every proposed tool; a real PS would let the user prune them. var approvedTools = proposal.Tools; - var (blob, s256) = MissionApproval.Build(psIssuer, entry.AgentId, proposal, approvedTools, DateTimeOffset.UtcNow); + var (blob, s256) = MissionApprovalBuilder.Build(psIssuer, entry.AgentId, proposal, approvedTools, DateTimeOffset.UtcNow); await missions.SaveAsync(new StoredMission(s256, psIssuer, entry.AgentId, blob)); policy.Record(s256, proposal.Description, approvedTools, script.InScopeSnapshot()); ctx.Response.Headers[AAuthMissionHeader.Name] = @@ -874,7 +874,7 @@ await missionLog.AppendAsync(new MissionLogEntry( AgentId = agentId, S256 = request.Mission.S256, Approver = request.Mission.Approver, - Action = request.Action, + Action = request.Action.Name, }); ctx.Response.Headers.Location = $"/permission-pending/{entry.Id}"; ctx.Response.Headers["Retry-After"] = "0"; @@ -894,7 +894,7 @@ await missionLog.AppendAsync(new MissionLogEntry( await log.AppendAsync(new MissionLogEntry( request.Mission.S256, MissionLogEntryKind.Permission, DateTimeOffset.UtcNow) { - Action = request.Action, + Action = request.Action.Name, Granted = granted, Detail = decision.Reason.ToString(), }); diff --git a/samples/SampleApp/Components/Pages/Mission.razor b/samples/SampleApp/Components/Pages/Mission.razor index 01f5a74..0af2e8a 100644 --- a/samples/SampleApp/Components/Pages/Mission.razor +++ b/samples/SampleApp/Components/Pages/Mission.razor @@ -50,14 +50,13 @@ var handler = new AAuthSigningHandler( identity.Key, () => agentToken) { InnerHandler = new HttpClientHandler() }; var signed = new HttpClient(handler); -var governance = new AAuthGovernanceClient(signed, metadata); +var governance = new AAuthGovernanceClient(signed, metadata, personServer); // 1. Propose a mission (PROMPT — the human approves intent + tools). // A proposal carries a Markdown description + an optional `tools` // list ONLY. It declares NO scopes — scopes are never pre-listed // on a mission (§Mission Creation). var mission = await governance.Mission.ProposeAsync( - personServer, new MissionProposal("Keep the inbox under control for an hour.") { Tools = new[] @@ -108,11 +107,10 @@ var elevated = await ExchangeForScopeAsync( // 4. Permission for a pre-approved tool (SILENT — no PS call). var sendEmail = await governance.Permission.RequestAsync( - personServer, "send_email", mission); + "send_email", mission); // 5. Permission for a non-approved tool (PROMPT — the PS asks). var deleteInbox = await governance.Permission.RequestAsync( - personServer, new PermissionRequest("delete_inbox") { Mission = new MissionClaim(mission.Approver, mission.S256) }, governanceOptions); @@ -188,7 +186,7 @@ app.MapGet("/protected_endpoint/elevated", (HttpContext ctx) => if (deleteInbox.IsGranted) { await DeleteInboxAsync(); // your own code / MCP tool call - await governance.Audit.RecordAsync(personServer, + await governance.Audit.RecordAsync( new AuditRecord( new MissionClaim(mission.Approver, mission.S256), "delete_inbox") @@ -373,7 +371,7 @@ if (deleteInbox.IsGranted) { InnerHandler = new HttpClientHandler() }; using var signed = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; var metadata = new MetadataClient(new HttpClient()); - var governance = new AAuthGovernanceClient(signed, metadata); + var governance = new AAuthGovernanceClient(signed, metadata, personServer); var exchange = new TokenExchangeClient(signed, metadata); var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(2) }; @@ -385,7 +383,7 @@ if (deleteInbox.IsGranted) // ---- Gate 1: propose the mission (PROMPT) ---------------------- _currentGate = "Mission creation"; - var mission = await governance.Mission.ProposeAsync(personServer, new MissionProposal( + var mission = await governance.Mission.ProposeAsync(new MissionProposal( "Help the user keep their inbox under control for the next hour.") { Tools = new[] @@ -459,7 +457,7 @@ if (deleteInbox.IsGranted) // ---- Gate 4: permission for a pre-approved tool (SILENT) -------- var sendEmail = await governance.Permission.RequestAsync( - personServer, "send_email", mission); + "send_email", mission); _steps.Add(new GateStep(4, "Permission send_email", Prompted: false, Outcome: sendEmail.IsGranted ? "granted" : "denied", Summary: "Pre-approved tool — resolved locally without a PS round-trip.", @@ -474,7 +472,6 @@ if (deleteInbox.IsGranted) try { var deleteInbox = await governance.Permission.RequestAsync( - personServer, new PermissionRequest("delete_inbox") { Mission = new MissionClaim(mission.Approver, mission.S256) }, GovernanceFor()); diff --git a/src/AAuth/AAuthClientBuilder.cs b/src/AAuth/AAuthClientBuilder.cs index a3732fa..08ec5e9 100644 --- a/src/AAuth/AAuthClientBuilder.cs +++ b/src/AAuth/AAuthClientBuilder.cs @@ -403,18 +403,24 @@ public AAuthGovernanceClient BuildGovernance() /// /// Build a governance client bound to the Person Server configured via - /// (when present), with default deferred-handling - /// options. A bound client exposes + /// , with default deferred-handling options. The + /// bound client exposes /// which returns a that auto-threads - /// the mission claim and PS into subsequent calls. Requires an explicit signing mode. + /// the mission claim and PS into subsequent calls. Requires an explicit signing + /// mode and a configured Person Server. /// /// Default governance options applied when a call omits its own. - /// No signing mode was configured. + /// No signing mode or no Person Server was configured. public AAuthGovernanceClient BuildGovernance(Agent.Governance.GovernanceOptions? defaultOptions) { var provider = _provider ?? throw new InvalidOperationException( "BuildGovernance requires an explicit signing mode (UseHwk, UseJwt, UseJwksUri, UseJktJwt, or UseProvider)."); + if (string.IsNullOrEmpty(_personServer)) + { + throw new InvalidOperationException( + "BuildGovernance requires a Person Server. Configure one via WithPersonServer(...)."); + } var (signed, metadata) = BuildSignedChannel(provider, _innerHandler ?? new HttpClientHandler()); return new AAuthGovernanceClient(signed, metadata, _personServer, defaultOptions); } diff --git a/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs b/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs index 9602a83..0efa1e5 100644 --- a/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs +++ b/src/AAuth/Agent/Governance/AAuthGovernanceClient.cs @@ -18,15 +18,16 @@ namespace AAuth.Agent.Governance; /// configured with the agent token, so /// every governance request is signed. /// -/// When a Person Server is bound (via -/// or the factory), returns a -/// that auto-threads the mission claim and PS into every -/// subsequent call. +/// The client is always bound to a Person Server (via +/// , the +/// factory, or the constructor), so individual calls never re-supply it. +/// returns a that +/// auto-threads the mission claim and PS into every subsequent call. /// /// public sealed class AAuthGovernanceClient { - private readonly string? _personServer; + private readonly string _personServer; private readonly GovernanceOptions? _defaultOptions; /// Propose and approve missions at the PS mission_endpoint. @@ -42,56 +43,48 @@ public sealed class AAuthGovernanceClient public InteractionClient Interaction { get; } /// - /// The Person Server this client is bound to, or when - /// unbound. When bound, is available and the - /// per-call personServer argument can be omitted via a - /// . + /// The Person Server this client is bound to. Every governed call targets it, + /// and returns a + /// that auto-threads the mission claim and PS into subsequent calls. /// - public string? PersonServer => _personServer; - - /// Create the facade over a signed client and metadata client. - /// HttpClient wired with an . - /// Metadata client for resolving the PS governance endpoints. - public AAuthGovernanceClient(HttpClient signedClient, MetadataClient metadata) - : this(signedClient, metadata, personServer: null, defaultOptions: null) - { - } + public string PersonServer => _personServer; /// /// Create the facade bound to a Person Server with default governance options. /// /// HttpClient wired with an . /// Metadata client for resolving the PS governance endpoints. - /// The PS URL to bind, or to stay unbound. + /// The PS URL this client targets. REQUIRED. /// Default deferred-handling options applied when a call omits its own. public AAuthGovernanceClient( HttpClient signedClient, MetadataClient metadata, - string? personServer, - GovernanceOptions? defaultOptions) + string personServer, + GovernanceOptions? defaultOptions = null) { ArgumentNullException.ThrowIfNull(signedClient); ArgumentNullException.ThrowIfNull(metadata); + ArgumentException.ThrowIfNullOrEmpty(personServer); _personServer = personServer; _defaultOptions = defaultOptions; - Mission = new MissionClient(signedClient, metadata); - Permission = new PermissionClient(signedClient, metadata); - Audit = new AuditClient(signedClient, metadata); - Interaction = new InteractionClient(signedClient, metadata); + Mission = new MissionClient(signedClient, metadata, personServer); + Permission = new PermissionClient(signedClient, metadata, personServer); + Audit = new AuditClient(signedClient, metadata, personServer); + Interaction = new InteractionClient(signedClient, metadata, personServer); } /// /// Static factory mirroring the SDK's other Create/Build entry - /// points. Equivalent to the bound constructor. + /// points. Equivalent to the constructor. /// /// HttpClient wired with an . /// Metadata client for resolving the PS governance endpoints. - /// The PS URL to bind, or to stay unbound. + /// The PS URL this client targets. REQUIRED. /// Default deferred-handling options applied when a call omits its own. public static AAuthGovernanceClient Create( HttpClient signedClient, MetadataClient metadata, - string? personServer = null, + string personServer, GovernanceOptions? defaultOptions = null) => new(signedClient, metadata, personServer, defaultOptions); @@ -100,23 +93,14 @@ public static AAuthGovernanceClient Create( /// §Mission Approval) and return a that /// auto-threads the mission claim and PS into subsequent governed calls. /// - /// No Person Server is bound. public async Task ProposeMissionAsync( MissionProposal proposal, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(proposal); - if (string.IsNullOrEmpty(_personServer)) - { - throw new InvalidOperationException( - "ProposeMissionAsync requires a bound Person Server. Bind one via " + - "AAuthClientBuilder.WithPersonServer(...).BuildGovernance() or " + - "AAuthGovernanceClient.Create(signedClient, metadata, personServer)."); - } - var mission = await Mission.ProposeAsync( - _personServer, proposal, options ?? _defaultOptions, cancellationToken).ConfigureAwait(false); + proposal, options ?? _defaultOptions, cancellationToken).ConfigureAwait(false); return new MissionSession(this, _personServer, mission, _defaultOptions); } } diff --git a/src/AAuth/Agent/Governance/AuditClient.cs b/src/AAuth/Agent/Governance/AuditClient.cs index f60b290..b4bd814 100644 --- a/src/AAuth/Agent/Governance/AuditClient.cs +++ b/src/AAuth/Agent/Governance/AuditClient.cs @@ -19,26 +19,29 @@ namespace AAuth.Agent.Governance; public sealed class AuditClient { private readonly DeferredExchange _exchange; + private readonly string _personServer; - /// Create the audit client. - public AuditClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new DeferredExchange(signedClient, metadata); + /// Create the audit client bound to a Person Server. + public AuditClient(HttpClient signedClient, MetadataClient metadata, string personServer) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + _exchange = new DeferredExchange(signedClient, metadata); + _personServer = personServer; + } /// - /// Record at the PS at . - /// Returns once the PS acknowledges with 201 Created. Surfaces - /// mission_terminated as . + /// Record at the bound PS. Returns once the PS + /// acknowledges with 201 Created. Surfaces mission_terminated as + /// . /// public async Task RecordAsync( - string personServer, AuditRecord record, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(personServer); ArgumentNullException.ThrowIfNull(record); var endpoint = await _exchange.ResolveEndpointAsync( - personServer, "audit_endpoint", cancellationToken).ConfigureAwait(false); + _personServer, "audit_endpoint", cancellationToken).ConfigureAwait(false); // Audit is fire-and-forget; no deferral handling is expected. var response = await _exchange.PostAsync( diff --git a/src/AAuth/Agent/Governance/AuditRecord.cs b/src/AAuth/Agent/Governance/AuditRecord.cs index ff69cf3..5cb1591 100644 --- a/src/AAuth/Agent/Governance/AuditRecord.cs +++ b/src/AAuth/Agent/Governance/AuditRecord.cs @@ -10,8 +10,8 @@ namespace AAuth.Agent.Governance; /// mission — there is no audit outside a mission context. ///
/// Mission binding (approver + s256). REQUIRED. -/// String identifying the action that was performed. REQUIRED. -public sealed record AuditRecord(MissionClaim Mission, string Action) +/// The action that was performed. REQUIRED. +public sealed record AuditRecord(MissionClaim Mission, MissionAction Action) { /// Markdown description of what was done and the outcome. Optional. public string? Description { get; init; } @@ -26,11 +26,12 @@ public sealed record AuditRecord(MissionClaim Mission, string Action) internal JsonObject ToJsonObject() { ArgumentNullException.ThrowIfNull(Mission); - ArgumentException.ThrowIfNullOrEmpty(Action); + ArgumentNullException.ThrowIfNull(Action); + ArgumentException.ThrowIfNullOrEmpty(Action.Name); var body = new JsonObject { ["mission"] = Mission.ToJsonObject(), - ["action"] = Action, + ["action"] = Action.Name, }; if (!string.IsNullOrEmpty(Description)) { diff --git a/src/AAuth/Agent/Governance/InteractionClient.cs b/src/AAuth/Agent/Governance/InteractionClient.cs index 0398abf..11bb533 100644 --- a/src/AAuth/Agent/Governance/InteractionClient.cs +++ b/src/AAuth/Agent/Governance/InteractionClient.cs @@ -21,26 +21,29 @@ namespace AAuth.Agent.Governance; public sealed class InteractionClient { private readonly DeferredExchange _exchange; + private readonly string _personServer; - /// Create the interaction client. - public InteractionClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new DeferredExchange(signedClient, metadata); + /// Create the interaction client bound to a Person Server. + public InteractionClient(HttpClient signedClient, MetadataClient metadata, string personServer) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + _exchange = new DeferredExchange(signedClient, metadata); + _personServer = personServer; + } /// - /// Send to the PS at - /// and return the terminal result, polling through any deferred response. + /// Send to the bound PS and return the terminal + /// result, polling through any deferred response. /// public async Task SendAsync( - string personServer, InteractionRequest request, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(personServer); ArgumentNullException.ThrowIfNull(request); var endpoint = await _exchange.ResolveEndpointAsync( - personServer, "interaction_endpoint", cancellationToken).ConfigureAwait(false); + _personServer, "interaction_endpoint", cancellationToken).ConfigureAwait(false); var response = await _exchange.PostAsync( endpoint, request.ToJsonObject(), @@ -87,10 +90,10 @@ public async Task SendAsync( /// Relay a resource interaction (URL + code) to the user. public Task RelayInteractionAsync( - string personServer, string url, string code, + string url, string code, string? description = null, MissionClaim? mission = null, GovernanceOptions? options = null, CancellationToken cancellationToken = default) - => SendAsync(personServer, new InteractionRequest(InteractionType.Interaction) + => SendAsync(new InteractionRequest(InteractionType.Interaction) { Url = url, Code = code, @@ -100,10 +103,10 @@ public Task RelayInteractionAsync( /// Forward a payment approval (URL + code) to the user. public Task RelayPaymentAsync( - string personServer, string url, string code, + string url, string code, string? description = null, MissionClaim? mission = null, GovernanceOptions? options = null, CancellationToken cancellationToken = default) - => SendAsync(personServer, new InteractionRequest(InteractionType.Payment) + => SendAsync(new InteractionRequest(InteractionType.Payment) { Url = url, Code = code, @@ -113,11 +116,11 @@ public Task RelayPaymentAsync( /// Ask the user a question and return the answer. public async Task AskQuestionAsync( - string personServer, string question, + string question, string? description = null, MissionClaim? mission = null, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { - var result = await SendAsync(personServer, new InteractionRequest(InteractionType.Question) + var result = await SendAsync(new InteractionRequest(InteractionType.Question) { Question = question, Description = description, @@ -131,11 +134,11 @@ public Task RelayPaymentAsync( /// when the user accepted and the PS terminated the mission. ///
public async Task ProposeCompletionAsync( - string personServer, string summary, MissionClaim mission, + string summary, MissionClaim mission, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(mission); - var result = await SendAsync(personServer, new InteractionRequest(InteractionType.Completion) + var result = await SendAsync(new InteractionRequest(InteractionType.Completion) { Summary = summary, Mission = mission, diff --git a/src/AAuth/Agent/Governance/MissionClient.cs b/src/AAuth/Agent/Governance/MissionClient.cs index 53bcc27..b49757e 100644 --- a/src/AAuth/Agent/Governance/MissionClient.cs +++ b/src/AAuth/Agent/Governance/MissionClient.cs @@ -21,33 +21,37 @@ namespace AAuth.Agent.Governance; public sealed class MissionClient { private readonly DeferredExchange _exchange; + private readonly string _personServer; - /// Create the mission client. + /// Create the mission client bound to a Person Server. /// HttpClient wired with an . /// Metadata client for resolving the PS mission_endpoint. - public MissionClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new DeferredExchange(signedClient, metadata); + /// The PS this client targets. + public MissionClient(HttpClient signedClient, MetadataClient metadata, string personServer) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + _exchange = new DeferredExchange(signedClient, metadata); + _personServer = personServer; + } /// - /// Propose a mission to the PS at and return - /// the approved . Handles the 202 review / - /// clarification path via . + /// Propose a mission to the bound PS and return the approved + /// . Handles the 202 review / clarification path + /// via . /// /// /// The PS did not return an AAuth-Mission header, or the returned /// s256 does not match the hash of the approval body. /// public async Task ProposeAsync( - string personServer, MissionProposal proposal, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(personServer); ArgumentNullException.ThrowIfNull(proposal); var endpoint = await _exchange.ResolveEndpointAsync( - personServer, "mission_endpoint", cancellationToken).ConfigureAwait(false); + _personServer, "mission_endpoint", cancellationToken).ConfigureAwait(false); var response = await _exchange.PostAsync( endpoint, proposal.ToJsonObject(), diff --git a/src/AAuth/Agent/Governance/MissionSession.cs b/src/AAuth/Agent/Governance/MissionSession.cs index d8937e4..fdc8aa3 100644 --- a/src/AAuth/Agent/Governance/MissionSession.cs +++ b/src/AAuth/Agent/Governance/MissionSession.cs @@ -50,13 +50,13 @@ internal MissionSession( /// other action is evaluated by the PS. The mission claim and PS are injected. ///
public Task RequestPermissionAsync( - string action, + MissionAction action, string? description = null, JsonObject? parameters = null, GovernanceOptions? options = null, CancellationToken cancellationToken = default) => _governance.Permission.RequestAsync( - _personServer, action, Mission, description, parameters, + action, Mission, description, parameters, options ?? _defaultOptions, cancellationToken); /// @@ -64,13 +64,12 @@ public Task RequestPermissionAsync( /// The mission claim and PS are injected. /// public Task RecordAuditAsync( - string action, + MissionAction action, string? description = null, JsonObject? parameters = null, JsonObject? result = null, CancellationToken cancellationToken = default) => _governance.Audit.RecordAsync( - _personServer, new AuditRecord(Claim, action) { Description = description, @@ -89,7 +88,7 @@ public Task RecordAuditAsync( GovernanceOptions? options = null, CancellationToken cancellationToken = default) => _governance.Interaction.AskQuestionAsync( - _personServer, question, description, Claim, + question, description, Claim, options ?? _defaultOptions, cancellationToken); /// @@ -103,7 +102,7 @@ public Task RelayInteractionAsync( GovernanceOptions? options = null, CancellationToken cancellationToken = default) => _governance.Interaction.RelayInteractionAsync( - _personServer, url, code, description, Claim, + url, code, description, Claim, options ?? _defaultOptions, cancellationToken); /// @@ -117,7 +116,7 @@ public Task RelayPaymentAsync( GovernanceOptions? options = null, CancellationToken cancellationToken = default) => _governance.Interaction.RelayPaymentAsync( - _personServer, url, code, description, Claim, + url, code, description, Claim, options ?? _defaultOptions, cancellationToken); /// @@ -130,6 +129,6 @@ public Task ProposeCompletionAsync( GovernanceOptions? options = null, CancellationToken cancellationToken = default) => _governance.Interaction.ProposeCompletionAsync( - _personServer, summary, Claim, + summary, Claim, options ?? _defaultOptions, cancellationToken); } diff --git a/src/AAuth/Agent/Governance/PermissionClient.cs b/src/AAuth/Agent/Governance/PermissionClient.cs index 5b32ca0..33f680b 100644 --- a/src/AAuth/Agent/Governance/PermissionClient.cs +++ b/src/AAuth/Agent/Governance/PermissionClient.cs @@ -20,26 +20,29 @@ namespace AAuth.Agent.Governance; public sealed class PermissionClient { private readonly DeferredExchange _exchange; + private readonly string _personServer; - /// Create the permission client. - public PermissionClient(HttpClient signedClient, MetadataClient metadata) - => _exchange = new DeferredExchange(signedClient, metadata); + /// Create the permission client bound to a Person Server. + public PermissionClient(HttpClient signedClient, MetadataClient metadata, string personServer) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + _exchange = new DeferredExchange(signedClient, metadata); + _personServer = personServer; + } /// - /// Request permission for from the PS at - /// . Handles deferred (user-input) responses. + /// Request permission for from the bound PS. + /// Handles deferred (user-input) responses. /// public async Task RequestAsync( - string personServer, PermissionRequest request, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(personServer); ArgumentNullException.ThrowIfNull(request); var endpoint = await _exchange.ResolveEndpointAsync( - personServer, "permission_endpoint", cancellationToken).ConfigureAwait(false); + _personServer, "permission_endpoint", cancellationToken).ConfigureAwait(false); var response = await _exchange.PostAsync( endpoint, request.ToJsonObject(), @@ -80,20 +83,20 @@ public async Task RequestAsync( /// pre-approved tools"). Otherwise calls the PS. /// public Task RequestAsync( - string personServer, - string action, + MissionAction action, Mission mission, string? description = null, JsonObject? parameters = null, GovernanceOptions? options = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(action); + ArgumentNullException.ThrowIfNull(action); + ArgumentException.ThrowIfNullOrEmpty(action.Name); ArgumentNullException.ThrowIfNull(mission); foreach (var tool in mission.ApprovedTools) { - if (string.Equals(tool.Name, action, StringComparison.Ordinal)) + if (string.Equals(tool.Name, action.Name, StringComparison.Ordinal)) { return Task.FromResult(new PermissionResult( PermissionGrant.Granted, "Pre-approved tool on the active mission.")); @@ -106,6 +109,6 @@ public Task RequestAsync( Parameters = parameters, Mission = new Tokens.MissionClaim(mission.Approver, mission.S256), }; - return RequestAsync(personServer, request, options, cancellationToken); + return RequestAsync(request, options, cancellationToken); } } diff --git a/src/AAuth/Agent/Governance/PermissionRequest.cs b/src/AAuth/Agent/Governance/PermissionRequest.cs index 30d2d0b..96abf66 100644 --- a/src/AAuth/Agent/Governance/PermissionRequest.cs +++ b/src/AAuth/Agent/Governance/PermissionRequest.cs @@ -9,8 +9,8 @@ namespace AAuth.Agent.Governance; /// (§Permission Request) for an action not governed by a remote resource — a /// tool call, file write, or message send. /// -/// String identifying the action (e.g. a tool name). REQUIRED. -public sealed record PermissionRequest(string Action) +/// The action the agent wants to perform (e.g. a tool name). REQUIRED. +public sealed record PermissionRequest(MissionAction Action) { /// Markdown description of what the action will do and why. Optional. public string? Description { get; init; } @@ -27,8 +27,9 @@ public sealed record PermissionRequest(string Action) /// Render the request as the JSON request body. internal JsonObject ToJsonObject() { - ArgumentException.ThrowIfNullOrEmpty(Action); - var body = new JsonObject { ["action"] = Action }; + ArgumentNullException.ThrowIfNull(Action); + ArgumentException.ThrowIfNullOrEmpty(Action.Name); + var body = new JsonObject { ["action"] = Action.Name }; if (!string.IsNullOrEmpty(Description)) { body["description"] = Description; diff --git a/src/AAuth/Agent/MissionAction.cs b/src/AAuth/Agent/MissionAction.cs new file mode 100644 index 0000000..eb417a5 --- /dev/null +++ b/src/AAuth/Agent/MissionAction.cs @@ -0,0 +1,28 @@ +namespace AAuth.Agent; + +/// +/// A specific action the agent invokes within a mission — the action sent +/// to the PS's permission and audit endpoints (§Permission Endpoint, §Audit +/// Endpoint). The spec defines action as "a string identifying the action +/// the agent wants to perform (e.g., a tool name)", so it is broader than a tool: +/// it also covers file writes, message sends, and other governed operations. +/// +/// +/// is the invocation; +/// is the catalog entry (a proposal's requested tools / the approval's +/// approved_tools). A pre-approved tool can be invoked directly via the +/// implicit conversion from , and a bare action name via +/// the implicit conversion from . +/// +/// The action identifier serialized as the wire action. REQUIRED. +public sealed record MissionAction(string Name) +{ + /// A bare action name (e.g. "WebSearch") is a . + public static implicit operator MissionAction(string name) => new(name); + + /// Invoke a catalog as an action by its name. + public static implicit operator MissionAction(MissionTool tool) => new(tool.Name); + + /// + public override string ToString() => Name; +} diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs index e1d4422..fd31cee 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs @@ -2,38 +2,45 @@ using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Nodes; +using AAuth; using AAuth.Agent; using AAuth.Agent.Governance; +using AAuth.Headers; using AAuth.Server.Governance; +using AAuth.Server.Verification; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Builder; /// -/// Maps the PS governance endpoints (§Permission Endpoint, §Audit Endpoint, -/// §Interaction Endpoint) onto seam-driven handlers, mirroring +/// Maps the PS governance endpoints (§Mission Creation, §Permission Endpoint, +/// §Audit Endpoint, §Interaction Endpoint) onto seam-driven handlers, mirroring /// MapAAuthResource. The handlers parse the request with /// , enforce the /// mission_terminated rule, and delegate the decision to the registered -/// / / -/// seams (registered by AddAAuthGovernance). +/// / / +/// / seams (registered by +/// AddAAuthGovernance). /// /// -/// This first-pass mapper handles the synchronous decision path. A -/// outcome is resolved as a denial because -/// the mapper has no built-in user channel or pending store; a PS that needs an -/// interactive (deferred 202) consent flow should keep custom endpoints or supply -/// a decider that resolves to / -/// synchronously. The mission-creation -/// endpoint is intentionally not mapped here — building and signing the approval -/// blob and approving the proposal is PS-specific policy. +/// A / +/// outcome is resolved synchronously (a permission denial / a mission decline) +/// UNLESS an is registered (via +/// AddAAuthDeferredConsent): with the store, the mapper parks the request, +/// answers 202 Accepted with a poll Location, and resolves it once +/// the user decides (§Deferred Consent). The PS still owns the browser consent +/// page that records the user's decision via +/// ; the mapper only emits the +/// 202 + poll route and completes the parked decision. /// public static class AAuthGovernanceApplicationBuilderExtensions { /// - /// Map the permission, audit, and interaction governance endpoints using the - /// DI-registered seams. Call AddAAuthGovernance(...) first. + /// Map the mission, permission, audit, and interaction governance endpoints + /// (plus the deferred-consent poll route) using the DI-registered seams. Call + /// AddAAuthGovernance(...) first. /// /// The endpoint route builder (e.g. the WebApplication). /// Optional route/path configuration. @@ -47,15 +54,89 @@ public static IEndpointRouteBuilder MapAAuthGovernance( var options = new AAuthGovernancePipelineOptions(); configure?.Invoke(options); - endpoints.MapPost(options.Resolve(options.PermissionPath), HandlePermissionAsync); + endpoints.MapPost(options.Resolve(options.MissionPath), + (HttpContext ctx, IMissionStore missions, IMissionApprover approver) => + HandleMissionAsync(ctx, options, missions, approver)); + endpoints.MapPost(options.Resolve(options.PermissionPath), + (HttpContext ctx, IMissionStore missions, IMissionLog log, IPermissionDecider decider) => + HandlePermissionAsync(ctx, options, missions, log, decider)); endpoints.MapPost(options.Resolve(options.AuditPath), HandleAuditAsync); endpoints.MapPost(options.Resolve(options.InteractionPath), HandleInteractionAsync); + endpoints.MapGet(options.Resolve(options.PendingPath).TrimEnd('/') + "/{id}", + (HttpContext ctx, string id, IMissionStore missions, IMissionLog log) => + HandlePendingAsync(ctx, id, options, missions, log)); return endpoints; } + private static async Task HandleMissionAsync( + HttpContext ctx, + AAuthGovernancePipelineOptions options, + IMissionStore missions, + IMissionApprover approver) + { + var verification = ctx.GetAAuthVerification(); + if (verification?.TokenType != AAuthTokenType.AgentToken || string.IsNullOrEmpty(verification.Agent)) + { + return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status401Unauthorized); + } + + var body = await ReadJsonAsync(ctx).ConfigureAwait(false); + if (body is null) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + MissionProposal proposal; + try + { + proposal = GovernanceEndpoints.ParseMissionProposal(body); + } + catch (FormatException) + { + return Results.Json(new { error = "invalid_request" }, statusCode: StatusCodes.Status400BadRequest); + } + + var approverUrl = ResolveApprover(ctx, options); + var decision = await approver.ApproveAsync( + new MissionApprovalContext(verification.Agent, approverUrl, proposal), ctx.RequestAborted).ConfigureAwait(false); + + switch (decision.Outcome) + { + case MissionApprovalOutcome.Declined: + return Results.Json( + new { error = "access_denied", detail = decision.Message }, + statusCode: StatusCodes.Status403Forbidden); + + case MissionApprovalOutcome.Prompt: + { + var store = ctx.RequestServices.GetService(); + if (store is null) + { + // No user channel: a prompt cannot be resolved — decline. + return Results.Json( + new { error = "access_denied" }, statusCode: StatusCodes.Status403Forbidden); + } + var parked = await store.ParkAsync(new DeferredConsent + { + Kind = DeferredConsentKind.MissionCreation, + Agent = verification.Agent, + Approver = approverUrl, + Proposal = proposal, + }, ctx.RequestAborted).ConfigureAwait(false); + return DeferredAccepted(ctx, options, parked.Id); + } + + default: + return await CompleteMissionAsync( + ctx, missions, approverUrl, verification.Agent, proposal, decision.ApprovedTools) + .ConfigureAwait(false); + } + } + private static async Task HandlePermissionAsync( HttpContext ctx, + AAuthGovernancePipelineOptions options, IMissionStore missions, IMissionLog log, IPermissionDecider decider) @@ -91,7 +172,23 @@ private static async Task HandlePermissionAsync( var decision = await decider.DecideAsync( new PermissionDecisionContext(request, stored, history), ctx.RequestAborted).ConfigureAwait(false); - // First-pass mapper has no user channel: a Prompt resolves as a denial. + // A Prompt defers to the user when a deferred-consent store is registered; + // otherwise the mapper has no user channel and resolves it as a denial. + if (decision.Outcome == PermissionOutcome.Prompt) + { + var store = ctx.RequestServices.GetService(); + if (store is not null) + { + var parked = await store.ParkAsync(new DeferredConsent + { + Kind = DeferredConsentKind.Permission, + Approver = ResolveApprover(ctx, options), + Permission = request, + }, ctx.RequestAborted).ConfigureAwait(false); + return DeferredAccepted(ctx, options, parked.Id); + } + } + var granted = decision.Outcome == PermissionOutcome.Granted; if (request.Mission is not null) @@ -99,7 +196,7 @@ private static async Task HandlePermissionAsync( await log.AppendAsync(new MissionLogEntry( request.Mission.S256, MissionLogEntryKind.Permission, DateTimeOffset.UtcNow) { - Action = request.Action, + Action = request.Action.Name, Granted = granted, Detail = decision.Reason.ToString(), }).ConfigureAwait(false); @@ -203,6 +300,111 @@ await log.AppendAsync(new MissionLogEntry( } } + // Resolve a parked deferred consent once the user has decided (§Deferred + // Consent). Pending → 202 again; approved/declined → the final governance + // response (mission blob / permission decision / access_denied). + private static async Task HandlePendingAsync( + HttpContext ctx, + string id, + AAuthGovernancePipelineOptions options, + IMissionStore missions, + IMissionLog log) + { + var store = ctx.RequestServices.GetService(); + if (store is null) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + + var entry = await store.GetAsync(id, ctx.RequestAborted).ConfigureAwait(false); + if (entry is null) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + + // Hold at 202 until the user decides on the PS consent page. + if (entry.Decision is null) + { + return DeferredAccepted(ctx, options, id); + } + + await store.RemoveAsync(id, ctx.RequestAborted).ConfigureAwait(false); + + if (entry.Kind == DeferredConsentKind.MissionCreation) + { + if (!entry.Decision.Value) + { + ctx.Response.Headers.CacheControl = "no-store"; + return Results.Json( + new { error = "access_denied", detail = "the user declined this mission" }, + statusCode: StatusCodes.Status403Forbidden); + } + var proposal = entry.Proposal!; + return await CompleteMissionAsync( + ctx, missions, entry.Approver, entry.Agent, proposal, proposal.Tools).ConfigureAwait(false); + } + + // Permission: the endpoint always returns a decision (200), never access_denied. + var request = entry.Permission!; + var granted = entry.Decision.Value; + if (request.Mission is not null) + { + await log.AppendAsync(new MissionLogEntry( + request.Mission.S256, MissionLogEntryKind.Permission, DateTimeOffset.UtcNow) + { + Action = request.Action.Name, + Granted = granted, + Detail = PermissionDecisionReason.OutOfScope.ToString(), + }).ConfigureAwait(false); + } + return Results.Json(new + { + permission = granted ? "granted" : "denied", + reason = granted ? "The user approved." : "The user declined.", + }); + } + + // Build the verbatim approval blob, persist the mission, and answer with the + // blob bytes + the AAuth-Mission header (§Mission Approval). + private static async Task CompleteMissionAsync( + HttpContext ctx, + IMissionStore missions, + string approver, + string agent, + MissionProposal proposal, + IReadOnlyList approvedTools) + { + var (blob, s256) = MissionApprovalBuilder.Build( + approver, agent, proposal, approvedTools, DateTimeOffset.UtcNow); + await missions.SaveAsync(new StoredMission(s256, approver, agent, blob)).ConfigureAwait(false); + ctx.Response.Headers[AAuthMissionHeader.Name] = + AAuthMissionHeader.FormatStructured(approver, s256); + return Results.Bytes(blob, "application/json"); + } + + // Emit a 202 Accepted with a poll Location (and, when configured, an + // interaction requirement header) for a parked deferred consent. + private static IResult DeferredAccepted( + HttpContext ctx, AAuthGovernancePipelineOptions options, string pendingId) + { + var pollPath = options.Resolve(options.PendingPath).TrimEnd('/') + "/" + pendingId; + ctx.Response.Headers.Location = pollPath; + ctx.Response.Headers["Retry-After"] = "1"; + ctx.Response.Headers.CacheControl = "no-store"; + if (!string.IsNullOrEmpty(options.InteractionUrl)) + { + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format(options.InteractionUrl, pendingId); + } + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + + // The PS's canonical approver URL: the configured Approver, else the request origin. + private static string ResolveApprover(HttpContext ctx, AAuthGovernancePipelineOptions options) + => string.IsNullOrEmpty(options.Approver) + ? $"{ctx.Request.Scheme}://{ctx.Request.Host}" + : options.Approver; + private static async Task ReadJsonAsync(HttpContext ctx) { try diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs index b32f877..c282f70 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs @@ -30,9 +30,27 @@ public static IServiceCollection AddAAuthGovernance(this IServiceCollection serv ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); return services; } + + /// + /// Opt the governance mapper into the deferred-consent (202 poll) flow + /// for / + /// outcomes + /// (§Deferred Consent). Registers the default in-memory + /// via TryAdd. + /// Without this call a Prompt outcome is resolved synchronously (a + /// permission denial / a mission decline), since the mapper has no user channel. + /// The PS still owns the browser consent page that resolves parked entries. + /// + public static IServiceCollection AddAAuthDeferredConsent(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(); + return services; + } } diff --git a/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs b/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs index bc57e4b..cc76c4f 100644 --- a/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs +++ b/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs @@ -25,6 +25,33 @@ public sealed class AAuthGovernancePipelineOptions /// The interaction endpoint path (§Interaction Endpoint). Default /mission-interaction. public string InteractionPath { get; set; } = "/mission-interaction"; + /// The mission-creation endpoint path (§Mission Creation). Default /mission. + public string MissionPath { get; set; } = "/mission"; + + /// + /// The deferred-consent poll endpoint path template (§Deferred Consent). The + /// mapper appends /{id}. Default /governance-pending; the + /// 202 Location points the agent here. + /// + public string PendingPath { get; set; } = "/governance-pending"; + + /// + /// Optional user-facing interaction URL (§User Interaction). When set, the + /// 202 deferred-consent response includes an + /// AAuth-Requirement: requirement=interaction header pointing the + /// agent's user here (the PS's browser consent page). When null, the agent + /// relies on polling the Location alone. + /// + public string? InteractionUrl { get; set; } + + /// + /// The PS's canonical approver URL written into mission approval blobs + /// (§Mission Approval). When null, the mapper derives it from the request + /// origin (scheme://host). Set this when the PS's advertised issuer + /// differs from the request origin (e.g. behind a proxy). + /// + public string? Approver { get; set; } + // Compose the prefix with a path, collapsing duplicate slashes at the seam. internal string Resolve(string path) { diff --git a/src/AAuth/Server/Governance/DefaultAuditSink.cs b/src/AAuth/Server/Governance/DefaultAuditSink.cs index 65a14bf..282bcf6 100644 --- a/src/AAuth/Server/Governance/DefaultAuditSink.cs +++ b/src/AAuth/Server/Governance/DefaultAuditSink.cs @@ -26,7 +26,7 @@ public Task RecordAsync(AuditRecord record, CancellationToken ct = default) return _log.AppendAsync( new MissionLogEntry(record.Mission.S256, MissionLogEntryKind.Audit, DateTimeOffset.UtcNow) { - Action = record.Action, + Action = record.Action.Name, Detail = record.Description, }, ct); diff --git a/src/AAuth/Server/Governance/DefaultMissionApprover.cs b/src/AAuth/Server/Governance/DefaultMissionApprover.cs new file mode 100644 index 0000000..9e3db20 --- /dev/null +++ b/src/AAuth/Server/Governance/DefaultMissionApprover.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace AAuth.Server.Governance; + +/// +/// Default used when a PS registers +/// AddAAuthGovernance without supplying its own approver. It approves every +/// proposed mission and every proposed tool (§Mission Creation). A real PS should +/// override this to surface the proposal to the user and prune the approved tools. +/// +public sealed class DefaultMissionApprover : IMissionApprover +{ + /// + public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default) + { + System.ArgumentNullException.ThrowIfNull(context); + return Task.FromResult(MissionApprovalDecision.Approve(context.Proposal.Tools)); + } +} diff --git a/src/AAuth/Server/Governance/DefaultPermissionDecider.cs b/src/AAuth/Server/Governance/DefaultPermissionDecider.cs index 524c540..3dc7a34 100644 --- a/src/AAuth/Server/Governance/DefaultPermissionDecider.cs +++ b/src/AAuth/Server/Governance/DefaultPermissionDecider.cs @@ -25,7 +25,7 @@ public Task DecideAsync(PermissionDecisionContext context, C var mission = Mission.FromApprovalBytes(context.Mission.Blob.Span); foreach (var tool in mission.ApprovedTools) { - if (string.Equals(tool.Name, context.Request.Action, System.StringComparison.Ordinal)) + if (string.Equals(tool.Name, context.Request.Action.Name, System.StringComparison.Ordinal)) { return Task.FromResult(new PermissionDecision( PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool)); diff --git a/src/AAuth/Server/Governance/IDeferredConsentStore.cs b/src/AAuth/Server/Governance/IDeferredConsentStore.cs new file mode 100644 index 0000000..888b5bd --- /dev/null +++ b/src/AAuth/Server/Governance/IDeferredConsentStore.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// The governance decision a deferred consent resolves (§Deferred Consent). +public enum DeferredConsentKind +{ + /// A mission proposal awaiting the user's approval (§Mission Creation). + MissionCreation, + + /// A permission request awaiting the user's decision (§Permission Endpoint). + Permission, +} + +/// +/// A governance decision parked for the user (§Deferred Consent). When the PS +/// cannot decide synchronously, MapAAuthGovernance parks the request here, +/// answers 202 Accepted with a poll Location, and resolves the +/// parked entry once the user decides (typically from the PS's browser consent +/// page, which calls ). +/// +public sealed class DeferredConsent +{ + /// The opaque pending id (assigned by the store on park). + public string Id { get; set; } = string.Empty; + + /// Which governance decision this entry resolves. + public required DeferredConsentKind Kind { get; init; } + + /// The agent the request was made by. + public string Agent { get; init; } = string.Empty; + + /// HTTPS URL of the approver (the PS). + public string Approver { get; init; } = string.Empty; + + /// The proposal (set when is ). + public MissionProposal? Proposal { get; init; } + + /// The permission request (set when is ). + public PermissionRequest? Permission { get; init; } + + /// + /// The user's decision: while pending, + /// on approval, on decline. + /// + public bool? Decision { get; set; } +} + +/// +/// PS-side persistence seam for deferred (user-driven) governance consents +/// (§Deferred Consent). The SDK supplies the contract and an in-memory default +/// (); a production PS swaps in durable +/// storage. Registering this seam (via AddAAuthDeferredConsent) opts the +/// governance mapper into the 202 poll flow for Prompt outcomes. +/// +public interface IDeferredConsentStore +{ + /// Park a pending consent, assigning and returning its . + Task ParkAsync(DeferredConsent consent, CancellationToken ct = default); + + /// Look up a parked consent by id. Returns when absent. + Task GetAsync(string id, CancellationToken ct = default); + + /// Record the user's decision on a parked consent. No-op when absent. + Task ResolveAsync(string id, bool approved, CancellationToken ct = default); + + /// Remove a parked consent (after it has been resolved and consumed). + Task RemoveAsync(string id, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/IMissionApprover.cs b/src/AAuth/Server/Governance/IMissionApprover.cs new file mode 100644 index 0000000..54d60eb --- /dev/null +++ b/src/AAuth/Server/Governance/IMissionApprover.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// The PS's outcome for a mission proposal (§Mission Creation). +public enum MissionApprovalOutcome +{ + /// Approve the mission without prompting the user. + Approved, + + /// Decline the mission without prompting the user. + Declined, + + /// + /// Defer — the PS must prompt the user before approving. The mapper parks the + /// proposal via and answers 202. + /// + Prompt, +} + +/// +/// The inputs a PS evaluates when deciding a mission proposal: the proposing +/// agent, the approving PS, and the proposal itself (§Mission Creation). +/// +/// The agent identifier the mission would be approved for. +/// HTTPS URL of the approver (the PS). +/// The parsed mission proposal. +public sealed record MissionApprovalContext( + string Agent, + string Approver, + MissionProposal Proposal); + +/// +/// A typed mission-approval decision carrying the outcome and — when approved — +/// the subset of proposed tools the PS approved (§Mission Approval). A real PS +/// may prune the proposed tools; the approved set is what gets written into the +/// verbatim approval blob. +/// +/// Approve, decline, or prompt. +/// The approved tools (used only when is approved). +/// Optional Markdown message for the user (e.g. a decline reason). +public sealed record MissionApprovalDecision( + MissionApprovalOutcome Outcome, + IReadOnlyList ApprovedTools, + string? Message = null) +{ + /// Approve the mission with the given approved tool set. + public static MissionApprovalDecision Approve(IReadOnlyList approvedTools) + => new(MissionApprovalOutcome.Approved, approvedTools); + + /// Decline the mission, optionally with a user-facing message. + public static MissionApprovalDecision Decline(string? message = null) + => new(MissionApprovalOutcome.Declined, System.Array.Empty(), message); + + /// Defer to the user via the deferred-consent (202) flow. + public static MissionApprovalDecision Defer() + => new(MissionApprovalOutcome.Prompt, System.Array.Empty()); +} + +/// +/// PS-side policy seam for mission creation (§Mission Creation, §Mission +/// Approval). The SDK supplies the proposal context and the approval-blob builder +/// (); the PS decides whether to approve, and +/// which proposed tools to approve. The default +/// () approves every proposed tool. +/// +public interface IMissionApprover +{ + /// Decide a mission proposal. + Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default); +} diff --git a/src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs b/src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs new file mode 100644 index 0000000..c1c1448 --- /dev/null +++ b/src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace AAuth.Server.Governance; + +/// +/// In-memory for development and samples +/// (§Deferred Consent). Pending consents live only in process memory; a +/// production PS swaps in durable, expiring storage. +/// +public sealed class InMemoryDeferredConsentStore : IDeferredConsentStore +{ + private readonly ConcurrentDictionary _entries = + new(StringComparer.Ordinal); + + /// + public Task ParkAsync(DeferredConsent consent, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(consent); + if (string.IsNullOrEmpty(consent.Id)) + { + consent.Id = Guid.NewGuid().ToString("N"); + } + _entries[consent.Id] = consent; + return Task.FromResult(consent); + } + + /// + public Task GetAsync(string id, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + return Task.FromResult(_entries.TryGetValue(id, out var entry) ? entry : null); + } + + /// + public Task ResolveAsync(string id, bool approved, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + if (_entries.TryGetValue(id, out var entry)) + { + entry.Decision = approved; + } + return Task.CompletedTask; + } + + /// + public Task RemoveAsync(string id, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + _entries.TryRemove(id, out _); + return Task.CompletedTask; + } +} diff --git a/src/AAuth/Server/Governance/MissionApprovalBuilder.cs b/src/AAuth/Server/Governance/MissionApprovalBuilder.cs new file mode 100644 index 0000000..d71a74e --- /dev/null +++ b/src/AAuth/Server/Governance/MissionApprovalBuilder.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Nodes; +using AAuth.Agent; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// Builds the verbatim mission approval blob (§Mission Approval). The bytes are +/// returned exactly as they will be sent, so the s256 the PS advertises in +/// the AAuth-Mission header matches what the agent computes over the same +/// bytes. A PS that needs a different on-the-wire shape can build its own blob; +/// this is the canonical default used by MapAAuthGovernance. +/// +public static class MissionApprovalBuilder +{ + /// + /// Build the approval blob bytes and their s256 identity for an + /// approved mission. + /// + /// HTTPS URL of the approver (the PS). + /// The agent the mission is approved for. + /// The proposed mission (its description is copied verbatim). + /// The tools the PS approved (a subset of the proposed tools). + /// The approval timestamp. + /// The exact blob bytes and their base64url(SHA-256) identity. + public static (byte[] Blob, string S256) Build( + string approver, + string agent, + MissionProposal proposal, + IReadOnlyList approvedTools, + DateTimeOffset approvedAt) + { + ArgumentException.ThrowIfNullOrEmpty(approver); + ArgumentException.ThrowIfNullOrEmpty(agent); + ArgumentNullException.ThrowIfNull(proposal); + ArgumentNullException.ThrowIfNull(approvedTools); + + var tools = new JsonArray(); + foreach (var tool in approvedTools) + { + var obj = new JsonObject { ["name"] = tool.Name }; + if (!string.IsNullOrEmpty(tool.Description)) + { + obj["description"] = tool.Description; + } + tools.Add(obj); + } + + var blob = new JsonObject + { + ["approver"] = approver, + ["agent"] = agent, + ["approved_at"] = approvedAt.ToString("o"), + ["description"] = proposal.Description, + ["approved_tools"] = tools, + }; + + var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString()); + return (bytes, Mission.ComputeS256(bytes)); + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs index 4025f59..a8a6c9b 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs @@ -53,16 +53,13 @@ public void BuildGovernance_BindsPersonServer() Assert.Equal(Ps, client.PersonServer); } - [Fact(DisplayName = "§Mission Creation — ProposeMissionAsync requires a bound PS")] - public async Task ProposeMissionAsync_Unbound_Throws() + [Fact(DisplayName = "§Mission Creation — the client cannot be constructed without a Person Server")] + public void Ctor_MissingPersonServer_Throws() { - var unbound = new AAuthGovernanceClient( + Assert.Throws(() => new AAuthGovernanceClient( new HttpClient(new SessionHandler()) { BaseAddress = new Uri(Ps) }, - new MetadataClient(new HttpClient(new SessionHandler()))); - - var ex = await Assert.ThrowsAsync( - () => unbound.ProposeMissionAsync(new MissionProposal("# Plan"))); - Assert.Contains("bound Person Server", ex.Message); + new MetadataClient(new HttpClient(new SessionHandler())), + personServer: "")); } [Fact(DisplayName = "§Mission Approval — ProposeMissionAsync returns a session over the approved mission")] diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs index 1659d72..fd30c9b 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs @@ -65,9 +65,9 @@ public async Task MissionClient_Propose_ReturnsApprovedMission() { var handler = new GovernanceHandler(); var (signed, metadata) = Build(handler); - var client = new MissionClient(signed, metadata); + var client = new MissionClient(signed, metadata, Ps); - var mission = await client.ProposeAsync(Ps, new MissionProposal("# Plan a trip") + var mission = await client.ProposeAsync(new MissionProposal("# Plan a trip") { Tools = new[] { new MissionTool("WebSearch", "Search the web") }, }); @@ -83,10 +83,10 @@ public async Task MissionClient_S256Mismatch_Throws() { var handler = new GovernanceHandler { TamperMissionHeaderS256 = true }; var (signed, metadata) = Build(handler); - var client = new MissionClient(signed, metadata); + var client = new MissionClient(signed, metadata, Ps); await Assert.ThrowsAsync(() => - client.ProposeAsync(Ps, new MissionProposal("# Plan a trip"))); + client.ProposeAsync(new MissionProposal("# Plan a trip"))); } [Fact(DisplayName = "§Mission Creation — 202 clarification review resolves to an approved mission")] @@ -94,10 +94,10 @@ public async Task MissionClient_ClarificationReview_ResolvesToMission() { var handler = new GovernanceHandler { MissionNeedsClarification = true }; var (signed, metadata) = Build(handler); - var client = new MissionClient(signed, metadata); + var client = new MissionClient(signed, metadata, Ps); ClarificationRequirement? seen = null; - var mission = await client.ProposeAsync(Ps, new MissionProposal("# Plan a trip"), + var mission = await client.ProposeAsync(new MissionProposal("# Plan a trip"), new GovernanceOptions { OnClarificationRequired = (clarification, _) => @@ -119,9 +119,9 @@ public async Task PermissionClient_Granted() { var handler = new GovernanceHandler(); var (signed, metadata) = Build(handler); - var client = new PermissionClient(signed, metadata); + var client = new PermissionClient(signed, metadata, Ps); - var result = await client.RequestAsync(Ps, new PermissionRequest("SendEmail") + var result = await client.RequestAsync(new PermissionRequest("SendEmail") { Description = "Send the itinerary", Mission = TestMission, @@ -135,9 +135,9 @@ public async Task PermissionClient_Denied() { var handler = new GovernanceHandler { PermissionDenied = true }; var (signed, metadata) = Build(handler); - var client = new PermissionClient(signed, metadata); + var client = new PermissionClient(signed, metadata, Ps); - var result = await client.RequestAsync(Ps, new PermissionRequest("DeleteAll")); + var result = await client.RequestAsync(new PermissionRequest("DeleteAll")); Assert.Equal(PermissionGrant.Denied, result.Grant); Assert.Equal("Out of scope.", result.Reason); @@ -148,7 +148,7 @@ public async Task PermissionClient_ApprovedTool_ShortCircuits() { var handler = new GovernanceHandler(); var (signed, metadata) = Build(handler); - var client = new PermissionClient(signed, metadata); + var client = new PermissionClient(signed, metadata, Ps); var mission = new Mission { @@ -160,7 +160,7 @@ public async Task PermissionClient_ApprovedTool_ShortCircuits() ApprovedTools = new[] { new MissionTool("WebSearch") }, }; - var result = await client.RequestAsync(Ps, "WebSearch", mission); + var result = await client.RequestAsync("WebSearch", mission); Assert.True(result.IsGranted); Assert.False(handler.PermissionCalled); @@ -171,10 +171,10 @@ public async Task PermissionClient_MissionTerminated_Throws() { var handler = new GovernanceHandler { MissionTerminated = true }; var (signed, metadata) = Build(handler); - var client = new PermissionClient(signed, metadata); + var client = new PermissionClient(signed, metadata, Ps); var ex = await Assert.ThrowsAsync(() => - client.RequestAsync(Ps, new PermissionRequest("SendEmail") { Mission = TestMission })); + client.RequestAsync(new PermissionRequest("SendEmail") { Mission = TestMission })); Assert.Equal("terminated", ex.MissionStatus); } @@ -186,9 +186,9 @@ public async Task AuditClient_Records() { var handler = new GovernanceHandler(); var (signed, metadata) = Build(handler); - var client = new AuditClient(signed, metadata); + var client = new AuditClient(signed, metadata, Ps); - await client.RecordAsync(Ps, new AuditRecord(TestMission, "WebSearch") + await client.RecordAsync(new AuditRecord(TestMission, "WebSearch") { Description = "Searched for flights", }); @@ -201,10 +201,10 @@ public async Task AuditClient_MissionTerminated_Throws() { var handler = new GovernanceHandler { MissionTerminated = true }; var (signed, metadata) = Build(handler); - var client = new AuditClient(signed, metadata); + var client = new AuditClient(signed, metadata, Ps); await Assert.ThrowsAsync(() => - client.RecordAsync(Ps, new AuditRecord(TestMission, "WebSearch"))); + client.RecordAsync(new AuditRecord(TestMission, "WebSearch"))); } // ---- §Interaction Endpoint ---- @@ -214,9 +214,9 @@ public async Task InteractionClient_Question_ReturnsAnswer() { var handler = new GovernanceHandler(); var (signed, metadata) = Build(handler); - var client = new InteractionClient(signed, metadata); + var client = new InteractionClient(signed, metadata, Ps); - var answer = await client.AskQuestionAsync(Ps, "Refundable option?"); + var answer = await client.AskQuestionAsync("Refundable option?"); Assert.Equal("Yes, go ahead.", answer); } @@ -226,9 +226,9 @@ public async Task InteractionClient_Completion_Terminates() { var handler = new GovernanceHandler(); var (signed, metadata) = Build(handler); - var client = new InteractionClient(signed, metadata); + var client = new InteractionClient(signed, metadata, Ps); - var terminated = await client.ProposeCompletionAsync(Ps, "# Done", TestMission); + var terminated = await client.ProposeCompletionAsync("# Done", TestMission); Assert.True(terminated); Assert.Equal("completion", handler.LastInteractionType); diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs new file mode 100644 index 0000000..2bb31c7 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs @@ -0,0 +1,290 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth; +using AAuth.Agent; +using AAuth.Agent.Governance; +using AAuth.Server.Governance; +using AAuth.Server.Verification; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the governance mapper's mission-creation endpoint and +/// deferred-consent (202 poll) flow (AAuth protocol §Mission Creation, +/// §Mission Approval, §Deferred Consent). The mapper maps mission_endpoint +/// via and, when AddAAuthDeferredConsent is +/// called, resolves a Prompt outcome by parking the request and answering +/// 202 with a poll Location. +/// +public class GovernanceDeferredConsentMapperTests +{ + private const string Ps = "https://ps.example"; + private const string Agent = "aauth:assistant@agent.example"; + + // Build a host with the mapper, a stub that marks every request as carrying a + // verified agent token, and the supplied governance seam overrides. + private static async Task BuildHostAsync(Action? configure = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddAAuthGovernance(); + builder.Services.AddRouting(); + configure?.Invoke(builder.Services); + + var app = builder.Build(); + + // Stand in for the verification middleware: present a verified agent token. + app.Use(async (ctx, next) => + { + ctx.Features.Set(new AAuthVerificationResult + { + Level = AAuthLevel.Identified, + Scheme = "jwt", + TokenType = AAuthTokenType.AgentToken, + Agent = Agent, + }); + await next(); + }); + + app.MapAAuthGovernance(o => + { + o.Approver = Ps; + o.InteractionUrl = Ps + "/interaction"; + }); + + await app.StartAsync(); + return app; + } + + private static StringContent JsonContent(JsonObject body) + => new(body.ToJsonString(), Encoding.UTF8, "application/json"); + + private static async Task ReadJson(HttpResponseMessage response) + => JsonNode.Parse(await response.Content.ReadAsStringAsync()) as JsonObject; + + [Fact(DisplayName = "§Mission Creation — the default approver approves and returns a verifiable blob")] + public async Task Mission_DefaultApprover_ReturnsApprovedBlob() + { + using var host = await BuildHostAsync(); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["description"] = "# Plan a trip", + ["tools"] = new JsonArray { new JsonObject { ["name"] = "WebSearch" } }, + }; + var response = await client.PostAsync("https://localhost/mission", JsonContent(body)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.Contains("AAuth-Mission")); + + var bytes = await response.Content.ReadAsByteArrayAsync(); + var mission = Mission.FromApprovalBytes(bytes); + Assert.Equal(Ps, mission.Approver); + Assert.Equal(Agent, mission.Agent); + Assert.Contains(mission.ApprovedTools, t => t.Name == "WebSearch"); + + // The mission is persisted and verifiable by its s256. + var store = host.Services.GetRequiredService(); + var stored = await store.GetAsync(mission.S256); + Assert.NotNull(stored); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Mission Creation — an agentless request is rejected (401 invalid_carrier_token)")] + public async Task Mission_NoAgentToken_Unauthorized() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddAAuthGovernance(); + builder.Services.AddRouting(); + var app = builder.Build(); + app.MapAAuthGovernance(); // no agent-token stub middleware + await app.StartAsync(); + + using var client = app.GetTestServer().CreateClient(); + var response = await client.PostAsync("https://localhost/mission", + JsonContent(new JsonObject { ["description"] = "# Plan a trip" })); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + await app.StopAsync(); + ((IDisposable)app).Dispose(); + } + + [Fact(DisplayName = "§Mission Creation — a declining approver yields 403 access_denied")] + public async Task Mission_DecliningApprover_Forbidden() + { + using var host = await BuildHostAsync(s => + s.AddSingleton(new StubApprover(MissionApprovalDecision.Decline("not now")))); + using var client = host.GetTestServer().CreateClient(); + + var response = await client.PostAsync("https://localhost/mission", + JsonContent(new JsonObject { ["description"] = "# Plan a trip" })); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var json = await ReadJson(response); + Assert.Equal("access_denied", (string?)json?["error"]); + await host.StopAsync(); + } + + [Fact(DisplayName = "§Deferred Consent — a prompting approver parks the mission and answers 202 with a poll Location")] + public async Task Mission_Prompt_Parks202_ThenApprovalCompletes() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubApprover(MissionApprovalDecision.Defer())); + }); + using var client = host.GetTestServer().CreateClient(); + + var response = await client.PostAsync("https://localhost/mission", + JsonContent(new JsonObject { ["description"] = "# Plan a trip" })); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var location = response.Headers.Location!.ToString(); + Assert.Contains("/governance-pending/", location); + Assert.True(response.Headers.Contains("AAuth-Requirement")); + + // The user has not decided yet — the poll holds at 202. + using var pendingPoll = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.Accepted, pendingPoll.StatusCode); + + // The user approves at the PS consent page (resolve the parked entry). + var id = location[(location.LastIndexOf('/') + 1)..]; + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: true); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.OK, done.StatusCode); + Assert.True(done.Headers.Contains("AAuth-Mission")); + var mission = Mission.FromApprovalBytes(await done.Content.ReadAsByteArrayAsync()); + Assert.Equal(Agent, mission.Agent); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Deferred Consent — a declined mission poll resolves to 403 access_denied")] + public async Task Mission_Prompt_Declined_Forbidden() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubApprover(MissionApprovalDecision.Defer())); + }); + using var client = host.GetTestServer().CreateClient(); + + var response = await client.PostAsync("https://localhost/mission", + JsonContent(new JsonObject { ["description"] = "# Plan a trip" })); + var location = response.Headers.Location!.ToString(); + var id = location[(location.LastIndexOf('/') + 1)..]; + + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: false); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.Forbidden, done.StatusCode); + var json = await ReadJson(done); + Assert.Equal("access_denied", (string?)json?["error"]); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Deferred Consent — a prompting permission parks and resolves to a granted decision")] + public async Task Permission_Prompt_Parks202_ThenGrant() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubDecider( + new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope))); + }); + using var client = host.GetTestServer().CreateClient(); + + var response = await client.PostAsync("https://localhost/permission", + JsonContent(new JsonObject { ["action"] = "SendEmail" })); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var location = response.Headers.Location!.ToString(); + var id = location[(location.LastIndexOf('/') + 1)..]; + + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: true); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.OK, done.StatusCode); + var json = await ReadJson(done); + Assert.Equal("granted", (string?)json?["permission"]); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Deferred Consent — a declined permission poll resolves to a denied decision (200, not access_denied)")] + public async Task Permission_Prompt_Declined_ReturnsDenied() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubDecider( + new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope))); + }); + using var client = host.GetTestServer().CreateClient(); + + var response = await client.PostAsync("https://localhost/permission", + JsonContent(new JsonObject { ["action"] = "SendEmail" })); + var location = response.Headers.Location!.ToString(); + var id = location[(location.LastIndexOf('/') + 1)..]; + + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: false); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.OK, done.StatusCode); + var json = await ReadJson(done); + Assert.Equal("denied", (string?)json?["permission"]); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Deferred Consent — without the store a prompting permission falls back to a denial")] + public async Task Permission_Prompt_NoStore_Denied() + { + using var host = await BuildHostAsync(s => + s.AddSingleton(new StubDecider( + new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope)))); + using var client = host.GetTestServer().CreateClient(); + + var response = await client.PostAsync("https://localhost/permission", + JsonContent(new JsonObject { ["action"] = "SendEmail" })); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJson(response); + Assert.Equal("denied", (string?)json?["permission"]); + + await host.StopAsync(); + } + + private sealed class StubApprover(MissionApprovalDecision decision) : IMissionApprover + { + public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default) + => Task.FromResult(decision); + } + + private sealed class StubDecider(PermissionDecision decision) : IPermissionDecider + { + public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default) + => Task.FromResult(decision); + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs index 9ba3d01..d4209f9 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs @@ -34,7 +34,8 @@ public class GovernanceFacadeTests private static AAuthGovernanceClient BuildFacade(HttpMessageHandler handler) => new( new HttpClient(handler) { BaseAddress = new Uri(Ps) }, - new MetadataClient(new HttpClient(handler))); + new MetadataClient(new HttpClient(handler)), + Ps); [Fact(DisplayName = "§PS Governance Endpoints — facade exposes all four governance clients")] public void Facade_Ctor_ExposesFourClients() @@ -51,7 +52,7 @@ public void Facade_Ctor_ExposesFourClients() public void Facade_Ctor_NullSignedClient_Throws() { Assert.Throws(() => - new AAuthGovernanceClient(null!, new MetadataClient(new HttpClient()))); + new AAuthGovernanceClient(null!, new MetadataClient(new HttpClient()), Ps)); } [Fact(DisplayName = "§PS Governance Endpoints — facade clients share one signed channel and work end-to-end")] @@ -60,20 +61,20 @@ public async Task Facade_SubClients_AreFunctional() var handler = new FacadeHandler(); var facade = BuildFacade(handler); - var mission = await facade.Mission.ProposeAsync(Ps, new MissionProposal("# Plan a trip") + var mission = await facade.Mission.ProposeAsync(new MissionProposal("# Plan a trip") { Tools = new[] { new MissionTool("WebSearch", "Search the web") }, }); Assert.Equal("aauth:assistant@agent.example", mission.Agent); var permission = await facade.Permission.RequestAsync( - Ps, new PermissionRequest("SendEmail") { Mission = TestMission }); + new PermissionRequest("SendEmail") { Mission = TestMission }); Assert.True(permission.IsGranted); - await facade.Audit.RecordAsync(Ps, new AuditRecord(TestMission, "WebSearch")); + await facade.Audit.RecordAsync(new AuditRecord(TestMission, "WebSearch")); Assert.True(handler.AuditCalled); - var answer = await facade.Interaction.AskQuestionAsync(Ps, "Refundable option?"); + var answer = await facade.Interaction.AskQuestionAsync("Refundable option?"); Assert.Equal("Yes, go ahead.", answer); } @@ -82,6 +83,7 @@ public void BuildGovernance_WithSigningMode_ReturnsWiredFacade() { var facade = new AAuthClientBuilder(AAuthKey.Generate()) .UseHwk() + .WithPersonServer(Ps) .WithInnerHandler(new FacadeHandler()) .BuildGovernance(); diff --git a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs index 67b163f..6a10894 100644 --- a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs +++ b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs @@ -75,7 +75,7 @@ public async Task Row02_MissionDenied_Aborts() var proposal = new MissionProposal("row02 rejected mission"); await Assert.ThrowsAsync( - () => MissionClientFor(agent).ProposeAsync(PsIssuer, proposal)); + () => MissionClientFor(agent).ProposeAsync(proposal)); } // ---- Token gate (rows 3-8) ----------------------------------------- @@ -212,7 +212,7 @@ public async Task Row09_PermissionApprovedTool_SilentGrant() var mission = await ProposeMissionAsync(agent, "row09 approved-tool mission", "send_email"); var result = await PermissionClientFor(agent) - .RequestAsync(PsIssuer, "send_email", mission); + .RequestAsync("send_email", mission); Assert.True(result.IsGranted); Assert.Equal(PermissionGrant.Granted, result.Grant); @@ -229,7 +229,7 @@ public async Task Row10_PermissionNonPreApproved_PromptThenGrant() { Mission = new MissionClaim(mission.Approver, mission.S256), }; - var result = await PermissionClientFor(agent).RequestAsync(PsIssuer, request); + var result = await PermissionClientFor(agent).RequestAsync(request); Assert.True(result.IsGranted); await AssertPermissionReasonAsync(mission, "delete_file", granted: true); @@ -246,7 +246,7 @@ public async Task Row11_PermissionNonPreApproved_PromptThenDeny() { Mission = new MissionClaim(mission.Approver, mission.S256), }; - var result = await PermissionClientFor(agent).RequestAsync(PsIssuer, request); + var result = await PermissionClientFor(agent).RequestAsync(request); Assert.False(result.IsGranted); Assert.Equal(PermissionGrant.Denied, result.Grant); @@ -304,9 +304,9 @@ private Agent NewAgent(string? agentId = null) return new Agent(agentId, agentKey, signed, plain, metadata); } - private MissionClient MissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata); + private MissionClient MissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata, PsIssuer); - private PermissionClient PermissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata); + private PermissionClient PermissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata, PsIssuer); private async Task ScriptAsync(Agent agent, JsonObject body) { @@ -321,7 +321,7 @@ private async Task ProposeMissionAsync(Agent agent, string description, { Tools = tools.Select(t => new MissionTool(t)).ToArray(), }; - return await MissionClientFor(agent).ProposeAsync(PsIssuer, proposal); + return await MissionClientFor(agent).ProposeAsync(proposal); } private async Task ExchangeAsync(Agent agent, Mission mission, string scope, TokenExchangeRequest options) From d8466d9178f2c948edd03128a826e2c0074848e2 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 04:25:01 +0000 Subject: [PATCH 16/24] feat(missions): refactor mission governance API and add call-chain demo Refactor the mission governance surface across the SDK, samples, and docs, and add a combined mission call-chain sample that threads one human-approved mission through a clarification round and a delegated call chain. SDK: - Honor InteractionRelayResult.Pending for interaction/payment in MapAAuthGovernance: park on the deferred-consent store and return 202 + poll Location, degrading to synchronous 200 when no store is registered (spec Interaction Response). - Add DeferredConsentKind.Interaction and DeferredConsent.Interaction. - Add MissionHeaderHandler and clarification challenge seam wiring. Samples: - Add SampleApp MissionCallChain page (clarification + mission-forwarded chain + mission log) with side-nav entry and e2e spec. - Echo the nested act claim from WhoAmI /jwt/mission and /jwt/mission/elevated for parity with /jwt (spec Upstream Token Verification step 4). Docs: - Update dependency-injection, missions, mission-governance, clarification, error-handling, and mission-governed-access references. Tests: - Add conformance tests for the interaction deferral path, mission-header seam, and clarification seam; update governance test suites. --- .../implementation-plan.md | 150 +++-- .../issues-and-deviations.md | 7 + .../research.md | 109 ++++ .../implementation-plan.md | 214 +++++++ .../research.md | 261 ++++++++ docs/advanced/clarification-chat.md | 8 +- docs/advanced/error-handling.md | 2 +- docs/advanced/mission-governance-clients.md | 123 ++-- docs/advanced/missions.md | 35 ++ docs/reference/dependency-injection.md | 27 +- docs/server/mission-governance.md | 66 +- docs/workflows/mission-governed-access.md | 44 +- samples/GuidedTour/CodeSnippets.cs | 8 +- samples/GuidedTour/TourSession.cs | 3 +- samples/MissionAgent/Program.cs | 66 +- samples/MockPersonServer/Program.cs | 23 + samples/Orchestrator/PendingStore.cs | 20 +- samples/Orchestrator/Program.cs | 103 ++- .../SampleApp/Components/Layout/NavMenu.razor | 6 + .../Components/Layout/NavMenu.razor.css | 4 + samples/SampleApp/Components/Pages/Home.razor | 20 + .../SampleApp/Components/Pages/Mission.razor | 71 ++- .../Components/Pages/MissionCallChain.razor | 586 ++++++++++++++++++ .../mission-call-chain.spec.ts | 119 ++++ samples/WhoAmI/Program.cs | 7 + src/AAuth/AAuthClientBuilder.cs | 49 +- src/AAuth/Agent/ChallengeHandler.cs | 17 + src/AAuth/Agent/DeferredExchange.cs | 45 +- src/AAuth/Agent/Governance/AuditClient.cs | 4 +- src/AAuth/Agent/MissionAction.cs | 13 +- src/AAuth/Agent/MissionHeaderHandler.cs | 51 ++ src/AAuth/Agent/MissionTool.cs | 6 +- src/AAuth/Agent/TokenExchangeRequest.cs | 37 +- ...hGovernanceApplicationBuilderExtensions.cs | 33 +- src/AAuth/HttpSig/ChallengeHandlingOptions.cs | 17 + .../Server/Governance/GovernanceEndpoints.cs | 4 +- .../Governance/IDeferredConsentStore.cs | 10 + .../ChallengeClarificationSeamTests.cs | 256 ++++++++ .../Missions/GovernanceClientBuilderTests.cs | 6 +- .../Missions/GovernanceClientTests.cs | 28 +- .../GovernanceDeferredConsentMapperTests.cs | 118 ++++ .../Missions/GovernanceFacadeTests.cs | 4 +- .../Missions/GovernanceServerTests.cs | 6 +- .../Missions/MissionHeaderSeamTests.cs | 122 ++++ .../Missions/TokenRequestParamsTests.cs | 35 ++ .../AAuthGovernanceDITests.cs | 3 +- .../Integration/MissionAgentFlowTests.cs | 6 +- 47 files changed, 2655 insertions(+), 297 deletions(-) create mode 100644 .agent/plans/2026-06-06-r3-rich-resource-requests/implementation-plan.md create mode 100644 .agent/plans/2026-06-06-r3-rich-resource-requests/research.md create mode 100644 samples/SampleApp/Components/Pages/MissionCallChain.razor create mode 100644 samples/SampleApp/playwright-tests/mission-call-chain.spec.ts create mode 100644 src/AAuth/Agent/MissionHeaderHandler.cs create mode 100644 tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs create mode 100644 tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md index f7bdd8c..db3fe10 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -58,8 +58,9 @@ Directives captured from the user for this initiative: - **Branch:** `feat/missions-ps-governance` (continue). - **Sequencing:** Phase 1 API first pass (agent + resource) → Phase 2 API consistency pass → Phase 3 spec hardening → Phase 4 sample migration → Phase 5 new - combined sample + e2e → Phase 6 docs → Phase 7 samples audit (subagent) → Phase 8 - docs audit (subagent) → Phase 9 independent spec-compliance review (subagent). + combined sample + e2e → Phase 6 mission convenience seam (`WithMission`) → Phase 7 + docs → Phase 8 samples audit (subagent) → Phase 9 docs audit (subagent) → Phase 10 + independent spec-compliance review (subagent). ## Cross-Cutting Decisions @@ -268,9 +269,9 @@ capability — naming, shape, and ergonomics only. Confirms the construction tri ### Definition of Done -- [ ] Convention diff recorded in research (Part B / Open Design Choices). -- [ ] Factory, builder, and DI paths share names/options/defaults across both sides. -- [ ] Public names align with `AAuthClientBuilder` / `AddAAuth*` / `MapAAuth*`. +- [x] Convention diff recorded in research (Part B / Open Design Choices). +- [x] Factory, builder, and DI paths share names/options/defaults across both sides. +- [x] Public names align with `AAuthClientBuilder` / `AddAAuth*` / `MapAAuth*`. - [x] Per-call `personServer` params removed; bound client is the only path (D1). - [x] Action passed as a `MissionAction` POCO (implicit `string` for terse call sites) (D4). - [x] Mission creation mapped by `MapAAuthGovernance` via `IMissionApprover` (D3, DEV-2). @@ -298,9 +299,9 @@ capability — naming, shape, and ergonomics only. Confirms the construction tri ### Definition of Done -- [ ] `AuditClient` rejects non-201 acknowledgments. -- [ ] `device` rejects control chars and lengths > 64 with a clear exception. -- [ ] Tests cover boundary cases; full suite green. +- [x] `AuditClient` rejects non-201 acknowledgments. +- [x] `device` rejects control chars and lengths > 64 with a clear exception. +- [x] Tests cover boundary cases; full suite green. --- @@ -315,18 +316,18 @@ surface. No behavior change; API shape only. | File | Action | |------|--------| -| `samples/MissionAgent/Program.cs` | **Modify** — `MissionSession`, bound PS, no manual `MissionClaim` | -| `samples/MockPersonServer/Program.cs` | **Modify** — `AddAAuthGovernance(...)` + `MapAAuthGovernance()`; remove hand-wired endpoints | -| `samples/MockPersonServer/MissionGovernance.cs` | **Modify** — seams unchanged or trimmed to new defaults | -| `samples/SampleApp/Components/Pages/Mission.razor` | **Modify** — new client surface | -| `samples/GuidedTour/TourSession.cs` | **Modify** — new client surface; step plan preserved | -| `samples/WhoAmI/Program.cs` | **Modify** — resource governance builder for mission-aware challenge | +| `samples/MissionAgent/Program.cs` | **Done** — bound PS via `WithMission`; `MissionSession`; no manual `MissionClaim` (folded into Phase 6) | +| `samples/MockPersonServer/Program.cs` | **Kept hand-wired** — see DEV-4; agent-facing parsing already on SDK (`GovernanceEndpoints` + `MissionApprovalBuilder`) | +| `samples/MockPersonServer/MissionGovernance.cs` | **No change** — see DEV-4 | +| `samples/SampleApp/Components/Pages/Mission.razor` | **Done** — `MissionSession` (`ProposeMissionAsync` → `session.RequestPermissionAsync`/`RecordAuditAsync`); gates preserved | +| `samples/GuidedTour/TourSession.cs` + `CodeSnippets.cs` | **Done** — teaching snippets on the session API; raw-wire steps preserved (pedagogy) | +| `samples/WhoAmI/Program.cs` | **No change** — `ChallengeOptions { MissionAware = true }` is the canonical resource seam (DEV-5) | ### Definition of Done -- [ ] All mission samples build and run against the new API. -- [ ] Mission e2e specs (4) pass unchanged in behavior. -- [ ] No leftover manual `MissionClaim`/PS-URL threading in samples. +- [x] All mission samples build and run against the new API. _(SampleApp builds 0/0; agent-side call-sites on the session API.)_ +- [x] Mission e2e specs (4) pass unchanged in behavior. _(SampleApp + GuidedTour mission specs: 4/4 green after migration.)_ +- [x] No leftover manual `MissionClaim`/PS-URL threading in samples. _(Agent-side clean; server-side parses incoming claims, which is correct. DEV-4/DEV-5 record the two server-side line-items intentionally not rewritten.)_ --- @@ -357,14 +358,86 @@ rounds; untrusted text → sanitize); §Call Chaining (mission present → forwa ### Definition of Done -- [ ] Page shows a clarification round (respond/update/cancel) during mission approval. -- [ ] Mission is forwarded through the orchestrator; downstream hop is governed. -- [ ] Mission log/trail surfaced in the UI. -- [ ] New Playwright spec passes; full backend stack boots via webServer array. +- [x] Page shows a clarification round (respond/update/cancel) during mission approval. _(`MissionCallChain.razor` step 2: `OnClarificationRequired` surfaces the sanitized question, agent answers, then the user approves the out-of-mission elevated scope.)_ +- [x] Mission is forwarded through the orchestrator; downstream hop is governed. _(step 3: `WithMission(...)` carries the mission to the Orchestrator `/mission` endpoint, which forwards `AAuth-Mission` to the WhoAmI `/jwt/mission` hop — chain result asserts `downstream.mode == "three-party"`, `agent == aauth:orchestrator@localhost:5200`, `mission` truthy.)_ +- [x] Mission log/trail surfaced in the UI. _(PS-held `/admin/mission-log` rendered in the `[data-test="mission-log"]` table, including the clarification entry.)_ +- [x] New Playwright spec passes; full backend stack boots via webServer array. _(`samples/SampleApp/playwright-tests/mission-call-chain.spec.ts` green; full `sample-app` suite 15 passed + 1 pre-existing skip, two consecutive clean CI runs.)_ --- -## Phase 6 — Docs update +## Phase 6 — Mission convenience seam (`WithMission`) + +**Goal:** Close PT-A7 (research Part A.2). Add an +`AAuthClientBuilder.WithMission(Mission)` seam that auto-emits the `AAuth-Mission` +header from an agent's own approved mission, so a mission-holding agent composes +`WithMission(...) + WithChallengeHandling() + WithInteractionHandling()` and the +entire resource-access leg (header + 401→exchange→retry) collapses to one signed +`SendAsync`. Retrofit the non-pedagogical sample (`MissionAgent`) to the seam; +leave the step-by-step teaching surfaces (`SampleApp/Mission.razor`, GuidedTour, +and the Phase 5 combined page) deliberately explicit so each gate stays visible. + +**Spec:** §Mission Context at Resources — "The agent includes the `AAuth-Mission` +header when sending requests to resources, unless the mission is already conveyed in +an auth token"; §HTTP Message Signatures — "When the agent is operating in a mission +context, it includes the `AAuth-Mission` header and adds `aauth-mission` to the +signed components." The SDK already auto-covers `aauth-mission` whenever the header +is present ([AAuthSigningHandler](../../../src/AAuth/HttpSig/AAuthSigningHandler.cs)), +so the seam only needs to set the header. + +### Approach + +- **`MissionHeaderHandler` (new).** A small `DelegatingHandler` that sets + `AAuth-Mission` from a directly-held `Mission` (`{approver, s256}`) on each + outbound request, mirroring `MissionForwardingHandler` but sourcing the mission + directly instead of extracting it from an upstream token. The signing handler + beneath it covers the `aauth-mission` component automatically. +- **`AAuthClientBuilder.WithMission(Mission)`.** Stores the mission and inserts the + handler at the top of the pipeline (above interaction/refresh/challenge), so the + header is present before the request is signed. Composes with + `WithChallengeHandling()` / `WithInteractionHandling()`. Idempotent with the + existing header — never emit `AAuth-Mission` twice (skip if the caller already set + it, matching the call-chaining carve-out). +- **Carve-out honored.** `WithMission(...)` is for the **originating** agent that + holds its own approved mission; call-chaining intermediaries keep using + `MissionForwardingHandler` (mission extracted from the upstream token). The two are + mutually exclusive on a given client. +- **Retrofit `MissionAgent`.** Replace the manual header + challenge cycle in + `AccessMissionResourceAsync` with a `WithMission(...)`-composed client; preserve + the per-request agent-token refresh (replay `jti`) behavior. + +### Files + +| File | Action | +|------|--------| +| `src/AAuth/Agent/MissionHeaderHandler.cs` | **New** — emits `AAuth-Mission` from a held `Mission` | +| `src/AAuth/AAuthClientBuilder.cs` | **Modify** — add `WithMission(Mission)`; insert handler in `BuildHandler()` | +| `samples/MissionAgent/Program.cs` | **Modify** — collapse `AccessMissionResourceAsync` onto the seam | +| `tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs` | **New** — header emitted + signed; not duplicated; carve-out | + +### Implementation Decisions + +- **D5 — originating-agent seam (2026-06-06).** `WithMission(...)` sources the + mission directly; `MissionForwardingHandler` stays the call-chaining path. The + "unless conveyed in an auth token" carve-out remains the agent's decision — + `WithMission(...)` is only wired when the agent holds an approved `Mission` and is + the originator. Spec-backed by research Part A.2 PT-A7 update. +- Teaching surfaces stay explicit by design (DC4 pedagogy): only `MissionAgent` is + collapsed this phase; `Mission.razor`, the combined page, and GuidedTour keep the + visible gate-by-gate flow. + +### Definition of Done + +- [x] `WithMission(Mission)` emits a spec-correct `AAuth-Mission` header that the + signing handler covers as `aauth-mission`. +- [x] Header is not emitted twice when already present (call-chaining carve-out). +- [x] `MissionAgent.AccessMissionResourceAsync` collapsed onto the seam; behavior + unchanged (per-request refresh + replay `jti` preserved). +- [x] New conformance test covers emit + signature coverage + de-dup; full suite + green (build 0/0). + +--- + +## Phase 7 — Docs update **Goal:** Bring mission docs to the new API. @@ -383,11 +456,12 @@ rounds; untrusted text → sanitize); §Call Chaining (mission present → forwa ### Definition of Done -- [ ] All mission docs reflect the new API; code blocks compile against the surface. +- [x] All mission docs reflect the new API; code blocks compile against the surface. _(Rewrote the stale faceted/per-call-PS examples in `mission-governance-clients.md`, `mission-governed-access.md`, `clarification-chat.md`, and `error-handling.md` to the bound `AAuthGovernanceClient` + `MissionSession` surface; added `MapAAuthGovernance()` + the no-op default seams and `AddAAuthDeferredConsent()` to `server/mission-governance.md`; `challenge-middleware.md`'s `ChallengeOptions { MissionAware = true }` is already the canonical resource seam per DEV-5.)_ +- [x] `WithMission(...)` convenience seam documented alongside `WithChallengeHandling`. _(New "Carrying your own mission with `WithMission`" section in `missions.md`, and the resource-access step of `mission-governed-access.md` now composes `WithMission(...)` + `WithChallengeHandling()`; the combined sample is linked from both `missions.md` and `clarification-chat.md`.)_ --- -## Phase 7 — Samples consistency audit (subagent) +## Phase 8 — Samples consistency audit (subagent) **Goal:** With fresh eyes, validate that **every** sample uses the new API surface and reads cleanly. A dedicated subagent surfaces inconsistencies, leftover old-API @@ -408,14 +482,14 @@ and remediated. ### Definition of Done -- [ ] Subagent report captured; each finding marked fixed / deferred / not-an-issue. -- [ ] No sample retains old-API mission usage or manual claim/PS threading. -- [ ] Significant issues logged in `issues-and-deviations.md`; research updated. -- [ ] All samples build/run; mission + combined e2e specs green. +- [x] Subagent report captured; each finding marked fixed / deferred / not-an-issue. _(Audit found zero stale faceted calls and zero manual `MissionClaim` constructions across `samples/**`; every remaining manual `AAuthMissionHeader.FormatStructured` is either a deliberately-explicit teaching surface — `Mission.razor`, `MissionCallChain.razor` snippet, GuidedTour `TourSession.cs`/`CodeSnippets.cs` — or the MockPersonServer acting as the legitimate header producer. All marked not-an-issue.)_ +- [x] No sample retains old-API mission usage or manual claim/PS threading. _(Confirmed: all governance call sites use the PS-bound `AAuthGovernanceClient` ctor → `ProposeMissionAsync` → flat `MissionSession` methods; call-chaining intermediaries `AgentConsole`/`Orchestrator` correctly use `WithCallChaining`, never `WithMission`.)_ +- [x] Significant issues logged in `issues-and-deviations.md`; research updated. _(No new issues — audit was clean; nothing to log beyond DEV-6/7/8 already recorded.)_ +- [x] All samples build/run; mission + combined e2e specs green. _(MissionAgent Phase-6 seam collapse verified clean; combined sample-app suite 15 passed + 1 pre-existing skip, two consecutive CI runs.)_ --- -## Phase 8 — Docs & code-snippet consistency audit (subagent) +## Phase 9 — Docs & code-snippet consistency audit (subagent) **Goal:** Validate that all docs and embedded code snippets — especially GuidedTour snippets and SampleApp walkthroughs — use the new API and read cleanly. A dedicated @@ -434,14 +508,14 @@ subagent surfaces inconsistencies; findings are adjudicated and remediated. ### Definition of Done -- [ ] Subagent report captured; each finding marked fixed / deferred / not-an-issue. -- [ ] All mission docs + GuidedTour/SampleApp snippets reflect the new API. -- [ ] Code blocks compile against the surface; cross-links resolve. -- [ ] Significant issues logged in `issues-and-deviations.md`; research updated. +- [x] Subagent report captured; each finding marked fixed / deferred / not-an-issue. _(One file flagged — `docs/reference/dependency-injection.md`: stale "no dedicated DI extension" prose, a `BuildGovernance()` snippet missing `.WithPersonServer(...)` (would throw at runtime), and prose omitting the PS requirement. All three FIXED. All other mission/governance/clarification/call-chain docs verified clean against the GuidedTour `CodeSnippets.cs` ground truth.)_ +- [x] All mission docs + GuidedTour/SampleApp snippets reflect the new API. _(GuidedTour `CodeSnippets.cs` already compiles against the surface and was used as ground truth; docs now match it.)_ +- [x] Code blocks compile against the surface; cross-links resolve. _(`dependency-injection.md` now documents `AddAAuthGovernanceClient(...)` + PS-bound `BuildGovernance()`; cross-links/anchors `token-issuance.md#mission-claims`, `error-handling.md#mission-termination`, `challenge-middleware.md#mission-aware-resources` and the `MissionCallChain.razor` sample path all resolve.)_ +- [x] Significant issues logged in `issues-and-deviations.md`; research updated. _(Doc-only fix, no SDK gap — nothing new to log beyond DEV-6/7/8.)_ --- -## Phase 9 — Independent spec-compliance review (subagent) +## Phase 10 — Independent spec-compliance review (subagent) **Goal:** A separate reviewer subagent independently validates **each change** in this initiative against the AAuth spec to confirm 100% compliance. Where the SDK is @@ -464,10 +538,10 @@ found non-compliant, fix it (DC6 still holds — fixes must not break existing f ### Definition of Done -- [ ] Reviewer subagent report captured; every finding adjudicated against spec text. -- [ ] SDK non-compliance fixed without breaking existing flows (DC6). -- [ ] `dotnet build AAuth.slnx` 0/0; unit + conformance + mission/combined e2e green. -- [ ] `issues-and-deviations.md` finalized; research updated; plan DoD ticked. +- [x] Reviewer subagent report captured; every finding adjudicated against spec text. _(Reviewer walked all six areas — `AAuth-Mission` header + signed-component coverage, mission claim shape + verbatim-bytes hash, the four endpoint request/response shapes, the deferred-consent 202 flow incl. the DEV-6 bug fixes, clarification round-trip + limits, `mission_terminated` surfacing, and originator-vs-intermediary header rules — each cited to a spec section. One genuine non-compliance (NC-1) found; everything else COMPLIANT; DEV-5 reconfirmed intentional.)_ +- [x] SDK non-compliance fixed without breaking existing flows (DC6). _(NC-1 → DEV-9: `interaction`/`payment` now honor `InteractionRelayResult.Pending` by parking on the deferred-consent store and answering `202` + poll `Location`, degrading to a synchronous `200` when no store is registered. Agent side already polled correctly; no client change. DEV-10 (completion synchronous review) adjudicated as an intentional, spec-tolerable simplification.)_ +- [x] `dotnet build AAuth.slnx` 0/0; unit + conformance + mission/combined e2e green. _(SDK build 0/0; `GovernanceDeferredConsentMapperTests` 12/12 incl. 4 new NC-1 cases — full suites run below.)_ +- [x] `issues-and-deviations.md` finalized; research updated; plan DoD ticked. _(DEV-9 + DEV-10 logged.)_ - [ ] Major open decisions surfaced to the user for input. --- diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md index d76a164..8e9be5c 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md @@ -44,6 +44,13 @@ These judgment calls were made during Phase 1. **All confirmed by the user on | DEV-1 | 1 | Resource mapper | `MapAAuthGovernance` resolved `PermissionOutcome.Prompt` as a denial — no built-in deferred (202) user-consent channel. | §Permission Endpoint (deferred consent) | resolved (Phase 2, D3 — `IDeferredConsentStore` seam + `AddAAuthDeferredConsent()`; mapper parks `Prompt` and answers 202 + poll route. Store is opt-in so the existing `Prompt`→denied default is preserved. Interactive browser page stays a sample concern.) | | DEV-2 | 1 | Resource mapper | Mission-creation endpoint not mapped by `MapAAuthGovernance`; approval-blob building + proposal approval stayed PS-side (`MissionApproval` was sample-local). | §Mission Creation, §Mission Approval | resolved (Phase 2, D3 — `MissionApprovalBuilder` + `IMissionApprover`/`DefaultMissionApprover` promoted into the SDK; `MapAAuthGovernance` maps the mission endpoint, persists the `StoredMission`, and emits the `AAuth-Mission` header. MockPersonServer now uses `MissionApprovalBuilder`.) | | DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. | §Interaction Endpoint | intentional | +| DEV-4 | 4 | MockPersonServer | Phase 4 listed "swap MockPersonServer's hand-wired `/mission` `/permission` `/audit` `/mission-interaction` + pending routes for `MapAAuthGovernance()`." Kept the hand-wired endpoints instead. They are bound to the deterministic `MissionConsentScript` (`/admin/*` scripting the e2e suite drives), the `MissionPolicyStore` that decides silent-vs-prompt **token exchange** at `/token`, and the interactive browser `/interaction` consent page. `MapAAuthGovernance()` targets the *non-interactive default* PS; reproducing this rich behavior through `IMissionApprover` + a custom `IDeferredConsentStore` is a large rewrite of a spec-conformant, e2e-green file for **zero spec/behavior gain**, against DC6 (no regressions). Consistent with **DEV-1**'s decision that the interactive browser page "stays a sample concern." The agent-facing call-sites (MissionAgent, Mission.razor, GuidedTour) ARE migrated to the new session API; server-side request parsing still uses `GovernanceEndpoints` + `MissionApprovalBuilder`. | §Mission Creation, §Permission Endpoint, §Audit Endpoint (deferred consent) | intentional | +| DEV-5 | 4 | WhoAmI | Phase 4 listed "WhoAmI — resource governance builder for mission-aware challenge." No such builder exists or was introduced (Phase 3 built the **agent** session surface, not a resource-side one). `ChallengeOptions { MissionAware = true }` IS the canonical, spec-correct resource-side seam (§Terminology: a *mission-aware resource* includes the mission object from the `AAuth-Mission` header in the resource tokens it issues), already used by `WhoAmI`. No agent-style manual `MissionClaim` threading exists on the resource side. No change required. | §Mission Context at Resources, §Terminology (mission-aware resource) | intentional | +| DEV-6 | 5 | SDK deferred exchange | Building the Phase 5 combined page surfaced two real SDK bugs in the clarification→interaction escalation path (`DeferredExchange.cs`). **Bug 1:** after a clarification round, the poller did not stop on a subsequent interaction `202` (the `stopOnInteraction` flag was not threaded through `PollAsync`/`ComposePollerOptions`), so the SDK kept polling instead of surfacing the user-approval gate. **Bug 2:** when a polled interaction `202` omitted the `Location` header, `ResolveLocation` returned null instead of falling back to the last pending URL, dropping the interaction URL the UI needs. Both fixed in the SDK and covered by `ChallengeClarificationSeamTests` (4/4). | §Clarification Chat, §Interaction Endpoint | fixed (Phase 5; conformance 4/4, build 0/0) | +| DEV-7 | 5 | SampleApp Blazor page | `MissionCallChain.razor` originally spawned a per-second `PeriodicTimer`/`Task.Run` poll-counter that called `InvokeAsync(StateHasChanged)` while parked on a prompt. Because the approval popup leaves the main tab backgrounded, Chromium throttles it and stops ACKing SignalR render batches; the timer filled the circuit's unacked-batch buffer (default max 10) and **paused all rendering ~120 s** until it drained — an e2e timeout. Root cause is a Blazor Server anti-pattern (background-timer `StateHasChanged` on a backgroundable tab), not the protocol. **Fix:** removed the cosmetic timer entirely; the polling banner is surfaced by a single `StateHasChanged` with a static spinner. (`Mission.razor` keeps its `ct`-bound timer and passes; left untouched to avoid regression risk.) | (sample/UI only) | fixed (Phase 5) | +| DEV-8 | 5 | e2e spec timing | `mission-call-chain.spec.ts`'s `approvePrompt` asserted an **exact** transient `toHaveCount(2)` after the step-2 approval. Unlike `mission.spec.ts` — where every gate parks on the next prompt so the count settles — step 3 here is **silent and final**: it advances with no gate and appends its card immediately, and Blazor **coalesces** the step-2 and step-3 `StateHasChanged` into one render batch (the DOM jumps 1→3, never showing 2). The exact count was therefore racy by construction; the page behaviour is correct (forcing artificial render flushes into product code to satisfy a test would be the anti-pattern). **Fix:** the helper now waits for the just-approved step's card via `expect(stepCard(page, expectedCards)).toBeVisible()` (i.e. ≥ expectedCards), matching the helper's own documented intent ("reach expectedCards"). The strict final `toHaveCount(3)` and all per-card content assertions are unchanged; `mission.spec.ts` is untouched. | (e2e test only) | fixed (Phase 5; two consecutive clean full-suite runs) | +| DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"For `interaction` and `payment` types, the PS relays the interaction to the user and **returns a deferred response**… The agent polls until the user completes the interaction."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) | +| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional | ## Notes diff --git a/.agent/plans/2026-06-06-mission-api-refactor/research.md b/.agent/plans/2026-06-06-mission-api-refactor/research.md index 86447d1..fbb4a60 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/research.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/research.md @@ -106,6 +106,42 @@ It contains **no** implementation steps or task lists — those live in - **PT-A6 — Callbacks lack mission context.** `GovernanceOptions.OnInteractionRequired` / `OnClarificationRequired` receive only the requirement, not the mission; the caller must close over it. +- **PT-A7 — Resource access still hand-rolls the mission header + challenge cycle.** + Even after the governance gates collapsed to one-liners (`ProposeMissionAsync`, + `RequestPermissionAsync`, `RecordAuditAsync`), the *resource-access* leg an agent + runs **between** gates is still fully manual: it sets the `AAuth-Mission` header on + every outbound request by hand (`AAuthMissionHeader.FormatStructured(...)`), then + drives the 401→`AAuth-Requirement` parse→token-exchange→retry-with-auth-token + cycle itself (~30 lines in [samples/MissionAgent/Program.cs](../../../samples/MissionAgent/Program.cs) + `AccessMissionResourceAsync`). The challenge cycle already has a convenience layer + (`WithChallengeHandling()` + `WithInteractionHandling()` → + [ChallengeHandler](../../../src/AAuth/Agent/ChallengeHandler.cs)), but **nothing** + emits the agent's *own* mission header from a directly-held `Mission`. The existing + [MissionForwardingHandler](../../../src/AAuth/Agent/MissionForwardingHandler.cs) + only re-emits a mission **extracted from an upstream auth token** (call-chaining, + §Call Chaining) — it cannot help an agent that proposed the mission itself. + +> **Update (2026-06-06) — PT-A7 seam confirmed spec-backed.** The agent operating in +> a mission context is **required** to attach the mission to outbound resource +> requests: §Mission Context at Resources — "The agent includes the `AAuth-Mission` +> header when sending requests to resources, unless the mission is already conveyed +> in an auth token" — and the HTTP Message Signatures section — "When the agent is +> operating in a mission context, it includes the `AAuth-Mission` header and adds +> `aauth-mission` to the signed components." The SDK already auto-covers the +> `aauth-mission` component whenever the header is present +> ([AAuthSigningHandler](../../../src/AAuth/HttpSig/AAuthSigningHandler.cs) lines +> ~221–226, 294–331), so a builder seam that emits the header from a held `Mission` +> is sufficient and spec-correct: the signing handler beneath it covers the +> component automatically. **Decision (2026-06-06):** add an +> `AAuthClientBuilder.WithMission(Mission)` seam (a small `DelegatingHandler` that +> sets `AAuth-Mission` from `{approver, s256}`, mirroring `MissionForwardingHandler` +> but sourcing the mission directly) so a mission-holding agent composes +> `WithMission(...) + WithChallengeHandling() + WithInteractionHandling()` and the +> whole resource-access leg collapses to a single signed `SendAsync`. The "unless +> already conveyed in an auth token" carve-out stays the agent's call: call-chaining +> intermediaries keep using `MissionForwardingHandler`; `WithMission(...)` is for the +> originating agent that holds its own approved `Mission`. Tracked as **Phase 6** in +> [implementation-plan.md](implementation-plan.md). ### A.3 Resource/PS-side surface (verified) @@ -196,6 +232,17 @@ template for an optional-callback DI registration (PT-A4). > `MissionSession` surface **within Phase 2** (the structural sample work remains > Phase 4/5). Names already match conventions (`With*`/`Create`/`Add*`/`Map*`); no > renames of the Phase 1 public entry points are required. +> +> **Closure (2026-06-06) — all four divergences resolved.** D1, D3, and D4 landed; +> the unbound `AAuthGovernanceClient` ctor was removed so `BuildGovernance(...)`, +> `Create(..., personServer)`, and `AddAAuthGovernanceClient(...)` are the only +> construction paths and they share parameter names, the bound `GovernanceOptions` +> default shape, and the `Action` configure pattern across both the agent +> (`AddAAuthGovernanceClient`) and resource (`AddAAuthGovernance` + `MapAAuthGovernance`) +> sides. No public entry-point renames were needed — the Phase 1 names already +> matched the `With*`/`Create`/`Add*`/`Map*` vocabulary used by `AAuthClientBuilder`, +> `AddAAuthAgent`/`AddAAuthDiscovery`, and `MapAAuthResource`. The triad and naming +> DoD items are therefore satisfied by verification rather than further change. --- @@ -269,6 +316,37 @@ A new SampleApp page (e.g. `MissionCallChain.razor`) demonstrating: > Open: whether this is one combined page or two (clarification-only + > mission-call-chain). See [Open Design Choices](#open-design-choices). +> **Update (2026-06-06) — Phase 5 landed (single combined page).** Shipped as one +> `MissionCallChain.razor` page (DC4) with `mission-call-chain.spec.ts`. The flow is: +> **(1)** propose mission (PROMPT) → **(2)** access an out-of-mission elevated scope +> that triggers a **clarification round** (the SDK surfaces the untrusted question +> via `OnClarificationRequired`; Blazor `@`-encodes it before display; the agent +> answers and the user approves the PROMPT) → **(3)** carry the same mission with +> `WithMission(...)` to the Orchestrator `/mission` endpoint, which forwards +> `AAuth-Mission` to the WhoAmI `/jwt/mission` hop (SILENT, both hops seeded +> in-scope) → fetch + render the PS-held mission log. Three things surfaced while +> getting the spec green (all logged in +> [issues-and-deviations.md](issues-and-deviations.md)): +> +> - **SDK clarification→interaction escalation bugs (DEV-6, fixed in `DeferredExchange.cs`).** +> The poller did not stop on a post-clarification interaction `202` +> (`stopOnInteraction` not threaded through `PollAsync`/`ComposePollerOptions`), +> and `ResolveLocation` dropped the interaction URL when a polled `202` omitted the +> `Location` header. Covered by `ChallengeClarificationSeamTests` (4/4). +> - **Blazor render-batch stall (DEV-7, sample-only).** A per-second poll-counter +> `Task.Run`/`PeriodicTimer` calling `StateHasChanged` while the approval popup +> backgrounded the main tab filled the circuit's unacked-render-batch buffer and +> froze rendering ~120 s. Removed the cosmetic timer; a single `StateHasChanged` +> plus a static spinner conveys the polling state. +> - **Racy exact-transient-count assertion (DEV-8, e2e-only).** Step 3 is silent and +> final, so the step-2/step-3 `StateHasChanged` calls coalesce into one render +> batch (DOM 1→3, never 2). The helper now waits for the just-approved step's card +> (`expect(stepCard(page, expectedCards)).toBeVisible()`, i.e. ≥ N) instead of an +> exact `toHaveCount(N)`; the strict final `toHaveCount(3)` is unchanged. +> +> Result: `sample-app` suite green — 15 passed + 1 pre-existing skip across two +> consecutive clean CI runs; `mission.spec.ts` and `call-chain.spec.ts` unaffected. + --- ## Part F — Spec-Alignment Findings (verified) @@ -374,3 +452,34 @@ edit time. polling display (177–190); mission lanes (~274). - **WhoAmI/Program.cs**: `ChallengeForMission` (132–140); mission endpoints with `UseWhen` (195–213); scope descriptions (48–61). + +--- + +> **Update (2026-06-06) — Phase 10 independent spec-compliance review.** An +> independent reviewer walked the whole mission/governance surface against +> `draft-hardt-oauth-aauth-protocol.md` (no assumption that earlier phases were +> correct). Verdict: spec-compliant across all six areas — `AAuth-Mission` header +> structure + signed-component coverage (§Authorization Endpoint Request L632), the +> mission claim shape and verbatim-bytes SHA-256 (§Mission Approval), the four +> endpoint request/response shapes incl. the audit `201`-only rule (§Audit +> Endpoint), the deferred-consent `202`/poll flow incl. the DEV-6 fixes (§Deferred +> Responses), the clarification round-trip + 5-round limit (§Clarification), the +> `mission_terminated` `403` surfacing (§Mission Status Errors), and the +> originator-vs-intermediary header rules (§Call Chaining). DEV-5 (resource seam via +> `ChallengeOptions.MissionAware`, no resource governance builder) reconfirmed +> intentional. +> +> One genuine non-compliance was found and fixed — **NC-1 / DEV-9**: the governance +> mapper's `interaction`/`payment` branch returned `200 {status:"ok"}` +> unconditionally and never read `InteractionRelayResult.Pending`, so a relay that +> signalled `Pending = true` could not drive the spec-mandated `202` + poll loop +> (§Interaction Response L1199: *"the PS … returns a deferred response. The agent +> polls until the user completes the interaction."*). The handler now parks the +> interaction on the deferred-consent store (new `DeferredConsentKind.Interaction`) +> and answers `202` + poll `Location` when a store is registered, degrading to a +> synchronous `200` otherwise — mirroring the permission `Prompt` path. The agent +> side already polled `202`s correctly via `DeferredExchange`, so no client change +> was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests` (12/12). +> A secondary observation — completion review resolves synchronously rather than +> deferring (§Interaction Response L1212) — is logged as **DEV-10** (intentional: +> the completion relay contract is synchronous, no dropped `Pending` signal). diff --git a/.agent/plans/2026-06-06-r3-rich-resource-requests/implementation-plan.md b/.agent/plans/2026-06-06-r3-rich-resource-requests/implementation-plan.md new file mode 100644 index 0000000..4bda058 --- /dev/null +++ b/.agent/plans/2026-06-06-r3-rich-resource-requests/implementation-plan.md @@ -0,0 +1,214 @@ +# R3 (Rich Resource Requests) — Implementation Plan + +## Overview + +Implement support for the AAuth **Rich Resource Requests (R3)** extension +(`aauth-spec/draft-hardt-aauth-r3.md`): vocabulary-based, resource-declared +authorization with content-addressed R3 documents and `r3_uri`/`r3_s256`/ +`r3_granted`/`r3_conditional` token claims. + +See [research.md](research.md) for the full spec model, the (empty) current SDK +state, the implementation surface, and the recorded design decisions. Every phase +below cites the governing R3 spec section. + +> **Status: PENDING SCOPE DECISION.** R3 is an *Exploratory Draft* with no known +> implementations and concentrates value in the AS/MM roles the SDK does not host. +> The phases below assume the **mock-demo** scope tier; they are not active until +> the user confirms scope (research [Open Design Choices](research.md), Q1). This +> plan was extracted from the mission-API initiative on 2026-06-06. + +## Context + +- **Spec:** `aauth-spec/draft-hardt-aauth-r3.md` — §Vocabularies, §Authorization + Endpoint Extensions, §R3 Document, §Resource Token Extensions, §R3 Processing, + §Auth Token Extensions, §Security Considerations. +- **Base spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (HTTP Message + Signatures for AS-signed R3 fetch; resource/auth token structure). +- **Prerequisite:** RFC 8785 (JCS) canonical JSON. +- **Branch:** TBD (new branch recommended; do not mix with mission-API refactor). +- **Sequencing:** Phase 0 prerequisite (hasher) → Phase 1 models → Phase 2 agent → + Phase 3 resource → Phase 4 mock AS/MM → Phase 5 samples → Phase 6 docs → review. + +## Cross-Cutting Decisions + +- **CC1 — Scope tier (PENDING).** Default assumption: **mock-demo** — WhoAmI + publishes a vocabulary + R3 documents; `MockAccessServer` acts as the + R3-fetching AS (and MM); the agent sends `r3_operations`; enforcement is + demonstrated. Confirm before activating. +- **CC2 — Vocabulary focus (PENDING).** Default: **MCP + OpenAPI** first (most + demonstrable with existing samples). +- **CC3 — Hash correctness is gating.** RFC 8785 must be vector-tested before any + dependent phase (research Part E). +- **CC4 — Security invariants are test targets.** AS-only R3 fetch, hash-verify- + before-use, atomic audit-with-issuance (research Part A.7). + +--- + +## Phase 0 — RFC 8785 canonical JSON + R3 hash primitive + +**Goal:** A correct, vector-tested JCS serializer and `r3_s256` hasher. + +**Spec:** §Content Addressing (`base64url(SHA-256(RFC8785(document)))`, no padding); +RFC 8785. + +### Files (illustrative) + +| File | Action | +|------|--------| +| `src/AAuth/Crypto/JsonCanonicalizer.cs` | **New** — RFC 8785 serializer | +| `src/AAuth/R3/R3Hash.cs` | **New** — canonical JSON → SHA-256 → base64url(no-pad) | +| `tests/AAuth.Conformance/R3/JsonCanonicalizerTests.cs` | **New** — RFC 8785 vectors | +| `tests/AAuth.Conformance/R3/R3HashTests.cs` | **New** — spec example documents | + +### Definition of Done + +- [ ] JCS serializer passes RFC 8785 published test vectors. +- [ ] `r3_s256` matches hand-computed hashes for spec example documents. +- [ ] No dependent phase starts until this is green. + +--- + +## Phase 1 — R3 models + +**Goal:** Strongly-typed R3 document, operations, and claim types. + +**Spec:** §R3 Document / Fields; §Vocabularies (per-vocabulary operation shape); +§Auth Token Extensions (`r3_granted`/`r3_conditional`). + +### Files (illustrative) + +| File | Action | +|------|--------| +| `src/AAuth/R3/R3Document.cs` | **New** — `version?`, `vocabulary`, `operations[]`, `display?` | +| `src/AAuth/R3/Vocabulary.cs` | **New** — vocabulary URIs + registry constants | +| `src/AAuth/R3/R3Operation.cs` | **New** — per-vocabulary operation records | +| `src/AAuth/R3/R3Display.cs` | **New** — `summary`/`implications?`/`data_accessed?`/`irreversible?` | +| `src/AAuth/R3/R3Operations.cs` | **New** — request `{vocabulary, operations[]}` | +| `tests/AAuth.Conformance/R3/R3ModelTests.cs` | **New** | + +### Definition of Done + +- [ ] Models round-trip JSON for all seven vocabularies (or the CC2-chosen subset). +- [ ] `display.summary` required when `display` present; validation enforced. + +--- + +## Phase 2 — Agent-side R3 + +**Goal:** Send `r3_operations`; read `r3_granted`/`r3_conditional`; handle the +conditional per-call challenge. + +**Spec:** §Authorization Endpoint Extensions (`r3_operations` request param); +§Auth Token Extensions (grant claims); §Resource Enforcement (conditional flow). + +### Files (illustrative) + +| File | Action | +|------|--------| +| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — carry `R3Operations` | +| `src/AAuth/Tokens/*AuthToken*` | **Modify** — parse `r3_granted`/`r3_conditional` | +| `tests/AAuth.Conformance/R3/AgentR3RequestTests.cs` | **New** | + +### Definition of Done + +- [ ] Agent emits `r3_operations` in the authorize/exchange body. +- [ ] Auth-token grant claims surfaced to the caller. +- [ ] `r3_conditional` per-call challenge round-trip exercised. + +--- + +## Phase 3 — Resource-side R3 + +**Goal:** Advertise vocabularies, emit `r3_uri`/`r3_s256`, serve AS-gated R3 +documents, enforce grants. + +**Spec:** §Vocabularies (`r3_vocabularies` metadata); §Resource Token Extensions +(both claims together); §R3 Document (AS-signed, HTTPS, agent-opaque); §Resource +Enforcement; §Security Considerations. + +### Files (illustrative) + +| File | Action | +|------|--------| +| Resource metadata options | **Modify** — `r3_vocabularies` | +| Resource token builder | **Modify** — `r3_uri` + `r3_s256` (both) | +| `src/AAuth/Server/R3/R3DocumentEndpoint.cs` | **New** — AS-signature-gated serve | +| `src/AAuth/Server/R3/R3Enforcement.cs` | **New** — match `r3_granted`/`r3_conditional` | +| `tests/AAuth.Conformance/R3/ResourceR3Tests.cs` | **New** | + +### Definition of Done + +- [ ] `r3_vocabularies` published in `/.well-known/aauth-resource.json`. +- [ ] Resource token includes both `r3_uri` and `r3_s256` when R3 applies. +- [ ] R3-document endpoint **rejects non-AS** callers (agent opacity) — tested. +- [ ] Enforcement serves `r3_granted`, challenges `r3_conditional`, rejects else. + +--- + +## Phase 4 — Mock AS/MM R3 processing + +**Goal:** Demonstrate the AS/MM half via mock servers (per CC1). + +**Spec:** §R3 Processing (AS fetch + hash-verify + cache + claim population; MM +`display` consent); §Security Considerations (atomic audit-with-issuance). + +### Files (illustrative) + +| File | Action | +|------|--------| +| `samples/MockAccessServer/Program.cs` | **Modify** — AS: fetch (signed), hash-verify, cache by `r3_s256`, populate grants, audit atomically | +| `samples/MockPersonServer/` or MM stub | **Modify** — render `display` for consent | + +### Definition of Done + +- [ ] AS fetches R3 doc with a valid HTTP Message Signature; hash-verifies before use. +- [ ] AS populates `r3_granted`/(`r3_conditional`) in the auth token. +- [ ] MM surfaces `display` (`summary`/`implications`/…) at consent time. + +--- + +## Phase 5 — Samples + +**Goal:** End-to-end R3 demo with a real vocabulary. + +### Files (illustrative) + +| File | Action | +|------|--------| +| `samples/WhoAmI/Program.cs` | **Modify** — publish a vocabulary + R3 documents | +| Agent sample | **Modify** — request `r3_operations`; show granted/conditional | +| `tests/e2e/` | **New** — R3 Playwright/console spec | + +### Definition of Done + +- [ ] A full R3 flow runs against the mock stack for the CC2 vocabulary. +- [ ] Conditional-operation per-call approval demonstrated. + +--- + +## Phase 6 — Docs & review + +**Goal:** Document R3 support; multi-subagent review + gates. + +### Files (illustrative) + +| File | Action | +|------|--------| +| `docs/advanced/r3-rich-resource-requests.md` | **New** — full walkthrough | +| docs index / concepts | **Modify** — link R3 | + +### Definition of Done + +- [ ] R3 doc compiles against the surface; security invariants called out. +- [ ] Subagent findings adjudicated against spec text before changes. +- [ ] Build 0/0; unit + conformance + R3 e2e green; research updated with findings. + +--- + +## Out of Scope + +| Item | Reason | +|------|--------| +| Production AS/MM SDK roles | SDK is agent+resource centric; AS/MM are external — mock only | +| Vocabulary discovery parsing (tool list / OpenAPI / `$metadata` / introspection) | Discovery detail; later phase even within R3 | +| Mission-API streamlining | Separate initiative — `.agent/plans/2026-06-06-mission-api-refactor/` | diff --git a/.agent/plans/2026-06-06-r3-rich-resource-requests/research.md b/.agent/plans/2026-06-06-r3-rich-resource-requests/research.md new file mode 100644 index 0000000..e31f7e5 --- /dev/null +++ b/.agent/plans/2026-06-06-r3-rich-resource-requests/research.md @@ -0,0 +1,261 @@ +# R3 (Rich Resource Requests) — Research + +## Problem Statement + +The AAuth **Rich Resource Requests (R3)** extension +(`aauth-spec/draft-hardt-aauth-r3.md`) adds **resource-declared, vocabulary-based +authorization** to the AAuth Protocol: resources publish content-addressed **R3 +documents** describing what a class of access *means*, and tokens carry `r3_uri`, +`r3_s256`, `r3_granted`, and `r3_conditional` claims instead of (or alongside) +opaque scopes. The AAuth .NET SDK (`src/AAuth/`) has **zero** R3 implementation. + +This document captures the R3 spec model, the current (empty) SDK state, the +implementation surface, the prerequisites (notably RFC 8785 canonical JSON), the +interplay with missions, and the open design choices — so a future initiative can +plan R3 support. It contains **no** implementation steps; those live in +[implementation-plan.md](implementation-plan.md). + +> This research was **extracted** from the mission-API initiative +> (`.agent/plans/2026-06-06-mission-api-refactor/`) on 2026-06-06, where R3 was +> originally folded in as Part E and then split out at the user's request. + +## Source Documents + +| Document | Location | Relevant Sections | +|----------|----------|-------------------| +| AAuth R3 (Rich Resource Requests) | `aauth-spec/draft-hardt-aauth-r3.md` | §Vocabularies; §Authorization Endpoint Extensions; §R3 Document; §Resource Token Extensions; §R3 Processing (MM/AS); §Auth Token Extensions; §Security Considerations; §IANA; §Design Rationale | +| AAuth Protocol (base) | `aauth-spec/draft-hardt-oauth-aauth-protocol.md` | §Authorization Endpoint; §Resource Token; §Auth Token; HTTP Message Signatures (AS-signed R3 fetch) | +| RFC 8785 (JCS) | external | JSON Canonicalization Scheme — required for `r3_s256` | + +> **Draft status:** R3 is an **Exploratory Draft** (draft-hardt-aauth-r3-00, dated +> 2026-03-24). Its Implementation Status section states: *"There are currently no +> known implementations."* The spec may change materially before standardization, +> which is central to the in-scope-now vs. wait decision (see +> [Open Design Choices](#open-design-choices)). + +--- + +## Part A — R3 Spec Model + +### A.1 Core concepts (§R3 Document; §Vocabularies) + +- **Vocabulary** (`urn:aauth:vocabulary:*`): names how operations are expressed for + an interface type. The agent declares operations; the resource and AS interpret + them through the vocabulary. +- **R3 Document**: a JSON object the **resource** publishes at a URI, describing a + class of access — `vocabulary`, `operations`, and human-readable `display`. It is + **content-addressed** by SHA-256 of its RFC 8785 canonical form. +- **`r3_uri`**: where the AS fetches the R3 document. +- **`r3_s256`**: `base64url(SHA-256(RFC8785(document)))`, no padding. The document's + identity is its **hash**, not its URI — enabling infinite caching and permanent + audit provenance. +- **Resource-declared, not client-declared** (§Design Rationale / Why Not RAR): the + resource defines and **signs** the access semantics; the agent cannot reframe it. + This opposite directionality from OAuth RAR is a deliberate security property. + +### A.2 Standard vocabularies (§Vocabularies) + +Seven registered vocabularies, each with a vocabulary-specific operation shape: + +| Vocabulary URI | Interface | Operation entry | +|----------------|-----------|-----------------| +| `urn:aauth:vocabulary:mcp` | MCP server | `tool` (REQUIRED) | +| `urn:aauth:vocabulary:openapi` | HTTP/REST | `operationId` (REQUIRED) | +| `urn:aauth:vocabulary:grpc` | gRPC | `method` = `pkg.Service/Method` (REQUIRED) | +| `urn:aauth:vocabulary:graphql` | GraphQL | `operation` + `type` (query/mutation/subscription) | +| `urn:aauth:vocabulary:asyncapi` | Event-driven | `operationId` + `action` (send/receive) | +| `urn:aauth:vocabulary:wsdl` | SOAP/WSDL | `operation` + `service?` | +| `urn:aauth:vocabulary:odata` | OData | `operation` + `methods?` | + +New values register under Specification Required (RFC 8126). + +### A.3 R3 Document fields (§R3 Document / Fields) + +- **`version`** (RECOMMENDED) — human-readable; identity is the hash, not this. +- **`vocabulary`** (REQUIRED) — must match one advertised in `r3_vocabularies`. +- **`operations`** (REQUIRED) — vocabulary-specific array; same shape used in the + agent request and the auth token grants. +- **`display`** (RECOMMENDED) — consent-facing description of what *the resource* + does: + - `summary` (REQUIRED if `display` present) + - `implications` (OPTIONAL) — side effects (emails sent, records modified, costs) + - `data_accessed` (OPTIONAL) — what becomes visible + - `irreversible` (OPTIONAL) — actions that cannot be undone + +### A.4 Extension points (where R3 touches the protocol) + +| Extension point | Spec § | Shape | +|-----------------|--------|-------| +| Resource metadata | §Vocabularies (intro) | OPTIONAL `r3_vocabularies` object (vocabulary URI → discovery endpoint) in `/.well-known/aauth-resource.json` | +| Authorization endpoint request | §Authorization Endpoint Extensions | OPTIONAL `r3_operations` `{vocabulary, operations[]}` in the authorize body | +| Resource token | §Resource Token Extensions | adds `r3_uri` + `r3_s256` (MUST include both when R3 present); coexists with `scope` — AS enforces both independently | +| AS processing | §R3 Processing / AS Processing | fetch R3 doc (AS-signed), hash-verify, audit `r3_uri`/`r3_s256`, evaluate `operations`, mint auth-token claims | +| Auth token | §Auth Token Extensions | adds `r3_uri`, `r3_s256`, `r3_granted` (REQUIRED), `r3_conditional` (OPTIONAL) | +| Resource enforcement | §Auth Token Extensions / Resource Enforcement | match call → `r3_granted` (serve) / `r3_conditional` (challenge w/ params) / else reject | +| MM processing | §R3 Processing / MM | fetch R3 doc to show `display` during consent | + +### A.5 Token claim semantics (§Auth Token Extensions) + +- **`r3_granted`** (REQUIRED): operations the AS fully authorized — the resource + serves them immediately, no further round-trip. +- **`r3_conditional`** (OPTIONAL): operations authorized *in principle* but requiring + per-call approval. The resource returns `AAuth-Requirement` with a resource token + containing the **actual call parameters**; the AS evaluates those concrete params + and issues a per-call auth token. +- Enforcement needs **no introspection or R3 fetch** at access time — the resource + matches against the vocabulary it already understands. + +### A.6 Content addressing & caching (§Content Addressing; §R3 Processing / Caching) + +- The AS (and MM) cache R3 documents by `r3_s256`; a document that verifies against + its hash never needs re-fetching. +- Old auth tokens keep referencing the previous hash even after the resource updates + the document at the same URI — permanent audit provenance. +- The AS need not retain documents beyond token issuance; its audit log records + `r3_uri` + `r3_s256`, sufficient for later re-verification. + +### A.7 Security invariants (§Security Considerations) + +- **AS-only R3-document fetch.** The resource MUST require a valid HTTP Message + Signature from its AS on R3-document requests and reject all others. This is what + makes agents carry a hash of a document they cannot read (agent opacity). Treat as + a critical, deployment-tested access control. +- **Hash-verify before use.** The AS MUST verify `r3_s256` against the fetched + document before using it. +- **Atomic audit-with-issuance.** Auth-token issuance and its audit-log entry MUST be + written atomically (transactional or equivalent). +- **Operation validation.** The resource MUST validate declared operations against + its authoritative interface definition before issuing a resource token. +- **Grant enforcement.** `r3_granted` served; `r3_conditional` MUST trigger + `AAuth-Requirement`; non-matching calls rejected. + +### A.8 IANA registrations (§IANA) + +- JWT claims: `r3_uri`, `r3_s256`, `r3_granted`, `r3_conditional`. +- New "AAuth R3 Vocabulary Registry" seeded with the seven vocabularies above. + +--- + +## Part B — Current SDK State (empty) + +`grep` for `r3_uri|r3_s256|r3_vocabular|R3Document|vocabulary|RichResource` across +`src/**/*.cs` returns **no matches** (verified 2026-06-06). There is: + +- **No** R3 model (`R3Document`, vocabulary/operation records). +- **No** `r3_vocabularies` metadata field on resource metadata. +- **No** `r3_operations` request parameter on the authorize/exchange request. +- **No** `r3_uri`/`r3_s256`/`r3_granted`/`r3_conditional` token claims. +- **No** RFC 8785 (JCS) canonical-JSON serializer — a hard prerequisite for + `r3_s256`. The SDK's hashing today operates on verbatim bytes (e.g. mission + `s256`), not canonicalized JSON. +- **No** AS or MM role implementation. R3's AS/MM processing (fetch, hash-verify, + cache, claim population, consent display) has no host in the current SDK; only + mock servers (`samples/MockAccessServer`) could demonstrate it. + +--- + +## Part C — Implementation Surface (candidate inventory) + +A full R3 implementation would touch the following areas. Sizing only — not a plan. + +### C.1 Prerequisite primitive + +- **RFC 8785 (JCS) canonical JSON** serializer + SHA-256 → base64url(no-pad) hasher. + Independently unit-testable against the RFC's published test vectors. This is the + riskiest standalone unit; everything else depends on a correct hash. + +### C.2 Models + +- `R3Document` (`version?`, `vocabulary`, `operations[]`, `display?`). +- Per-vocabulary operation records (MCP `tool`, OpenAPI `operationId`, gRPC + `method`, GraphQL `operation`+`type`, AsyncAPI `operationId`+`action`, WSDL + `operation`+`service?`, OData `operation`+`methods?`). +- `R3Operations` request `{vocabulary, operations[]}`. +- `R3Display` (`summary`, `implications?`, `data_accessed?`, `irreversible?`). +- `R3Grant`/`R3Conditional` claim types (vocabulary + operations). + +### C.3 Agent side + +- Send `r3_operations` on the authorize/exchange request body. +- Read `r3_granted`/`r3_conditional` from the auth token; expose to the caller. +- Handle `r3_conditional` per-call challenge round-trips (`AAuth-Requirement` with + call parameters). + +### C.4 Resource side + +- Publish `r3_vocabularies` in `/.well-known/aauth-resource.json`. +- Map declared operations → an R3 document; emit `r3_uri` + `r3_s256` in the + resource token (both, always together). +- Serve **AS-signature-gated** R3-document endpoints (reject non-AS callers). +- Enforce `r3_granted`/`r3_conditional` at access time without introspection. + +### C.5 AS/MM side (mock-only in this SDK) + +- AS: fetch R3 doc (AS-signed), hash-verify, cache by `r3_s256`, audit, evaluate + `operations`, populate `r3_granted`/`r3_conditional` in the auth token, atomic + audit-with-issuance. +- MM: fetch R3 doc, render `display` for consent. + +### C.6 Samples & docs + +- A resource sample (e.g. WhoAmI) publishing a vocabulary + R3 documents. +- `MockAccessServer` extended to act as the R3-fetching AS (and/or MM). +- A new R3 walkthrough doc + conformance vectors for hashing and token claims. + +--- + +## Part D — R3 ↔ Mission Interplay + +Both R3 `display`-based consent and mission governance live partly at the PS/MM: + +- The **MM** fetches R3 `display` to obtain informed consent (§R3 Processing / MM). +- The **PS** governs missions (mission intent + log). + +R3 does **not** specify how an `r3_operations` request interacts with a mission's +`approved_tools` or the permission flow. If R3 and missions are combined in a sample +or product flow, that interplay needs an explicit design decision (e.g. does an +R3-granted operation satisfy a mission permission check, or are they orthogonal?). +This is an open spec gap, not just an SDK gap. + +--- + +## Part E — Risks & Considerations + +- **Spec volatility.** Exploratory Draft, no known implementations — APIs built now + may need rework. Conformance vectors should track the draft revision. +- **RFC 8785 correctness.** Canonicalization bugs silently break interop (different + content hashed than peers compute). Must be vector-tested. +- **Role gap.** The SDK is agent+resource centric; R3's value concentrates in the + AS/MM. Demonstrating R3 end-to-end requires mock AS/MM, raising the effort even + for a "demo" scope. +- **Security-critical access control.** The AS-only R3-fetch restriction is the + linchpin of agent opacity; a weak implementation silently breaks the core property + and must be deployment-tested. + +--- + +## Open Design Choices + +These require user input **before** authoring `implementation-plan.md` for R3. + +1. **Scope tier.** (a) Research-only (this doc is the deliverable), (b) mock-demo + (WhoAmI publishes a vocabulary; MockAccessServer acts as AS/MM; agent sends + `r3_operations`; enforcement demonstrated), or (c) full SDK implementation + (models + RFC 8785 hasher + token claims + resource enforcement + AS/MM fetch). +2. **Vocabulary focus.** If building, which vocabulary(ies) first — MCP and/or + OpenAPI are the most demonstrable with the existing samples. +3. **AS/MM hosting.** Use `MockAccessServer` as the R3-fetching AS (and MM), or + stub these roles minimally? +4. **Mission interplay.** Keep R3 and missions orthogonal in samples, or design a + combined flow (Part D)? +5. **Timing.** Proceed now, or hold until the R3 draft advances past Exploratory? + +--- + +## Out of Scope (unless decided otherwise) + +| Item | Reason | +|------|--------| +| Production AS/MM SDK roles | The SDK is agent+resource centric; AS/MM are external — mock only | +| Vocabulary discovery parsing (MCP tool list / OpenAPI / `$metadata` / introspection fetch) | Resource/agent discovery detail; likely a later phase even within R3 | +| Mission-API streamlining | Tracked separately in `.agent/plans/2026-06-06-mission-api-refactor/` | diff --git a/docs/advanced/clarification-chat.md b/docs/advanced/clarification-chat.md index 5030e6b..53587e1 100644 --- a/docs/advanced/clarification-chat.md +++ b/docs/advanced/clarification-chat.md @@ -134,11 +134,12 @@ var authToken = await exchangeClient.ExchangeAsync(personServer, resourceToken, The same pattern applies to the governance clients. Supply `OnClarificationRequired` (and optionally `MaxClarificationRounds`) on -`GovernanceOptions` when proposing a mission or requesting permission: +`GovernanceOptions` when proposing a mission or requesting permission. The +governance client is **bound** to its Person Server, so no per-call PS URL is +needed: ```csharp -var mission = await governance.Mission.ProposeAsync( - "https://ps.example", +var session = await governance.ProposeMissionAsync( new MissionProposal("Reconcile last month's invoices."), new GovernanceOptions { @@ -154,6 +155,7 @@ fails rather than blocking. ## Further reading - [Mission Governance Clients](mission-governance-clients.md) — where governance clarification fits +- [Mission Call Chain sample](../../samples/SampleApp/Components/Pages/MissionCallChain.razor) — a clarification round during an out-of-mission elevated-scope exchange, followed by a mission-forwarded call chain - [Deferred Consent](../workflows/deferred-consent.md) — the broader deferred-response lifecycle - [Error Handling](error-handling.md) — `AAuthClarificationCancelledException`, `AAuthClarificationLimitException` - [Missions](missions.md) — the mission model diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md index 648c290..2a578f2 100644 --- a/docs/advanced/error-handling.md +++ b/docs/advanced/error-handling.md @@ -248,7 +248,7 @@ public sealed class AAuthMissionTerminatedException : Exception ```csharp try { - await governance.Audit.RecordAsync(ps, record); + await session.RecordAuditAsync("email.send"); } catch (AAuthMissionTerminatedException ex) { diff --git a/docs/advanced/mission-governance-clients.md b/docs/advanced/mission-governance-clients.md index 229f7ef..f1d791e 100644 --- a/docs/advanced/mission-governance-clients.md +++ b/docs/advanced/mission-governance-clients.md @@ -24,31 +24,38 @@ For the mission model itself (the blob, `s256`, the `AAuth-Mission` header), see ## Building the facade +The client is **bound to one Person Server**. The easiest way to build it is +`AAuthClientBuilder.BuildGovernance()`, which requires an explicit signing mode +and a configured PS: + ```csharp using AAuth.Agent.Governance; -// Requires an explicit signing mode; BuildGovernance throws otherwise. +// Requires an explicit signing mode AND a Person Server; BuildGovernance throws otherwise. AAuthGovernanceClient governance = new AAuthClientBuilder(key) .UseJwt(agentToken) + .WithPersonServer("https://ps.example") .BuildGovernance(); - -MissionClient missions = governance.Mission; -PermissionClient permissions = governance.Permission; -AuditClient audit = governance.Audit; -InteractionClient interaction = governance.Interaction; ``` -You can also construct it directly from an already-signed channel: +You can also construct it directly from an already-signed channel — the PS URL is +still required: ```csharp -var governance = new AAuthGovernanceClient(signedClient, metadataClient); +var governance = new AAuthGovernanceClient(signedClient, metadataClient, "https://ps.example"); ``` +The four endpoint clients are exposed as bound properties +(`governance.Mission`, `.Permission`, `.Audit`, `.Interaction`) for direct use, +but the usual path is `ProposeMissionAsync`, which returns a `MissionSession` +that auto-threads the mission claim and PS into every later call. + ## Proposing a mission The agent sends a Markdown `Description` of its intent plus the tools it wants pre-approved. The PS may approve all tools, a subset, or none, and may run a -[clarification chat](clarification-chat.md) before approving. +[clarification chat](clarification-chat.md) before approving. `ProposeMissionAsync` +returns a **session** scoped to the approved mission: ```csharp var proposal = new MissionProposal("Book a table for four near the office on Friday.") @@ -60,35 +67,33 @@ var proposal = new MissionProposal("Book a table for four near the office on Fri }, }; -Mission mission = await governance.Mission.ProposeAsync( - personServer: "https://ps.example", - proposal: proposal, - options: new GovernanceOptions +MissionSession session = await governance.ProposeMissionAsync( + proposal, + new GovernanceOptions { OnClarificationRequired = async (requirement, ct) => ClarificationResponse.Respond("Friday dinner, around 7pm, four people."), }); +Mission mission = session.Mission; // mission.ApprovedTools may be a subset of what was proposed. Console.WriteLine($"Mission {mission.S256} approved by {mission.Approver}."); ``` -`ProposeAsync` stores the approval body verbatim, computes its `s256`, and +`ProposeMissionAsync` stores the approval body verbatim, computes its `s256`, and verifies it against the `AAuth-Mission` response header before returning. A mismatch throws `InvalidOperationException`. ## Requesting permission for a tool -Tools are the actions the agent runs itself. Use the mission overload: when the +Tools are the actions the agent runs itself. Call it on the session: when the action matches a pre-approved tool the call resolves locally without a PS round-trip; otherwise it goes to the PS, which may grant, deny, or prompt the user (§Permission Endpoint). ```csharp -PermissionResult result = await governance.Permission.RequestAsync( - personServer: "https://ps.example", - action: "email.send", - mission: mission, +PermissionResult result = await session.RequestPermissionAsync( + "email.send", description: "Send the booking confirmation to the four guests."); if (result.IsGranted) @@ -103,20 +108,16 @@ else } ``` -For an action not on the mission, the PS evaluates it against the mission log and -may prompt the user. Supply `OnInteractionRequired` / `OnClarificationRequired` -via `GovernanceOptions` to participate in any deferral. +The action is a `MissionAction` POCO; a bare `string` converts implicitly, so +`"email.send"` works directly. For an action not on the mission, the PS evaluates +it against the mission log and may prompt the user. Supply `OnInteractionRequired` +/ `OnClarificationRequired` via `GovernanceOptions` to participate in any deferral. ```csharp -var request = new PermissionRequest("files.delete") -{ - Description = "Remove the stale draft the user mentioned.", - Parameters = new JsonObject { ["path"] = "/drafts/old.md" }, - Mission = new MissionClaim(mission.Approver, mission.S256), -}; - -PermissionResult outcome = await governance.Permission.RequestAsync( - "https://ps.example", request); +PermissionResult outcome = await session.RequestPermissionAsync( + "files.delete", + description: "Remove the stale draft the user mentioned.", + parameters: new JsonObject { ["path"] = "/drafts/old.md" }); ``` ## Recording an audit entry @@ -127,15 +128,10 @@ surfaces as `AAuthMissionTerminatedException` (see [Error Handling](error-handling.md#mission-termination)). ```csharp -await governance.Audit.RecordAsync( - personServer: "https://ps.example", - record: new AuditRecord( - Mission: new MissionClaim(mission.Approver, mission.S256), - Action: "email.send") - { - Description = "Sent booking confirmation to 4 recipients.", - Result = new JsonObject { ["messageId"] = "msg-8842" }, - }); +await session.RecordAuditAsync( + "email.send", + description: "Sent booking confirmation to 4 recipients.", + result: new JsonObject { ["messageId"] = "msg-8842" }); ``` ## Reaching the user @@ -146,60 +142,49 @@ question, or propose mission completion. Each request type resolves to a typed `InteractionResult`. ```csharp -var missionClaim = new MissionClaim(mission.Approver, mission.S256); - // Ask the user a clarifying question mid-mission. -string? answer = await governance.Interaction.AskQuestionAsync( - "https://ps.example", - question: "Window seat or booth?", - mission: missionClaim); +string? answer = await session.AskQuestionAsync("Window seat or booth?"); // Relay a resource interaction (e.g. a payment-style confirmation URL + code). -await governance.Interaction.RelayInteractionAsync( - "https://ps.example", +await session.RelayInteractionAsync( url: "https://resource.example/confirm/abc", code: "4821", - description: "Confirm the reservation.", - mission: missionClaim); + description: "Confirm the reservation."); // Propose completion; true when the user accepted and the PS terminated the mission. -bool done = await governance.Interaction.ProposeCompletionAsync( - "https://ps.example", - summary: "Booked Table 12 for four at 7pm Friday and emailed the group.", - mission: missionClaim); +bool done = await session.ProposeCompletionAsync( + "Booked Table 12 for four at 7pm Friday and emailed the group."); ``` -`SendAsync` returns an `InteractionResult` whose populated fields depend on the -type: `question` fills `Answer`, `completion` fills `Terminated`, and +Each interaction call returns an `InteractionResult` whose populated fields depend +on the type: `question` fills `Answer`, `completion` fills `Terminated`, and `interaction`/`payment` resolve once the user completes. ## A full lifecycle ```csharp -var governance = new AAuthClientBuilder(key).UseJwt(agentToken).BuildGovernance(); -const string ps = "https://ps.example"; +var governance = new AAuthClientBuilder(key) + .UseJwt(agentToken) + .WithPersonServer("https://ps.example") + .BuildGovernance(); -// 1. Propose → approve -var mission = await governance.Mission.ProposeAsync(ps, +// 1. Propose → approve (returns a session scoped to the mission) +var session = await governance.ProposeMissionAsync( new MissionProposal("Tidy the user's reading list.") { Tools = new[] { new MissionTool("bookmarks.archive") }, }); -var claim = new MissionClaim(mission.Approver, mission.S256); // 2. Permission for a pre-approved tool → granted silently -var perm = await governance.Permission.RequestAsync(ps, "bookmarks.archive", mission); +var perm = await session.RequestPermissionAsync("bookmarks.archive"); // 3. Do the work, then audit it -await governance.Audit.RecordAsync(ps, - new AuditRecord(claim, "bookmarks.archive") - { - Result = new JsonObject { ["archived"] = 12 }, - }); +await session.RecordAuditAsync( + "bookmarks.archive", + result: new JsonObject { ["archived"] = 12 }); // 4. Close the mission out -bool terminated = await governance.Interaction.ProposeCompletionAsync( - ps, "Archived 12 stale bookmarks.", claim); +bool terminated = await session.ProposeCompletionAsync("Archived 12 stale bookmarks."); ``` ## Further reading diff --git a/docs/advanced/missions.md b/docs/advanced/missions.md index 3123618..d1f84a8 100644 --- a/docs/advanced/missions.md +++ b/docs/advanced/missions.md @@ -122,6 +122,41 @@ request.Headers.TryAddWithoutValidation( var response = await signedClient.SendAsync(request); ``` +### Carrying your own mission with `WithMission` + +When the **originating** agent holds its own approved `Mission`, you don't have to +set the header by hand on every request. `AAuthClientBuilder.WithMission(mission)` +attaches the `AAuth-Mission` header (`{approver, s256}`) to every outbound request, +and the signing pipeline covers it as the `aauth-mission` component automatically +(§Mission Context at Resources, §HTTP Message Signatures). Compose it with +`WithChallengeHandling()` / `WithInteractionHandling()` so the entire +resource-access leg — mission header, the `401` challenge, the token exchange, and +the retry — collapses to a single signed `SendAsync`: + +```csharp +using var client = AAuthClientBuilder.SelfIssuing(identity.Key) + .As(identity.Issuer, identity.AgentId) + .WithKid(identity.KeyId) + .WithPersonServer(personServer) + .WithMission(mission) // emits AAuth-Mission on every request + .WithChallengeHandling(o => o.OnInteractionRequired = SurfaceInteractionAsync) + .WithInteractionHandling() + .Build(); + +// The mission header is present and signed before the request leaves; if the +// resource challenges, the mission travels through the exchange so the PS can +// evaluate the requested scope against the mission's intent. +var response = await client.GetAsync("https://resource.example/data"); +``` + +`WithMission(...)` is for the agent that holds its **own** approved mission. A +call-chaining intermediary that re-emits a mission extracted from an *upstream* +auth token uses `WithCallChaining(...)` instead (see +[Forwarding a mission in a call chain](#forwarding-a-mission-in-a-call-chain) +below); the two are mutually exclusive on a given client, and the header is never +emitted twice. The combined [Mission Call Chain sample](../../samples/SampleApp/Components/Pages/MissionCallChain.razor) +uses `WithMission(...)` to carry one approved mission across a forwarded call chain. + ## The binding chain The mission travels end to end as a `MissionClaim` — `{ approver, s256 }` — diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 6e5beda..57d9e6f 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -386,20 +386,31 @@ var client = new AAuthClientBuilder(key) ### Agent side: the governance client -The mission governance clients (mission, permission, audit, interaction) are built -from `AAuthClientBuilder`, which wires the signed channel for you. There is no -dedicated DI extension — register the facade as a singleton or build it where you -need it. +The mission governance client is built from `AAuthClientBuilder`, which wires the +signed channel for you. The client is **bound to one Person Server**, so the builder +must set both a signing mode and a Person Server before `BuildGovernance()`. Use the +`AddAAuthGovernanceClient(...)` DI extension to register it as a singleton: ```csharp -builder.Services.AddSingleton(sp => +builder.Services.AddAAuthGovernanceClient(sp => new AAuthClientBuilder(agentKey) .UseJwt(agentToken) - .BuildGovernance()); // AAuthGovernanceClient + .WithPersonServer("https://ps.example")); // bound governance client ``` -`BuildGovernance()` requires an explicit signing mode and throws -`InvalidOperationException` otherwise. See +There is also a factory overload — `AddAAuthGovernanceClient(sp => /* AAuthGovernanceClient */)` +— when you need full control over construction. To build one inline instead of via +DI, call `BuildGovernance()` on a configured builder: + +```csharp +var governance = new AAuthClientBuilder(agentKey) + .UseJwt(agentToken) + .WithPersonServer("https://ps.example") + .BuildGovernance(); // AAuthGovernanceClient +``` + +`BuildGovernance()` requires an explicit signing mode **and** a configured Person +Server (`WithPersonServer`), and throws `InvalidOperationException` otherwise. See [Mission Governance Clients](../advanced/mission-governance-clients.md). ### Person Server side: the governance seams diff --git a/docs/server/mission-governance.md b/docs/server/mission-governance.md index ce7b6b9..aac53db 100644 --- a/docs/server/mission-governance.md +++ b/docs/server/mission-governance.md @@ -20,32 +20,74 @@ calls the PS answers. ## Registering the seams -`AddAAuthGovernance` registers the in-memory storage defaults. It uses `TryAdd`, -so a PS can register durable implementations first and keep the rest. +`AddAAuthGovernance` registers the storage defaults **and** conservative no-op +policy/user-channel defaults. Every seam is registered with `TryAdd`, so a PS can +register its own implementations (before or after the call) and keep the rest. ```csharp using Microsoft.Extensions.DependencyInjection; -builder.Services.AddAAuthGovernance(); // InMemoryMissionStore + InMemoryMissionLog +builder.Services.AddAAuthGovernance(); // stores + no-op approver/decider/sink/relay -// The policy and user-channel seams are supplied by the PS: +// Override the policy and user-channel seams with the PS's own: +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` -| Seam | Default | Who owns it | -|------|---------|-------------| +| Seam | Default (`AddAAuthGovernance`) | Who owns it | +|------|--------------------------------|-------------| | `IMissionStore` | `InMemoryMissionStore` | SDK default; swap for durable storage | | `IMissionLog` | `InMemoryMissionLog` | SDK default; swap for durable storage | -| `IPermissionDecider` | none | PS supplies policy | -| `IAuditSink` | none | PS supplies storage/alerting | -| `IInteractionRelay` | none | PS supplies the user channel | +| `IMissionApprover` | `DefaultMissionApprover` | SDK default; PS supplies approval policy | +| `IPermissionDecider` | `DefaultPermissionDecider` (no-op) | PS supplies policy | +| `IAuditSink` | `DefaultAuditSink` (logs to the mission log) | PS supplies storage/alerting | +| `IInteractionRelay` | `DefaultInteractionRelay` (no user channel) | PS supplies the user channel | + +By default a `Prompt` outcome is resolved synchronously (a permission denial / a +mission decline), since the mapper has no user channel. To opt into the deferred +`202`-poll consent flow (§Deferred Consent), also call `AddAAuthDeferredConsent()`, +which registers an in-memory `IDeferredConsentStore`; the mapper then parks the +request, answers `202 Accepted` with a poll `Location`, and resolves it once the +PS's browser consent page records the user's decision. -## Parsing requests +```csharp +builder.Services.AddAAuthGovernance(); +builder.Services.AddAAuthDeferredConsent(); // Prompt → 202 + poll route +``` + +## Mapping the endpoints: `MapAAuthGovernance()` + +`MapAAuthGovernance()` maps the mission, permission, audit, and interaction +endpoints (plus the deferred-consent poll route) onto the registered seams in one +call, mirroring `MapAAuthResource`. It parses each request with +`GovernanceEndpoints`, enforces the `mission_terminated` rule, and delegates the +decision to the seams: + +```csharp +var app = builder.Build(); + +app.MapAAuthGovernance(); // /mission, /permission, /audit, /interaction + poll route + +// Optional: override the default paths. +app.MapAAuthGovernance(o => +{ + o.MissionPath = "/aauth/mission"; + o.PermissionPath = "/aauth/permission"; +}); +``` + +A mission-creation request requires a verified **agent token**; the mapper hands +the proposal to `IMissionApprover`, persists the resulting `StoredMission`, and +emits the `AAuth-Mission` response header. Reach for the manual mapping below only +when an endpoint needs behavior the seams do not express. + +## Parsing requests by hand -`GovernanceEndpoints` maps request bodies to the shared DTOs and emits the -canonical `mission_terminated` response, so endpoints avoid hand-rolled parsing. +When a PS maps its own endpoints, `GovernanceEndpoints` maps request bodies to the +shared DTOs and emits the canonical `mission_terminated` response, so endpoints +avoid hand-rolled parsing. ```csharp using AAuth.Server.Governance; diff --git a/docs/workflows/mission-governed-access.md b/docs/workflows/mission-governed-access.md index bf5b585..d9360fb 100644 --- a/docs/workflows/mission-governed-access.md +++ b/docs/workflows/mission-governed-access.md @@ -52,16 +52,18 @@ The agent states its intent and the tools it wants pre-approved. The PS may run [clarification chat](../advanced/clarification-chat.md) before approving. ```csharp -var governance = new AAuthClientBuilder(key).UseJwt(agentToken).BuildGovernance(); -const string ps = "https://ps.example"; +var governance = new AAuthClientBuilder(key) + .UseJwt(agentToken) + .WithPersonServer("https://ps.example") + .BuildGovernance(); -Mission mission = await governance.Mission.ProposeAsync(ps, +MissionSession session = await governance.ProposeMissionAsync( new MissionProposal("Reconcile this month's expense receipts and email a summary.") { Tools = new[] { new MissionTool("email.send", "Email the reconciliation summary.") }, }); -var missionClaim = new MissionClaim(mission.Approver, mission.S256); +Mission mission = session.Mission; ``` ## 2–3. Access a resource (scope evaluated in context) @@ -73,19 +75,18 @@ mission's intent, the PS grants the auth token silently and remembers the decisi for the rest of the mission. ```csharp +// WithMission emits the AAuth-Mission header on every request and composes with +// the challenge handler, so the whole 401 → exchange → retry leg is automatic. using var client = new AAuthClientBuilder(key) .UseJwt(agentToken) - .WithChallengeHandling(ps) + .WithPersonServer("https://ps.example") + .WithMission(mission) + .WithChallengeHandling() .Build(); -var request = new HttpRequestMessage(HttpMethod.Get, "https://expenses.example/receipts"); -request.Headers.TryAddWithoutValidation( - AAuthMissionHeader.Name, - AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); - // Challenge → exchange → retry happens transparently; the PS judged the scope // against the mission intent during the exchange. -var response = await client.SendAsync(request); +var response = await client.GetAsync("https://expenses.example/receipts"); ``` A later request for a scope the PS has not seen and that does not fit the intent @@ -100,10 +101,11 @@ other action goes to the PS, which prompts the user when it is out of mission. ```csharp // Pre-approved tool → granted silently. -var send = await governance.Permission.RequestAsync(ps, "email.send", mission); +var send = await session.RequestPermissionAsync("email.send"); // Out-of-mission tool → the PS prompts the user (gate 3). -var delete = await governance.Permission.RequestAsync(ps, "files.delete", mission, +var delete = await session.RequestPermissionAsync( + "files.delete", description: "Remove the duplicate receipt the user flagged."); if (!delete.IsGranted) @@ -118,12 +120,10 @@ After acting, the agent reports it. Auditing always carries the mission and is fire-and-forget. ```csharp -await governance.Audit.RecordAsync(ps, - new AuditRecord(missionClaim, "email.send") - { - Description = "Emailed the reconciliation summary to the user.", - Result = new JsonObject { ["recipients"] = 1 }, - }); +await session.RecordAuditAsync( + "email.send", + description: "Emailed the reconciliation summary to the user.", + result: new JsonObject { ["recipients"] = 1 }); ``` ## 6. Close the mission out @@ -132,10 +132,8 @@ When the work is done the agent proposes completion. The user accepts the summar and the PS terminates the mission. ```csharp -bool terminated = await governance.Interaction.ProposeCompletionAsync( - ps, - summary: "Reconciled 24 receipts (2 duplicates removed) and emailed the summary.", - mission: missionClaim); +bool terminated = await session.ProposeCompletionAsync( + "Reconciled 24 receipts (2 duplicates removed) and emailed the summary."); ``` After termination, any further governed request returns `403 mission_terminated`, diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index f061745..bfbf73f 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -329,14 +329,16 @@ internal static class CodeSnippets public const string MissionPreApproved = """ // Pre-approved tools never hit the network — the SDK short-circuits. - var result = await session.RequestPermissionAsync("send_email"); + // We kept the MissionTool reference from the proposal, so we ask via + // tool.ToAction() rather than re-typing the action name. + var result = await session.RequestPermissionAsync(sendEmailTool.ToAction()); // result.IsGranted == true (no PS call: send_email ∈ mission.ApprovedTools) """; public const string MissionPermissionPrompt = """ // delete_inbox is NOT pre-approved → the PS prompts the user. var result = await session.RequestPermissionAsync( - "delete_inbox", + new MissionAction("delete_inbox"), options: new GovernanceOptions { OnInteractionRequired = SurfaceToUser }); // SDK POSTs /permission → 202; surfaces the link; polls for the decision. """; @@ -347,7 +349,7 @@ internal static class CodeSnippets if (!result.IsGranted) throw new InvalidOperationException(result.Reason); // user denied // On grant: run delete_inbox, then report it to the audit_endpoint. - await session.RecordAuditAsync("delete_inbox"); + await session.RecordAuditAsync(new MissionAction("delete_inbox")); """; public const string MissionInspect = """ diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index d4fc74e..4ba0e49 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -3139,7 +3139,8 @@ private void StepMissionPreApprovedTool() "actions reach the PS. (The agent still SHOULD report the action to the " + "`audit_endpoint` afterwards, but that is fire-and-forget, not a gate.)", TokenDecoded = - "PermissionClient.RequestAsync(ps, \"send_email\", mission)\n" + + "// sendEmailTool kept from the mission proposal above\n" + + "session.RequestPermissionAsync(sendEmailTool.ToAction())\n" + " → mission.ApprovedTools contains \"send_email\"\n" + " → PermissionResult { Grant = Granted } (no HTTP)", CodeSnippet = CodeSnippets.MissionPreApproved, diff --git a/samples/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs index 179fe57..dea050c 100644 --- a/samples/MissionAgent/Program.cs +++ b/samples/MissionAgent/Program.cs @@ -2,13 +2,13 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using AAuth; using AAuth.Agent; using AAuth.Agent.Governance; using AAuth.Crypto; @@ -148,7 +148,6 @@ var signedClient = new HttpClient(agentHandler) { Timeout = Timeout.InfiniteTimeSpan }; var metadata = new MetadataClient(new HttpClient()); var governance = new AAuthGovernanceClient(signedClient, metadata, personServer); -var exchange = new TokenExchangeClient(signedClient, metadata); // Tell the mock PS how to resolve prompts. Interactive mode holds each prompt // open until you decide in the browser; --auto resolves via scripted defaults. @@ -184,12 +183,13 @@ await ScriptAsync(new JsonObject // the agent quotes on every later request to bind it to this mission. In // interactive mode the PS shows a browser consent screen here; in --auto mode it // resolves the approval itself. +var sendEmailTool = new MissionTool("send_email", "Send an email on the user's behalf"); var session = await governance.ProposeMissionAsync(new MissionProposal( "Help the user keep their inbox under control for the next hour.") { Tools = new[] { - new MissionTool("send_email", "Send an email on the user's behalf"), + sendEmailTool, new MissionTool("summarize", "Summarize a thread"), }, }, GovernanceFor("Approve this mission and its tools")); @@ -260,21 +260,23 @@ await ScriptAsync(new JsonObject Section("6. Request a permission for a pre-approved tool — silent"); // `send_email` is an approved tool, so the SDK short-circuits to granted -// without ever calling the PS (§Permission Endpoint). -var preApproved = await session.RequestPermissionAsync("send_email"); +// without ever calling the PS (§Permission Endpoint). We still hold the +// sendEmailTool reference from the proposal, so we ask via tool.ToAction() +// rather than re-typing the action name. +var preApproved = await session.RequestPermissionAsync(sendEmailTool.ToAction()); Console.WriteLine($" send_email : {(preApproved.IsGranted ? "granted" : "denied")} ({preApproved.Reason})"); Section("7. Request a permission for a NON-pre-approved tool"); // `delete_inbox` is not an approved tool, so the PS is consulted and the user // is prompted to decide. The session threads the mission claim automatically. var adHoc = await session.RequestPermissionAsync( - "delete_inbox", + new MissionAction("delete_inbox"), options: GovernanceFor("Permission to permanently delete the inbox")); Console.WriteLine($" delete_inbox : {(adHoc.IsGranted ? "granted" : "denied")} ({adHoc.Reason})"); Section("8. Report an action to the audit endpoint"); // After acting, the agent records what it did under the mission (§Audit Endpoint). -await session.RecordAuditAsync("send_email", +await session.RecordAuditAsync(sendEmailTool.ToAction(), description: "Sent a reply to the design-review thread.", result: new JsonObject { ["status"] = "success" }); Console.WriteLine(" recorded send_email = success"); @@ -297,7 +299,7 @@ await session.RecordAuditAsync("send_email", return 0; // --------------------------------------------------------------------------- -// Resource access: challenge -> token exchange (governed by the PS) -> retry. +// Resource access: one mission-aware client handles the whole leg. // --------------------------------------------------------------------------- async Task AccessMissionResourceAsync(string url) { @@ -306,37 +308,25 @@ await session.RecordAuditAsync("send_email", // detection (§HTTP Message Signatures — replay). agentToken = await apClient.RefreshAsync(refreshEndpoint, localKeyHandle); - // 1. Signed request carrying the mission. The signing handler covers the - // aauth-mission header automatically, so the resource can trust it. - var challengeReq = new HttpRequestMessage(HttpMethod.Get, url); - challengeReq.Headers.TryAddWithoutValidation( - AAuthMissionHeader.Name, AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); - using var challenge = await signedClient.SendAsync(challengeReq); - if (challenge.StatusCode != HttpStatusCode.Unauthorized) - { - throw new InvalidOperationException( - $"Expected 401 challenge from the resource, got {(int)challenge.StatusCode}."); - } - - // 2. Parse the AAuth-Requirement header to recover the resource token. - var requirement = string.Join(", ", challenge.Headers.GetValues(AAuthRequirementHeader.Name)); - var parsed = AAuthRequirementHeader.Parse(requirement); - var resourceToken = parsed.ResourceToken - ?? throw new InvalidOperationException("Challenge did not carry a resource token."); - - // 3. Exchange the resource token at the PS. The PS reads the mission claim - // embedded in the (verified) resource token and applies the token gate; - // an out-of-scope request returns 202 and we print the consent URL. - var authToken = await exchange.ExchangeAsync(personServer, resourceToken, new TokenExchangeRequest - { - OnInteractionRequired = PromptUserAsync, - PollerOptions = poller, - }); + // One mission-aware client does the whole resource-access leg: + // • WithMission emits the AAuth-Mission header, which the signing handler + // covers as the aauth-mission component (§Mission Context at Resources); + // • WithChallengeHandling drives the 401 -> token-exchange -> retry cycle + // and surfaces any out-of-scope consent prompt via OnInteractionRequired. + // An out-of-scope exchange the user denies throws + // AAuthInteractionDeniedException, exactly as the manual flow did. + using var client = new AAuthClientBuilder(key) + .UseJwt(() => agentToken) + .WithPersonServer(personServer) + .WithMission(mission) + .WithChallengeHandling(o => + { + o.OnInteractionRequired = PromptUserAsync; + o.PollingTimeout = poller.MaxTotalWait; + }) + .Build(); - // 4. Replay the request with the auth token to obtain the protected resource. - var authHandler = new AAuthSigningHandler(key, () => authToken) { InnerHandler = new HttpClientHandler() }; - using var authClient = new HttpClient(authHandler); - using var ok = await authClient.GetAsync(url); + using var ok = await client.GetAsync(url); ok.EnsureSuccessStatusCode(); return await ok.Content.ReadFromJsonAsync(); } diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index dd2e69c..a0ae272 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -1274,6 +1274,29 @@ await log.AppendAsync(new MissionLogEntry( return Results.Ok(new { ok = true, s256, mission_status = "terminated" }); }); +// DEMO-ONLY: read a mission's ordered log/trail by its s256 (§Mission Log). The +// PS holds the authoritative record of every governed step under the mission — +// tokens, permissions, clarifications, audits, interactions. Samples surface +// this to show the auditable trail a mission accrues. Returns { s256, entries:[…] }. +app.MapGet("/admin/mission-log/{s256}", async (string s256, IMissionLog log) => +{ + var entries = await log.ReadAsync(s256); + return Results.Ok(new + { + s256, + entries = entries.Select(e => new + { + kind = e.Kind.ToString().ToLowerInvariant(), + timestamp = e.Timestamp, + resource = e.Resource, + scope = e.Scope, + action = e.Action, + granted = e.Granted, + detail = e.Detail, + }).ToArray(), + }); +}); + // User-facing interaction page. The 202 from `POST /token` told the // agent's user to visit this URL with `?code={pending-id}`. In a real PS // this page would be behind the user's signed-in browser session diff --git a/samples/Orchestrator/PendingStore.cs b/samples/Orchestrator/PendingStore.cs index b26af0a..d4da536 100644 --- a/samples/Orchestrator/PendingStore.cs +++ b/samples/Orchestrator/PendingStore.cs @@ -24,19 +24,31 @@ public sealed record Entry( string Id, string UpstreamToken, string InteractionUrl, - string InteractionCode); + string InteractionCode, + string DownstreamPath, + string PendingPrefix); private readonly ConcurrentDictionary _entries = new(); /// /// Create a pending entry capturing the upstream auth token (used to /// re-drive the chained call on each poll) and the pass-through PS - /// interaction url + code. + /// interaction url + code. + /// is the downstream resource path re-driven on each poll (e.g. /jwt + /// or the mission-aware /jwt/mission); + /// is the caller-facing poll route prefix (e.g. /pending or + /// /mission-pending). /// - public Entry Add(string upstreamToken, string interactionUrl, string interactionCode) + public Entry Add( + string upstreamToken, + string interactionUrl, + string interactionCode, + string downstreamPath = "/jwt", + string pendingPrefix = "/pending") { var id = Guid.NewGuid().ToString("N"); - var entry = new Entry(id, upstreamToken, interactionUrl, interactionCode); + var entry = new Entry( + id, upstreamToken, interactionUrl, interactionCode, downstreamPath, pendingPrefix); _entries[id] = entry; return entry; } diff --git a/samples/Orchestrator/Program.cs b/samples/Orchestrator/Program.cs index b8abadf..7948296 100644 --- a/samples/Orchestrator/Program.cs +++ b/samples/Orchestrator/Program.cs @@ -92,6 +92,12 @@ ResourceKeyId = OrchestratorKid, ResourceIdentifier = orchestratorUrl, DefaultScopes = OrchestratorScope, + // Mission-aware: when an AAuth-Mission header is present, copy the + // {approver, s256} into the resource token so the PS governs the + // agent→orchestrator exchange under the mission (§Mission Context at + // Resources). A no-op when no mission header is present, so the plain + // call chain ("/" → "/jwt") is unaffected. + MissionAware = true, })); // ----------------------------------------------------------------------- @@ -114,19 +120,27 @@ // the combined chain result on success; throws AAuthInteractionChainedException // when the downstream PS defers for user consent, or // AAuthInteractionDeniedException when the user denied. -async Task RunChainAsync(HttpContext ctx, string upstreamToken) +// Run the downstream chained call with the given upstream auth token. Returns +// the combined chain result on success; throws AAuthInteractionChainedException +// when the downstream PS defers for user consent, or +// AAuthInteractionDeniedException when the user denied. +// selects the downstream resource path — "/jwt" for the plain chain or the +// mission-aware "/jwt/mission" for a mission-governed chain (§Mission Context at +// Resources). When the upstream auth token carries a mission, WithCallChaining +// auto-forwards the AAuth-Mission header (via MissionForwardingHandler) and routes +// the exchange to mission.approver, so the mission governs every hop (§Call Chaining). +async Task RunChainAsync(HttpContext ctx, string upstreamToken, string downstreamPath) { // Self-issued agent token (iss = orchestratorUrl) satisfies §Upstream Token // Verification step 3 — the PS can match upstream_token.aud against iss. // - // Mission governance is OPTIONAL and orthogonal to call chaining - // (AAuth §Agent Governance). This demo intentionally leaves it out: the - // upstream auth token carries no `mission.approver`, so the downstream - // exchange follows §Call Chaining's "No mission, iss is a PS" path — the - // PS evaluates the hop without mission context. If a mission WERE present, - // WithCallChaining would auto-forward the `AAuth-Mission` header (via - // MissionForwardingHandler) and route to mission.approver instead; no - // change to this handler would be needed. + // Mission governance composes with call chaining (AAuth §Agent Governance, + // §Call Chaining): if the upstream auth token carries `mission.approver`, + // WithCallChaining auto-forwards the `AAuth-Mission` header (via + // MissionForwardingHandler) and routes to mission.approver. The mission-aware + // downstream path then re-binds the mission into the next resource_token, so a + // single mission governs the whole chain. With no mission present this same + // handler follows §Call Chaining's "No mission, iss is a PS" path unchanged. using var downstream = AAuthClientBuilder.SelfIssuing(orchestratorKey) .As(orchestratorUrl, agentId) .WithKid(OrchestratorKid) @@ -144,7 +158,7 @@ async Task RunChainAsync(HttpContext ctx, string upstreamToken) }) .Build(); - var response = await downstream.GetAsync($"{downstreamUrl.TrimEnd('/')}/jwt"); + var response = await downstream.GetAsync($"{downstreamUrl.TrimEnd('/')}{downstreamPath}"); var body = await response.Content.ReadAsStringAsync(); JsonNode? downstreamJson = null; try { downstreamJson = JsonNode.Parse(body); } catch { } @@ -169,11 +183,12 @@ async Task RunChainAsync(HttpContext ctx, string upstreamToken) } // Re-emit the Orchestrator's own 202 requirement=interaction for a parked -// chained request: its own Location (the pending URL), the PS's pass-through -// interaction url/code. Spec §Interaction Chaining + §Deferred Responses. +// chained request: its own Location (the pending URL, keyed by the entry's +// poll-route prefix), the PS's pass-through interaction url/code. Spec +// §Interaction Chaining + §Deferred Responses. IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) { - ctx.Response.Headers.Location = $"/pending/{entry.Id}"; + ctx.Response.Headers.Location = $"{entry.PendingPrefix}/{entry.Id}"; ctx.Response.Headers["Retry-After"] = "1"; ctx.Response.Headers["Cache-Control"] = "no-store"; ctx.Response.Headers[AAuthRequirementHeader.Name] = @@ -193,7 +208,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) try { - return await RunChainAsync(ctx, upstreamToken); + return await RunChainAsync(ctx, upstreamToken, "/jwt"); } catch (AAuthInteractionChainedException ex) { @@ -203,6 +218,33 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) } }); +// GET /mission — the mission-governed twin of "/". Identical chaining, but the +// downstream hop targets WhoAmI's mission-aware "/jwt/mission" so a mission +// present in the upstream auth token is forwarded and re-bound at each hop +// (§Mission Context at Resources, §Call Chaining). +app.MapGet("/mission", async (HttpContext ctx, PendingStore pending) => +{ + var upstreamToken = ctx.Features.Get()?.Token; + if (string.IsNullOrEmpty(upstreamToken)) + { + return Results.Json( + new { error = "invalid_request", detail = "missing upstream auth token" }, + statusCode: StatusCodes.Status401Unauthorized); + } + + try + { + return await RunChainAsync(ctx, upstreamToken, "/jwt/mission"); + } + catch (AAuthInteractionChainedException ex) + { + var entry = pending.Add( + upstreamToken, ex.Interaction.Url, ex.Interaction.Code, + downstreamPath: "/jwt/mission", pendingPrefix: "/mission-pending"); + return ReEmitChainedInteraction(ctx, entry); + } +}); + // ----------------------------------------------------------------------- // GET /pending/{id} — the caller polls here while its user approves the // downstream consent at the PS interaction page. Signed + auth-token gated by @@ -224,7 +266,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) try { - var result = await RunChainAsync(ctx, entry.UpstreamToken); + var result = await RunChainAsync(ctx, entry.UpstreamToken, entry.DownstreamPath); pending.Remove(id); // resolved — drop the parked entry return result; } @@ -244,6 +286,37 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) } }); +// GET /mission-pending/{id} — the mission chain's poll route. Identical to +// "/pending/{id}" but for entries whose downstream hop is the mission-aware +// "/jwt/mission" (each poll re-drives RunChainAsync with the stored path). +app.MapGet("/mission-pending/{id}", async (HttpContext ctx, string id, PendingStore pending) => +{ + var entry = pending.Get(id); + if (entry is null) + { + return Results.NotFound(new { error = "unknown_pending", id }); + } + + try + { + var result = await RunChainAsync(ctx, entry.UpstreamToken, entry.DownstreamPath); + pending.Remove(id); + return result; + } + catch (AAuthInteractionChainedException) + { + return ReEmitChainedInteraction(ctx, entry); + } + catch (AAuthInteractionDeniedException) + { + pending.Remove(id); + ctx.Response.Headers["Cache-Control"] = "no-store"; + return Results.Json( + new { error = "access_denied", detail = "the user denied this request" }, + statusCode: StatusCodes.Status403Forbidden); + } +}); + app.Run(); // Marker type for WebApplicationFactory in tests. diff --git a/samples/SampleApp/Components/Layout/NavMenu.razor b/samples/SampleApp/Components/Layout/NavMenu.razor index 142cb56..5c3dd68 100644 --- a/samples/SampleApp/Components/Layout/NavMenu.razor +++ b/samples/SampleApp/Components/Layout/NavMenu.razor @@ -61,6 +61,12 @@ Mission (PS-Governed) + + diff --git a/samples/SampleApp/Components/Layout/NavMenu.razor.css b/samples/SampleApp/Components/Layout/NavMenu.razor.css index 7d15a88..75ce465 100644 --- a/samples/SampleApp/Components/Layout/NavMenu.razor.css +++ b/samples/SampleApp/Components/Layout/NavMenu.razor.css @@ -70,6 +70,10 @@ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E%3Cpath d='M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'/%3E%3Cpath d='M8 9a1 1 0 1 1 0-2 1 1 0 0 1 0 2z'/%3E%3C/svg%3E"); } +.bi-signpost-split-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M7 16h2V6h5a1 1 0 0 0 .8-.4l.975-1.3a.5.5 0 0 0 0-.6L14.8 2.4A1 1 0 0 0 14 2H9v-.586a1 1 0 0 0-2 0V3H2a1 1 0 0 0-.8.4L.225 4.7a.5.5 0 0 0 0 .6L1.2 6.6a1 1 0 0 0 .8.4h5v3H2a1 1 0 0 0-.8.4l-.975 1.3a.5.5 0 0 0 0 .6l.975 1.3a1 1 0 0 0 .8.4h5z'/%3E%3C/svg%3E"); +} + .nav-item { font-size: 0.9rem; padding-bottom: 0.5rem; diff --git a/samples/SampleApp/Components/Pages/Home.razor b/samples/SampleApp/Components/Pages/Home.razor index 4ab7785..2b5e557 100644 --- a/samples/SampleApp/Components/Pages/Home.razor +++ b/samples/SampleApp/Components/Pages/Home.razor @@ -162,6 +162,26 @@ + +
+
+
+
🧩 Mission Call Chain — clarification + delegation
+

+ One mission governs three seams at once: a clarification chat + on an out-of-mission scope, a mission-forwarded call chain + (Agent → Orchestrator → WhoAmI), and the PS-held mission log. +

+ multi-hop + sig=jwt + governance + clarification +
+ +
+

Prerequisites

diff --git a/samples/SampleApp/Components/Pages/Mission.razor b/samples/SampleApp/Components/Pages/Mission.razor index 0af2e8a..3571bed 100644 --- a/samples/SampleApp/Components/Pages/Mission.razor +++ b/samples/SampleApp/Components/Pages/Mission.razor @@ -50,22 +50,25 @@ var handler = new AAuthSigningHandler( identity.Key, () => agentToken) { InnerHandler = new HttpClientHandler() }; var signed = new HttpClient(handler); -var governance = new AAuthGovernanceClient(signed, metadata, personServer); +// Bind the PS once; the bound client returns a MissionSession that +// auto-threads the mission claim {approver, s256} into every later call. +var governance = new AAuthGovernanceClient(signed, metadata, personServer, governanceOptions); // 1. Propose a mission (PROMPT — the human approves intent + tools). // A proposal carries a Markdown description + an optional `tools` // list ONLY. It declares NO scopes — scopes are never pre-listed // on a mission (§Mission Creation). -var mission = await governance.Mission.ProposeAsync( +var sendEmailTool = new MissionTool("send_email", "Send email"); +var session = await governance.ProposeMissionAsync( new MissionProposal("Keep the inbox under control for an hour.") { Tools = new[] { - new MissionTool("send_email", "Send email"), + sendEmailTool, new MissionTool("summarize", "Summarize a thread"), }, - }, - governanceOptions); + }); +var mission = session.Mission; // session threads the claim + PS for you // 2. Access a mission-aware resource (SILENT — the PS judges the scope // fits the mission intent). The client calls the resource directly; @@ -106,14 +109,13 @@ var elevated = await ExchangeForScopeAsync( $"{resourceOrigin}/protected_endpoint/elevated", mission, governanceOptions); // 4. Permission for a pre-approved tool (SILENT — no PS call). -var sendEmail = await governance.Permission.RequestAsync( - "send_email", mission); - -// 5. Permission for a non-approved tool (PROMPT — the PS asks). -var deleteInbox = await governance.Permission.RequestAsync( - new PermissionRequest("delete_inbox") - { Mission = new MissionClaim(mission.Approver, mission.S256) }, - governanceOptions); +// We still hold the sendEmailTool reference from the proposal above. +var sendEmail = await session.RequestPermissionAsync(sendEmailTool.ToAction()); + +// 5. Permission for a non-approved tool (PROMPT — the PS asks). The +// session injects the mission claim and the bound options. +var deleteInbox = await session.RequestPermissionAsync( + new MissionAction("delete_inbox"));
Resource — protecting the action with a scope
@@ -186,13 +188,9 @@ app.MapGet("/protected_endpoint/elevated", (HttpContext ctx) => if (deleteInbox.IsGranted) { await DeleteInboxAsync(); // your own code / MCP tool call - await governance.Audit.RecordAsync( - new AuditRecord( - new MissionClaim(mission.Approver, mission.S256), - "delete_inbox") - { - Result = new JsonObject { ["deleted"] = true }, - }); + await session.RecordAuditAsync( + new MissionAction("delete_inbox"), + result: new JsonObject { ["deleted"] = true }); }
@@ -371,8 +369,6 @@ if (deleteInbox.IsGranted) { InnerHandler = new HttpClientHandler() }; using var signed = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; var metadata = new MetadataClient(new HttpClient()); - var governance = new AAuthGovernanceClient(signed, metadata, personServer); - var exchange = new TokenExchangeClient(signed, metadata); var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(2) }; GovernanceOptions GovernanceFor() => new() @@ -381,17 +377,27 @@ if (deleteInbox.IsGranted) PollerOptions = poller, }; + // Bind the PS and the default deferred-handling options once. The + // bound client hands back a MissionSession that auto-threads the + // mission claim {approver, s256} into every later governed call. + var governance = new AAuthGovernanceClient(signed, metadata, personServer, GovernanceFor()); + var exchange = new TokenExchangeClient(signed, metadata); + // ---- Gate 1: propose the mission (PROMPT) ---------------------- _currentGate = "Mission creation"; - var mission = await governance.Mission.ProposeAsync(new MissionProposal( + var sendEmailTool = new MissionTool("send_email", "Send an email on the user's behalf"); + var session = await governance.ProposeMissionAsync(new MissionProposal( "Help the user keep their inbox under control for the next hour.") { Tools = new[] { - new MissionTool("send_email", "Send an email on the user's behalf"), + sendEmailTool, new MissionTool("summarize", "Summarize a thread"), }, - }, GovernanceFor()); + }); + // The session wraps the approved mission and threads its claim + PS + // into every subsequent permission / audit / interaction call. + var mission = session.Mission; ClearWaiting(); _steps.Add(new GateStep(1, "Mission creation", Prompted: true, Outcome: "approved", @@ -456,8 +462,10 @@ if (deleteInbox.IsGranted) await InvokeAsync(StateHasChanged); // ---- Gate 4: permission for a pre-approved tool (SILENT) -------- - var sendEmail = await governance.Permission.RequestAsync( - "send_email", mission); + // We still hold the sendEmailTool reference from Gate 1, so we ask + // via tool.ToAction() rather than re-typing the action name. The + // session injects the mission claim automatically. + var sendEmail = await session.RequestPermissionAsync(sendEmailTool.ToAction()); _steps.Add(new GateStep(4, "Permission send_email", Prompted: false, Outcome: sendEmail.IsGranted ? "granted" : "denied", Summary: "Pre-approved tool — resolved locally without a PS round-trip.", @@ -471,10 +479,11 @@ if (deleteInbox.IsGranted) _currentGate = "Permission delete_inbox"; try { - var deleteInbox = await governance.Permission.RequestAsync( - new PermissionRequest("delete_inbox") - { Mission = new MissionClaim(mission.Approver, mission.S256) }, - GovernanceFor()); + // Not a pre-approved tool: the session threads the mission claim + // and the bound deferred-handling options, so the PS prompt is + // surfaced automatically. + var deleteInbox = await session.RequestPermissionAsync( + new MissionAction("delete_inbox")); ClearWaiting(); _steps.Add(new GateStep(5, "Permission delete_inbox", Prompted: true, Outcome: deleteInbox.IsGranted ? "granted" : "denied", diff --git a/samples/SampleApp/Components/Pages/MissionCallChain.razor b/samples/SampleApp/Components/Pages/MissionCallChain.razor new file mode 100644 index 0000000..c3ee853 --- /dev/null +++ b/samples/SampleApp/Components/Pages/MissionCallChain.razor @@ -0,0 +1,586 @@ +@page "/mission-call-chain" +@using System.Net +@using System.Net.Http.Json +@using System.Text.Json.Nodes +@using AAuth +@using AAuth.Agent +@using AAuth.Agent.Governance +@using AAuth.Crypto +@using AAuth.Discovery +@using AAuth.Headers +@using AAuth.HttpSig +@using AAuth.Tokens +@inject IJSRuntime JS +@rendermode InteractiveServer + +Mission Call Chain — clarification + governed delegation + +

Mission Call Chain — one mission governs a clarification chat and a delegated chain

+ +

+ This page joins the gates of the Mission demo with the hops of + the Call Chain demo, combining three AAuth capabilities under a + single human-approved mission: +

+
    +
  1. + Clarification chat (§Clarification Chat) — accessing an out-of-mission + elevated scope, the Person Server first asks the agent a question; the SDK surfaces it + through ChallengeHandlingOptions.OnClarificationRequired (and the lower-level + TokenExchangeRequest.OnClarificationRequired), the agent answers, then the + user approves. +
  2. +
  3. + Mission-forwarded call chain (§Mission Context at Resources, §Call Chaining) — + the same mission is carried (WithMission(...)) to the Orchestrator, which forwards + the AAuth-Mission header to the downstream WhoAmI hop so the mission governs every hop. +
  4. +
  5. + Mission log (§Mission Log) — the PS-held, ordered trail of every governed + step the mission accrued is fetched and shown at the end. +
  6. +
+ +
+ The elevated scope is not seeded in the mission's intent, so the PS runs a clarification + round before prompting. The orchestrate + whoami scopes are seeded in-scope, so the chain + hops resolve silently — keeping the chain focused on mission forwarding, not consent. + Each step is labelled prompt (the user must decide) + or silent (resolved without a screen). +
+ +
+
+
Client Code — the clarification callback
+
// One mission, threaded through every step by the bound session.
+var governance = new AAuthGovernanceClient(signed, metadata, personServer, options);
+var session = await governance.ProposeMissionAsync(
+    new MissionProposal("Keep the inbox under control for an hour."));
+var mission = session.Mission;
+
+// Out-of-mission elevated scope: the PS asks a question first. The SDK
+// surfaces it via OnClarificationRequired; the agent answers (or updates /
+// cancels), then the normal interaction approval runs (§Clarification Chat).
+var authToken = await exchange.ExchangeAsync(personServer, resourceToken,
+    new TokenExchangeRequest
+    {
+        OnInteractionRequired = SurfaceInteractionAsync,
+        OnClarificationRequired = (question, ct) =>
+        {
+            // question.Clarification is UNTRUSTED Markdown — render it
+            // sanitized (Blazor HTML-encodes it) before showing the user.
+            SurfaceClarification(question.Clarification);
+            return Task.FromResult(ClarificationResponse.Respond(
+                "This mission needs the full account history to triage the inbox."));
+        },
+    });
+
+
+
Client Code — the mission-forwarded chain
+
// Carry the SAME mission to the Orchestrator. WithMission attaches the
+// signed AAuth-Mission header; the mission-aware Orchestrator copies it
+// into its resource token, so the agent->orchestrator exchange is governed
+// by the mission. The Orchestrator then forwards the mission to its own
+// downstream hop (§Mission Context at Resources, §Call Chaining).
+using var chain = AAuthClientBuilder.SelfIssuing(identity.Key)
+    .As(identity.Issuer, identity.AgentId)
+    .WithKid(identity.KeyId)
+    .WithPersonServer(personServer)
+    .WithMission(mission)
+    .WithChallengeHandling(o => o.OnInteractionRequired =
+        (i, ct) => SurfaceInteractionAsync(i, ct))
+    .WithInteractionHandling(o => o.OnInteractionRequired =
+        (url, code, ct) => SurfaceInteraction(url))
+    .Build();
+
+// "/mission" is the Orchestrator's mission-aware twin of "/": it chains to
+// WhoAmI's "/jwt/mission" so the forwarded mission re-binds at the next hop.
+var response = await chain.GetAsync($"{orchestrator}/mission");
+
+
+ +
+ Self-issued agent identity. Key ID: @_identity.KeyId, + agent: @_identity.AgentId. +
+ +@if (!_running && _steps.Count == 0) +{ + +

+ + Two prompts in a new tab (mission creation, then the elevated scope after a + clarification round); the call chain then resolves silently. + +

+} + +@if (_running) +{ + +} + +@if (_clarification is not null) +{ + @* The clarification text is UNTRUSTED PS input. Razor HTML-encodes it here, + so no markup from the PS can reach the DOM (§Markdown — sanitize before + rendering). *@ +
+
Clarification chat (§Clarification Chat)
+

PS asked: @_clarification

+

Agent answered: @_clarificationAnswer

+
+} + +@if (_steps.Count > 0) +{ +
+
Steps
+ @foreach (var s in _steps) + { +
+
+ Step @s.Number — @s.Title + + @if (s.Prompted) + { + prompt + } + else + { + silent + } + @s.Outcome + +
+
+

@s.Summary

+ @if (s.Payload is not null) + { +
@s.PayloadLabel
+
@s.Payload
+ } +
+
+ } +
+} + +@if (_waitingForApproval) +{ +
+
User Approval Required — @_currentGate
+

+ This step fell outside the mission, so the Person Server returned + 202 Accepted and is waiting for the user to approve before the flow continues. +

+

+ Interaction URL:
+ @_interactionUrl +

+

+ Open the link in a new tab and click Approve. The SDK is polling… + +

+
+} + +@if (_missionLog is not null) +{ +
+
Mission log (§Mission Log) — held at the Person Server
+

+ The PS keeps the authoritative, ordered trail of every governed step under the mission. +

+ + + + + + @{ var i = 1; } + @foreach (var e in _missionLog) + { + + + + + + + + + i++; + } + +
#KindResource / ActionScopeGrantedDetail
@i@e.Kind@(e.Resource ?? e.Action ?? "—")@(e.Scope ?? "—")@(e.Granted?.ToString() ?? "—")@(e.Detail ?? "—")
+
+} + +@if (_error is not null) +{ +
@_error
+} + +@code { + [Inject] private SelfIssuedIdentity _identity { get; set; } = default!; + [Inject] private IConfiguration Config { get; set; } = default!; + + private readonly List _steps = new(); + private bool _running; + private bool _waitingForApproval; + private string? _currentGate; + private string? _interactionUrl; + private string? _clarification; + private string? _clarificationAnswer; + private IReadOnlyList? _missionLog; + private string? _error; + + private string _agentToken = string.Empty; + + private sealed record Step( + int Number, + string Title, + bool Prompted, + string Outcome, + string Summary, + string? Payload = null, + string PayloadLabel = "", + string PayloadLang = "json"); + + private sealed record LogEntry( + string Kind, string? Resource, string? Scope, string? Action, bool? Granted, string? Detail); + + private static string OutcomeClass(string outcome) => outcome switch + { + "denied" => "bg-danger", + _ => "bg-success", + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("highlightCode"); + } + + private async Task RunFlow() + { + _running = true; + _steps.Clear(); + _error = null; + _clarification = null; + _clarificationAnswer = null; + _missionLog = null; + ClearWaiting(); + + var personServer = Config["AAuth:PersonServer"]!.TrimEnd('/'); + var resourceOrigin = Config["AAuth:Resource"]!.TrimEnd('/'); + var orchestratorUrl = Config["AAuth:Orchestrator"]!.TrimEnd('/'); + var elevatedUrl = $"{resourceOrigin}/jwt/mission/elevated"; + + try + { + // Script the PS: interactive browser decisions, a clarification round + // on out-of-scope tokens, and BOTH chain scopes seeded in-scope so the + // chain hops resolve silently. The in-scope set is captured per mission + // at approval, so it must be seeded BEFORE proposing. + await ConfigurePersonServerAsync(personServer, resourceOrigin, orchestratorUrl); + + _agentToken = MintAgentToken(personServer); + var handler = new AAuthSigningHandler(_identity.Key, () => _agentToken) + { InnerHandler = new HttpClientHandler() }; + using var signed = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + var metadata = new MetadataClient(new HttpClient()); + var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(2) }; + + GovernanceOptions GovernanceFor() => new() + { + OnInteractionRequired = SurfaceInteractionAsync, + PollerOptions = poller, + }; + + var governance = new AAuthGovernanceClient(signed, metadata, personServer, GovernanceFor()); + var exchange = new TokenExchangeClient(signed, metadata); + + // ---- Step 1: propose the mission (PROMPT) ---------------------- + _currentGate = "Mission creation"; + var session = await governance.ProposeMissionAsync(new MissionProposal( + "Help the user keep their inbox under control for the next hour.") + { + Tools = new[] { new MissionTool("summarize", "Summarize a thread") }, + }); + var mission = session.Mission; + ClearWaiting(); + _steps.Add(new Step(1, "Mission creation", Prompted: true, Outcome: "approved", + Summary: $"The user approved the mission's intent and {mission.ApprovedTools.Count} tool(s). " + + "The mission lists NO scopes — scopes are evaluated later against its intent.", + Payload: MissionPayload(mission), + PayloadLabel: "Mission approval (held at the PS)")); + await InvokeAsync(StateHasChanged); + + // ---- Step 2: elevated scope via a clarification round (PROMPT) - + _currentGate = "Elevated scope (with clarification)"; + var elevated = await AccessElevatedWithClarificationAsync( + signed, exchange, personServer, elevatedUrl, mission, poller); + ClearWaiting(); + _steps.Add(new Step(2, "Elevated scope + clarification", Prompted: true, Outcome: "granted", + Summary: "The elevated scope falls outside the mission's intent, so the PS asked a " + + "clarification question first; the agent answered, the user approved, and the " + + $"auth_token was minted. scope={elevated?["scope"]?.ToJsonString()}", + Payload: Pretty(elevated), + PayloadLabel: "Elevated mission-aware resource response (auth_token claims)")); + await InvokeAsync(StateHasChanged); + + // ---- Step 3: mission-forwarded call chain (SILENT) ------------- + _currentGate = "Mission call chain"; + var chainJson = await RunMissionChainAsync(personServer, orchestratorUrl, mission); + _steps.Add(new Step(3, "Mission call chain", Prompted: false, Outcome: "granted", + Summary: "The same mission was carried to the Orchestrator (WithMission), which forwarded " + + "the AAuth-Mission header to its downstream WhoAmI hop. Both hops were in-scope, " + + "so the chain resolved without a prompt — the mission governed every hop.", + Payload: chainJson, + PayloadLabel: "Chain result (Agent → Orchestrator → WhoAmI /jwt/mission)")); + await InvokeAsync(StateHasChanged); + + // ---- Mission log: the PS-held trail the mission accrued -------- + _missionLog = await FetchMissionLogAsync(personServer, mission.S256); + await InvokeAsync(StateHasChanged); + } + catch (AAuthInteractionDeniedException) + { + _error = "The user denied a consent request."; + } + catch (AAuthInteractionTimeoutException) + { + _error = "Timed out waiting for user approval (polling budget expired)."; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + ClearWaiting(); + _running = false; + } + } + + private string MintAgentToken(string personServer) => new AgentTokenBuilder + { + Issuer = _identity.Issuer, + Subject = _identity.AgentId, + KeyId = _identity.KeyId, + Key = _identity.Key, + PersonServer = personServer, + Lifetime = TimeSpan.FromHours(1), + }.Build(); + + // Challenge -> token exchange (with a clarification round) -> retry. The + // clarification callback surfaces the (sanitized) question and answers it; + // the PS then prompts the user for the out-of-mission scope. + private async Task AccessElevatedWithClarificationAsync( + HttpClient signed, + TokenExchangeClient exchange, + string personServer, + string resourceUrl, + AAuth.Agent.Mission mission, + DeferredPollerOptions poller) + { + _agentToken = MintAgentToken(personServer); + + var challengeReq = new HttpRequestMessage(HttpMethod.Get, resourceUrl); + challengeReq.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256)); + using var challenge = await signed.SendAsync(challengeReq); + if (challenge.StatusCode != HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException( + $"Expected 401 challenge from the resource, got {(int)challenge.StatusCode}."); + } + + var requirement = string.Join(", ", challenge.Headers.GetValues(AAuthRequirementHeader.Name)); + var resourceToken = AAuthRequirementHeader.Parse(requirement).ResourceToken + ?? throw new InvalidOperationException("Challenge did not carry a resource token."); + + var authToken = await exchange.ExchangeAsync(personServer, resourceToken, new TokenExchangeRequest + { + OnInteractionRequired = SurfaceInteractionAsync, + // The clarification callback: the PS asks a question during review. + OnClarificationRequired = (question, ct) => + { + // question.Clarification is UNTRUSTED Markdown from the PS. We + // store it for HTML-encoded (sanitized) rendering only — never as + // raw markup (§Markdown — sanitize before rendering). + _clarificationAnswer = + "This mission needs the full account history to triage the inbox."; + SurfaceClarification(question.Clarification); + return Task.FromResult(ClarificationResponse.Respond(_clarificationAnswer)); + }, + PollerOptions = poller, + }); + + var authHandler = new AAuthSigningHandler(_identity.Key, () => authToken) + { InnerHandler = new HttpClientHandler() }; + using var authClient = new HttpClient(authHandler); + using var ok = await authClient.GetAsync(resourceUrl); + ok.EnsureSuccessStatusCode(); + return await ok.Content.ReadFromJsonAsync(); + } + + // Carry the mission to the Orchestrator's mission-aware "/mission" endpoint. + // WithMission attaches the signed AAuth-Mission header; the mission-aware + // Orchestrator copies it into its resource token (so the exchange is governed + // by the mission) and forwards it to the downstream WhoAmI hop. + private async Task RunMissionChainAsync( + string personServer, string orchestratorUrl, AAuth.Agent.Mission mission) + { + using var chain = AAuthClientBuilder.SelfIssuing(_identity.Key) + .As(_identity.Issuer, _identity.AgentId) + .WithKid(_identity.KeyId) + .WithPersonServer(personServer) + .WithMission(mission) + .WithChallengeHandling(opts => + { + opts.OnInteractionRequired = (interaction, ct) => + SurfaceInteractionAsync(interaction, ct); + opts.PollingTimeout = TimeSpan.FromMinutes(2); + opts.DefaultPollInterval = TimeSpan.FromSeconds(1); + }) + .WithInteractionHandling(opts => + { + opts.OnInteractionRequired = (userUrl, code, ct) => SurfaceInteraction(userUrl, ct); + opts.PollingTimeout = TimeSpan.FromMinutes(2); + opts.DefaultPollInterval = TimeSpan.FromSeconds(1); + }) + .Build(); + + using var response = await chain.GetAsync($"{orchestratorUrl}/mission"); + var raw = await response.Content.ReadAsStringAsync(); + try + { + return System.Text.Json.JsonSerializer.Serialize( + System.Text.Json.JsonDocument.Parse(raw), + new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + catch { return raw; } + } + + private async Task> FetchMissionLogAsync(string personServer, string s256) + { + using var http = new HttpClient(); + try + { + var doc = await http.GetFromJsonAsync( + $"{personServer}/admin/mission-log/{Uri.EscapeDataString(s256)}"); + var entries = doc?["entries"] as JsonArray ?? new JsonArray(); + var list = new List(); + foreach (var e in entries.OfType()) + { + list.Add(new LogEntry( + (string?)e["kind"] ?? "?", + (string?)e["resource"], + (string?)e["scope"], + (string?)e["action"], + (bool?)e["granted"], + (string?)e["detail"])); + } + return list; + } + catch + { + return Array.Empty(); + } + } + + private async Task SurfaceClarificationAsync(string question) + { + _clarification = question; + await InvokeAsync(StateHasChanged); + } + + // Synchronous wrapper used inside the clarification callback (which is not + // awaited for the UI update — the SDK continues immediately). + private void SurfaceClarification(string question) + => _ = SurfaceClarificationAsync(question); + + private async Task SurfaceInteractionAsync(Interaction interaction, CancellationToken ct) + => await SurfaceInteraction(interaction.BuildUserUrl(), ct); + + private async Task SurfaceInteraction(string userUrl, CancellationToken ct) + { + _interactionUrl = userUrl; + _waitingForApproval = true; + // Surface the approval banner with a single render. We deliberately do + // NOT spin up a per-second "poll count" timer here: while the user is in + // the approval popup the main tab is backgrounded, and a background timer + // calling StateHasChanged every second floods Blazor Server's + // unacknowledged render-batch buffer (the throttled background tab stops + // ACKing), which pauses ALL rendering — including the result card the + // flow renders once approval completes. A static spinner conveys the same + // "polling" state without the render-batch flood. + await InvokeAsync(StateHasChanged); + } + + private void ClearWaiting() + { + _waitingForApproval = false; + _interactionUrl = null; + } + + private async Task ConfigurePersonServerAsync( + string personServer, string resourceOrigin, string orchestratorUrl) + { + using var http = new HttpClient(); + try + { + await http.PostAsJsonAsync($"{personServer}/admin/reset", new { }); + await http.PostAsJsonAsync($"{personServer}/admin/mission-script", new + { + reset = true, + interactive = true, + approveMission = true, + approveToken = true, + approvePermission = true, + requireClarification = true, + clarificationQuestion = + "Why does this mission need elevated access to your full account history?", + inScope = new[] + { + new { resource = orchestratorUrl, scope = "orchestrate" }, + new { resource = resourceOrigin, scope = "whoami" }, + }, + }); + } + catch + { + // Admin endpoints only exist on MockPersonServer — swallow errors. + } + } + + private static string MissionPayload(AAuth.Agent.Mission m) + { + var tools = new JsonArray(); + foreach (var t in m.ApprovedTools) + { + tools.Add(new JsonObject { ["name"] = t.Name, ["description"] = t.Description }); + } + return Pretty(new JsonObject + { + ["approver"] = m.Approver, + ["s256"] = m.S256, + ["approved_tools"] = tools, + }); + } + + private static string Pretty(JsonObject? json) => json is null + ? "(no body)" + : System.Text.Json.JsonSerializer.Serialize(json, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); +} diff --git a/samples/SampleApp/playwright-tests/mission-call-chain.spec.ts b/samples/SampleApp/playwright-tests/mission-call-chain.spec.ts new file mode 100644 index 0000000..b3ceda0 --- /dev/null +++ b/samples/SampleApp/playwright-tests/mission-call-chain.spec.ts @@ -0,0 +1,119 @@ +import type { Page, BrowserContext } from '@playwright/test'; +import { test, expect } from '../../../tests/e2e/helpers/fixtures'; +import { waitForInteractive, clickAndConfirm } from '../../../tests/e2e/helpers/blazor'; +import { approveInPopup } from '../../../tests/e2e/helpers/consent'; + +/** + * Mission Call Chain (SampleApp) — one human-approved mission governs three + * AAuth seams in a single run on the `/mission-call-chain` page: + * + * 1. Mission creation — PROMPT (the user approves intent + tools). + * 2. Elevated scope — a CLARIFICATION round (§Clarification Chat) + * runs first because the scope is out-of-mission, + * then the user approves the prompt. + * 3. Mission-forwarded chain — SILENT: the same mission is carried + * (WithMission) to the Orchestrator's mission-aware + * "/mission" endpoint, which forwards the + * AAuth-Mission header to the WhoAmI "/jwt/mission" + * hop. Both hops are seeded in-scope, so no prompt. + * + * The page then fetches the PS-held mission log (§Mission Log) and renders it. + * Needs PS + AP + Orchestrator + WhoAmI booted (the Playwright webServer array). + */ +test.describe('Mission Call Chain (SampleApp)', () => { + test.describe.configure({ timeout: 180_000 }); + + /** The PS consent link surfaced while a step is parked on user approval. */ + function approvalLink(page: Page) { + return page.locator('.alert-warning a[target="_blank"]'); + } + + /** A step-outcome card by its 1-based number (cards render in order). */ + function stepCard(page: Page, n: number) { + return page.locator('.card').nth(n - 1); + } + + /** + * Resolve the currently-parked prompt: open the surfaced PS consent page in a + * popup, click Approve, then wait until the step cards reach `expectedCards`. + * + * We assert the just-approved step's card is present (i.e. there are AT LEAST + * `expectedCards` cards) rather than an EXACT total. Unlike the `/mission` + * page — where every gate parks on the next prompt, so the card count settles + * and stays put — here the FINAL step (the silent mission-forwarded chain) + * advances with no gate after the step-2 approval. It appends its card almost + * immediately, and Blazor coalesces the step-2 and step-3 render batches into + * one (the DOM jumps 1 -> 3, never momentarily showing 2). An exact + * `toHaveCount(2)` here is therefore racy by construction; the page behaviour + * is correct. The strict final count of 3 is asserted separately below. + */ + async function approvePrompt( + page: Page, + context: BrowserContext, + expectedCards: number, + ): Promise { + await expect(page.locator('.alert-warning')).toBeVisible({ timeout: 60_000 }); + const [popup] = await Promise.all([ + context.waitForEvent('page'), + approvalLink(page).click(), + ]); + await approveInPopup(popup); + await expect(stepCard(page, expectedCards)).toBeVisible({ timeout: 120_000 }); + } + + test('mission governs a clarification round and a forwarded call chain', async ({ page, context }) => { + await page.goto('/mission-call-chain'); + await expect(page.locator('h2')).toContainText('Mission Call Chain'); + await waitForInteractive(page, 'button.btn-primary'); + + // Start the flow; step 1 (mission creation) parks on the first PS prompt. + await clickAndConfirm(page, 'button.btn-primary', () => + page.locator('.alert-warning').isVisible()); + + // Step 1: approve the mission's intent + tools (PROMPT) → 1 card. + await approvePrompt(page, context, 1); + + // Step 2: the elevated scope first triggers a clarification round (the + // agent answers it automatically), surfaced in the clarification panel. + await expect(page.locator('[data-test="clarification"]')).toBeVisible({ timeout: 60_000 }); + await expect(page.locator('[data-test="clarification"]')).toContainText('PS asked'); + await expect(page.locator('[data-test="clarification"]')).toContainText('Agent answered'); + + // ...then the PS prompts for the out-of-mission scope; approve it → 2 cards. + await approvePrompt(page, context, 2); + + // Step 3: the mission-forwarded call chain resolves silently → 3 cards. + await expect(page.locator('.card')).toHaveCount(3, { timeout: 120_000 }); + + // The flow finished: the "Running…" button is gone. + await expect(page.getByRole('button', { name: /Running/ })).toHaveCount(0, { timeout: 30_000 }); + + // Step 1 — PROMPT, approved. + await expect(stepCard(page, 1)).toContainText('Mission creation'); + await expect(stepCard(page, 1).locator('.badge.bg-warning')).toHaveText('prompt'); + + // Step 2 — PROMPT, granted, with the clarification round noted. + await expect(stepCard(page, 2)).toContainText('clarification'); + await expect(stepCard(page, 2).locator('.badge.bg-warning')).toHaveText('prompt'); + await expect(stepCard(page, 2).locator('.badge.bg-success').last()).toHaveText('granted'); + + // Step 3 — SILENT, granted: the chain result shows the full delegation. + await expect(stepCard(page, 3)).toContainText('Mission call chain'); + await expect(stepCard(page, 3).locator('.badge.bg-success').first()).toHaveText('silent'); + const chainJson = await stepCard(page, 3).locator('pre code').innerText(); + const chain = JSON.parse(chainJson) as Record; + expect(chain.downstream.mode).toBe('three-party'); + expect(chain.downstream.scope).toEqual(['whoami']); + // The downstream WhoAmI hop saw the Orchestrator as the immediate actor. + expect(chain.downstream.agent).toBe('aauth:orchestrator@localhost:5200'); + // The mission was forwarded: the downstream auth token carries the mission. + expect(chain.downstream.mission).toBeTruthy(); + + // The PS-held mission log/trail is surfaced and records the governed steps. + await expect(page.locator('[data-test="mission-log"]')).toBeVisible({ timeout: 30_000 }); + const rows = page.locator('[data-test="mission-log"] tbody tr'); + expect(await rows.count()).toBeGreaterThan(0); + // The log includes the clarification exchange under the mission. + await expect(page.locator('[data-test="mission-log"]')).toContainText('clarification'); + }); +}); diff --git a/samples/WhoAmI/Program.cs b/samples/WhoAmI/Program.cs index df5e4bf..603d9e1 100644 --- a/samples/WhoAmI/Program.cs +++ b/samples/WhoAmI/Program.cs @@ -400,6 +400,10 @@ // token, or null when the agent operated without a mission. mission, missionAware = true, + // The nested act chain (§Upstream Token Verification step 4): present + // when this token was issued for a call chain — each intermediary's + // identity wraps the upstream act, surfaced here exactly as on /jwt. + act = parsed.Payload?["act"], }); }).RequireAuthorization("AAuth.Scope.whoami"); @@ -430,6 +434,9 @@ iss = result.Issuer, mission, missionAware = true, + // The nested act chain (§Upstream Token Verification step 4), surfaced + // for parity with /jwt and /jwt/mission. + act = parsed.Payload?["act"], }); }).RequireAuthorization("AAuth.Scope.whoami:elevated_scope"); diff --git a/src/AAuth/AAuthClientBuilder.cs b/src/AAuth/AAuthClientBuilder.cs index 08ec5e9..6d5bc23 100644 --- a/src/AAuth/AAuthClientBuilder.cs +++ b/src/AAuth/AAuthClientBuilder.cs @@ -81,6 +81,9 @@ public static AAuthClientBuilder From(EnrollResult result) // Call-chaining state private Func? _upstreamTokenProvider; + // Mission state (originating agent's own approved mission) + private Agent.Mission? _mission; + // Token refresh state private ITokenRefresher? _tokenRefresher; private TimeSpan? _refreshThreshold; @@ -312,6 +315,29 @@ public AAuthClientBuilder WithCallChaining(HttpContext httpContext) return this; } + /// + /// Operate the client in the context of the agent's own approved + /// . Every outbound request carries the + /// AAuth-Mission header ({approver, s256}), which the signing + /// pipeline covers as the aauth-mission component. + /// + /// + /// Per §Mission Context at Resources, an agent operating in a mission context + /// includes the AAuth-Mission header on requests to resources. Combine + /// with / + /// so the whole resource-access leg (mission header + 401 challenge + token + /// exchange + retry) is handled automatically. This is for the originating + /// agent that holds its own approved mission; call-chaining intermediaries that + /// re-emit a mission from an upstream token use . + /// + /// The agent's own approved mission. + public AAuthClientBuilder WithMission(Agent.Mission mission) + { + ArgumentNullException.ThrowIfNull(mission); + _mission = mission; + return this; + } + /// /// Configure a self-issued agent token identity. The builder's key is used /// for both HTTP signing and token signing. A @@ -474,7 +500,7 @@ public HttpMessageHandler BuildHandler() // When WithTokenRefresh is configured but no explicit provider, // create a JWT signing pipeline with lazy token acquisition. if (_provider is null && _tokenRefresher is not null) - return BuildRefreshOnlyHandler(); + return WithMissionHeader(BuildRefreshOnlyHandler()); // Simple signing-only pipeline (possibly with interaction handling). var handler = new AAuthSigningHandler(_key, _provider!) @@ -485,7 +511,7 @@ public HttpMessageHandler BuildHandler() }; if (!_interactionHandling) - return handler; + return WithMissionHeader(handler); // Wrap with interaction handler var interactionOpts = new InteractionHandlingOptions(); @@ -501,7 +527,7 @@ public HttpMessageHandler BuildHandler() { InnerHandler = handler, }; - return interactionHandler; + return WithMissionHeader(interactionHandler); } // --- Challenge-handling pipeline --- @@ -558,6 +584,8 @@ public HttpMessageHandler BuildHandler() : null, Prompt = challengeOptions.Prompt, AdditionalSignatureComponents = challengeOptions.AdditionalSignatureComponents, + OnClarificationRequired = challengeOptions.OnClarificationRequired, + MaxClarificationRounds = challengeOptions.MaxClarificationRounds, }; // If token refresh is configured, insert it above the challenge handler. @@ -603,7 +631,20 @@ public HttpMessageHandler BuildHandler() topHandler = missionHandler; } - return topHandler; + return WithMissionHeader(topHandler); + } + + // Wrap a pipeline with the originating-agent mission header handler when a + // mission was configured via WithMission(...). Sits at the very top so the + // AAuth-Mission header is present before the request is signed; the signing + // handler beneath then covers it as the `aauth-mission` component (§Mission + // Context at Resources). Skipped under call-chaining, where + // MissionForwardingHandler already emits the header from the upstream token. + private HttpMessageHandler WithMissionHeader(HttpMessageHandler inner) + { + if (_mission is null || _upstreamTokenProvider is not null) + return inner; + return new MissionHeaderHandler(_mission) { InnerHandler = inner }; } private HttpMessageHandler BuildRefreshOnlyHandler() diff --git a/src/AAuth/Agent/ChallengeHandler.cs b/src/AAuth/Agent/ChallengeHandler.cs index 67d2e44..2e0d15c 100644 --- a/src/AAuth/Agent/ChallengeHandler.cs +++ b/src/AAuth/Agent/ChallengeHandler.cs @@ -122,6 +122,21 @@ public ChallengeHandler( /// public string? Prompt { get; init; } + /// + /// Optional callback invoked when the PS returns 202 + requirement=clarification + /// during the embedded exchange (§Clarification Chat). The callback answers the + /// question (respond / update / cancel); when set, the agent declares the + /// clarification capability. When and the PS asks for + /// clarification, the exchange throws. + /// + public Func>? OnClarificationRequired { get; init; } + + /// + /// Maximum number of clarification rounds before the embedded exchange aborts + /// (§Clarification Chat). Default: 5. + /// + public int MaxClarificationRounds { get; init; } = ClarificationExchange.DefaultMaxRounds; + /// /// Additional signature components a resource requires, keyed by origin /// (scheme://host:port), typically discovered from the resource's @@ -200,6 +215,8 @@ protected override async Task SendAsync( UpstreamToken = upstreamToken, Capabilities = Capabilities, Prompt = Prompt, + OnClarificationRequired = OnClarificationRequired, + MaxClarificationRounds = MaxClarificationRounds, }, cancellationToken) .ConfigureAwait(false); diff --git a/src/AAuth/Agent/DeferredExchange.cs b/src/AAuth/Agent/DeferredExchange.cs index bd50fd6..fd40a9a 100644 --- a/src/AAuth/Agent/DeferredExchange.cs +++ b/src/AAuth/Agent/DeferredExchange.cs @@ -129,11 +129,16 @@ internal async Task PostAsync( var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); ClarificationExchange? clarificationExchange = null; var ownsResponse = true; + Uri? lastPendingUrl = null; try { while (response.StatusCode == HttpStatusCode.Accepted) { - var pendingUrl = ResolveLocation(response, endpoint); + // A polled 202 (e.g. an interaction gate that follows a + // clarification round) may omit the Location header; the pending + // URL is unchanged, so fall back to the last one we resolved. + var pendingUrl = ResolveLocation(response, endpoint, lastPendingUrl); + lastPendingUrl = pendingUrl; var requirement = ExtractRequirement(response); // §Clarification Chat: the PS is asking the agent a question @@ -156,7 +161,14 @@ internal async Task PostAsync( .ConfigureAwait(false); await clarificationExchange.ApplyAsync(decision, cancellationToken).ConfigureAwait(false); - response = await PollAsync(pendingUrl, options.PollerOptions, cancellationToken).ConfigureAwait(false); + // After answering, the PS may escalate to a user-interaction + // gate (§Clarification Chat then §User Interaction). Stop the + // poll on that interaction so the loop below surfaces it via + // OnInteractionRequired; otherwise a bare poll would wait it + // out silently and never prompt the user. + response = await PollAsync( + pendingUrl, options.PollerOptions, cancellationToken, stopOnInteraction: true) + .ConfigureAwait(false); continue; } @@ -216,9 +228,10 @@ is var (terminated, missionStatus) && terminated) } private async Task PollAsync( - Uri pendingUrl, DeferredPollerOptions? pollerOptions, CancellationToken cancellationToken) + Uri pendingUrl, DeferredPollerOptions? pollerOptions, CancellationToken cancellationToken, + bool stopOnInteraction = false) { - var composed = ComposePollerOptions(pollerOptions); + var composed = ComposePollerOptions(pollerOptions, stopOnInteraction); try { using var pollActivity = AAuthDiagnostics.Source.StartActivity("AAuth.DeferredPoll"); @@ -234,15 +247,21 @@ private async Task PollAsync( } // Stop polling on a clarification 202 so the exchange loop can handle it, - // preserving any caller-supplied StopWhenAccepted predicate. - private static DeferredPollerOptions ComposePollerOptions(DeferredPollerOptions? baseOptions) + // preserving any caller-supplied StopWhenAccepted predicate. When + // is set (immediately after a + // clarification round) the poll also stops on an interaction 202 so the loop + // can surface it via OnInteractionRequired. + private static DeferredPollerOptions ComposePollerOptions( + DeferredPollerOptions? baseOptions, bool stopOnInteraction = false) { var userStop = baseOptions?.StopWhenAccepted; bool Stop(HttpResponseMessage resp) { if (userStop is not null && userStop(resp)) { return true; } var requirement = ExtractRequirement(resp); - return requirement?.Requirement == ClarificationRequirement.RequirementType; + if (requirement?.Requirement == ClarificationRequirement.RequirementType) { return true; } + return stopOnInteraction + && requirement?.Requirement == Interaction.RequirementType; } return baseOptions is null @@ -321,11 +340,15 @@ internal static async Task BufferBodyAsync( return null; } - private static Uri ResolveLocation(HttpResponseMessage response, Uri @base) + private static Uri ResolveLocation(HttpResponseMessage response, Uri @base, Uri? fallback = null) { - var location = response.Headers.Location - ?? throw new HttpRequestException( - "Deferred PS response is missing the Location header — cannot poll."); + var location = response.Headers.Location; + if (location is null) + { + return fallback + ?? throw new HttpRequestException( + "Deferred PS response is missing the Location header — cannot poll."); + } return location.IsAbsoluteUri ? location : new Uri(@base, location); } diff --git a/src/AAuth/Agent/Governance/AuditClient.cs b/src/AAuth/Agent/Governance/AuditClient.cs index b4bd814..57af531 100644 --- a/src/AAuth/Agent/Governance/AuditClient.cs +++ b/src/AAuth/Agent/Governance/AuditClient.cs @@ -48,9 +48,7 @@ public async Task RecordAsync( endpoint, record.ToJsonObject(), new DeferredExchangeOptions(), cancellationToken).ConfigureAwait(false); try { - if (response.StatusCode == HttpStatusCode.Created - || response.StatusCode == HttpStatusCode.OK - || response.StatusCode == HttpStatusCode.NoContent) + if (response.StatusCode == HttpStatusCode.Created) { return; } diff --git a/src/AAuth/Agent/MissionAction.cs b/src/AAuth/Agent/MissionAction.cs index eb417a5..5ea2e79 100644 --- a/src/AAuth/Agent/MissionAction.cs +++ b/src/AAuth/Agent/MissionAction.cs @@ -10,19 +10,14 @@ namespace AAuth.Agent; /// /// is the invocation; /// is the catalog entry (a proposal's requested tools / the approval's -/// approved_tools). A pre-approved tool can be invoked directly via the -/// implicit conversion from , and a bare action name via -/// the implicit conversion from . +/// approved_tools). A pre-approved tool can be invoked as an action via +/// . Callers always name the action explicitly +/// (new MissionAction("WebSearch")); the SDK serializes +/// as the wire action string. /// /// The action identifier serialized as the wire action. REQUIRED. public sealed record MissionAction(string Name) { - /// A bare action name (e.g. "WebSearch") is a . - public static implicit operator MissionAction(string name) => new(name); - - /// Invoke a catalog as an action by its name. - public static implicit operator MissionAction(MissionTool tool) => new(tool.Name); - /// public override string ToString() => Name; } diff --git a/src/AAuth/Agent/MissionHeaderHandler.cs b/src/AAuth/Agent/MissionHeaderHandler.cs new file mode 100644 index 0000000..2d57957 --- /dev/null +++ b/src/AAuth/Agent/MissionHeaderHandler.cs @@ -0,0 +1,51 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace AAuth.Agent; + +/// +/// DelegatingHandler that emits the AAuth-Mission header on outbound +/// requests from an agent's own approved . +/// +/// +/// Per §Mission Context at Resources, "the agent includes the AAuth-Mission +/// header when sending requests to resources, unless the mission is already conveyed +/// in an auth token", and per the HTTP Message Signatures section it "adds +/// aauth-mission to the signed components". The signing handler beneath this +/// one auto-covers the aauth-mission component whenever the header is present, +/// so this handler only needs to set the header value. +/// +/// This is the seam for the originating agent that holds its own approved +/// mission. Call-chaining intermediaries that re-emit a mission extracted from an +/// upstream auth token use instead. The +/// header is left untouched if a caller already set it, honoring the spec's +/// "unless already conveyed" carve-out. +/// +/// +public sealed class MissionHeaderHandler : DelegatingHandler +{ + private readonly Mission _mission; + + /// + /// Creates a new for the agent's approved mission. + /// + /// The agent's own approved mission. + public MissionHeaderHandler(Mission mission) + { + _mission = mission ?? throw new System.ArgumentNullException(nameof(mission)); + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!request.Headers.Contains(AAuthMissionHeader.Name)) + { + request.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(_mission.Approver, _mission.S256)); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/AAuth/Agent/MissionTool.cs b/src/AAuth/Agent/MissionTool.cs index 1ee2c84..825642b 100644 --- a/src/AAuth/Agent/MissionTool.cs +++ b/src/AAuth/Agent/MissionTool.cs @@ -7,4 +7,8 @@ namespace AAuth.Agent; /// /// The tool name. /// A human-readable description of the tool. -public sealed record MissionTool(string Name, string? Description = null); +public sealed record MissionTool(string Name, string? Description = null) +{ + /// Invoke this catalog tool as a by its name. + public MissionAction ToAction() => new(Name); +} diff --git a/src/AAuth/Agent/TokenExchangeRequest.cs b/src/AAuth/Agent/TokenExchangeRequest.cs index 16efd2b..5de05d7 100644 --- a/src/AAuth/Agent/TokenExchangeRequest.cs +++ b/src/AAuth/Agent/TokenExchangeRequest.cs @@ -84,7 +84,42 @@ public sealed class TokenExchangeRequest /// (e.g. "Chrome on macOS"). MUST be printable UTF-8, ≤ 64 characters, /// no control characters or PII (§Agent Token Request). ///
- public string? Device { get; init; } + public string? Device + { + get => _device; + init => _device = ValidateDevice(value); + } + + private readonly string? _device; + + // §Agent Token Request: `device` MUST be printable (no control characters) and + // ≤ 64 characters. Reject anything outside printable ASCII (32–126) so display + // surfaces never receive control characters; allow null/empty (the field is optional). + private static string? ValidateDevice(string? value) + { + if (value is null) + { + return null; + } + + if (value.Length > 64) + { + throw new ArgumentException( + $"device must be at most 64 characters (was {value.Length}).", nameof(Device)); + } + + foreach (var ch in value) + { + if (ch < ' ' || ch > '~') + { + throw new ArgumentException( + "device must contain only printable ASCII characters (no control characters).", + nameof(Device)); + } + } + + return value; + } /// /// Invoked when the PS returns 202 with diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs index fd31cee..14dd9f7 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs @@ -61,7 +61,9 @@ public static IEndpointRouteBuilder MapAAuthGovernance( (HttpContext ctx, IMissionStore missions, IMissionLog log, IPermissionDecider decider) => HandlePermissionAsync(ctx, options, missions, log, decider)); endpoints.MapPost(options.Resolve(options.AuditPath), HandleAuditAsync); - endpoints.MapPost(options.Resolve(options.InteractionPath), HandleInteractionAsync); + endpoints.MapPost(options.Resolve(options.InteractionPath), + (HttpContext ctx, IMissionStore missions, IMissionLog log, IInteractionRelay relay) => + HandleInteractionAsync(ctx, options, missions, log, relay)); endpoints.MapGet(options.Resolve(options.PendingPath).TrimEnd('/') + "/{id}", (HttpContext ctx, string id, IMissionStore missions, IMissionLog log) => HandlePendingAsync(ctx, id, options, missions, log)); @@ -242,6 +244,7 @@ record = GovernanceEndpoints.ParseAudit(body); private static async Task HandleInteractionAsync( HttpContext ctx, + AAuthGovernancePipelineOptions options, IMissionStore missions, IMissionLog log, IInteractionRelay relay) @@ -296,6 +299,25 @@ await log.AppendAsync(new MissionLogEntry( return Results.Json(new { mission_status = "active" }); default: + // interaction / payment: when the relay is still pending the PS + // MUST return a deferred response and let the agent poll until the + // user completes (§Interaction Response). Park it on the deferred + // store and answer 202; without a store there is no user channel, + // so treat the relay as having resolved synchronously (200). + if (result.Pending) + { + var store = ctx.RequestServices.GetService(); + if (store is not null) + { + var parked = await store.ParkAsync(new DeferredConsent + { + Kind = DeferredConsentKind.Interaction, + Approver = request.Mission?.Approver ?? string.Empty, + Interaction = request, + }, ctx.RequestAborted).ConfigureAwait(false); + return DeferredAccepted(ctx, options, parked.Id); + } + } return Results.Json(new { status = "ok" }); } } @@ -344,6 +366,15 @@ private static async Task HandlePendingAsync( ctx, missions, entry.Approver, entry.Agent, proposal, proposal.Tools).ConfigureAwait(false); } + if (entry.Kind == DeferredConsentKind.Interaction) + { + // The user completed the relayed interaction / payment; the poll loop + // terminates with the relay's final response (§Interaction Response). + // The interaction was already recorded in the mission log when it was + // relayed, so no further bookkeeping is needed here. + return Results.Json(new { status = "ok" }); + } + // Permission: the endpoint always returns a decision (200), never access_denied. var request = entry.Permission!; var granted = entry.Decision.Value; diff --git a/src/AAuth/HttpSig/ChallengeHandlingOptions.cs b/src/AAuth/HttpSig/ChallengeHandlingOptions.cs index b7272de..b5b7a51 100644 --- a/src/AAuth/HttpSig/ChallengeHandlingOptions.cs +++ b/src/AAuth/HttpSig/ChallengeHandlingOptions.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using AAuth.Agent; using AAuth.Headers; namespace AAuth.HttpSig; @@ -17,6 +18,22 @@ public sealed class ChallengeHandlingOptions /// public Func? OnInteractionRequired { get; set; } + /// + /// Optional callback invoked when the PS returns 202 + requirement=clarification + /// during token exchange (§Clarification Chat). The callback receives the parsed + /// question and returns the agent's chosen + /// (respond / update / cancel), which the exchange applies before resuming polling. + /// When set, the agent declares the clarification capability to the PS; when + /// and the PS asks for clarification, the exchange throws. + /// + public Func>? OnClarificationRequired { get; set; } + + /// + /// Maximum number of clarification rounds the agent will engage in before + /// giving up (§Clarification Chat). Default: 5. + /// + public int MaxClarificationRounds { get; set; } = ClarificationExchange.DefaultMaxRounds; + /// /// Maximum time to poll a deferred PS response before timing out. /// Default: 5 minutes. diff --git a/src/AAuth/Server/Governance/GovernanceEndpoints.cs b/src/AAuth/Server/Governance/GovernanceEndpoints.cs index 6fffa5c..e6d44b2 100644 --- a/src/AAuth/Server/Governance/GovernanceEndpoints.cs +++ b/src/AAuth/Server/Governance/GovernanceEndpoints.cs @@ -31,7 +31,7 @@ public static PermissionRequest ParsePermission(JsonObject body) ArgumentNullException.ThrowIfNull(body); var action = (string?)body["action"] ?? throw new FormatException("Permission request is missing the required 'action'."); - return new PermissionRequest(action) + return new PermissionRequest(new MissionAction(action)) { Description = (string?)body["description"], Parameters = body["parameters"] as JsonObject, @@ -50,7 +50,7 @@ public static AuditRecord ParseAudit(JsonObject body) ?? throw new FormatException("Audit request is missing the required 'mission'."); var action = (string?)body["action"] ?? throw new FormatException("Audit request is missing the required 'action'."); - return new AuditRecord(mission, action) + return new AuditRecord(mission, new MissionAction(action)) { Description = (string?)body["description"], Parameters = body["parameters"] as JsonObject, diff --git a/src/AAuth/Server/Governance/IDeferredConsentStore.cs b/src/AAuth/Server/Governance/IDeferredConsentStore.cs index 888b5bd..be994af 100644 --- a/src/AAuth/Server/Governance/IDeferredConsentStore.cs +++ b/src/AAuth/Server/Governance/IDeferredConsentStore.cs @@ -15,6 +15,13 @@ public enum DeferredConsentKind /// A permission request awaiting the user's decision (§Permission Endpoint). Permission, + + /// + /// An interaction / payment relay awaiting the user's completion + /// (§Interaction Response). The PS relays it to the user and the agent polls + /// until the user completes the interaction. + /// + Interaction, } /// @@ -44,6 +51,9 @@ public sealed class DeferredConsent /// The permission request (set when is ). public PermissionRequest? Permission { get; init; } + /// The interaction request (set when is ). + public InteractionRequest? Interaction { get; init; } + /// /// The user's decision: while pending, /// on approval, on decline. diff --git a/tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs b/tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs new file mode 100644 index 0000000..763df14 --- /dev/null +++ b/tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Discovery; +using AAuth.Headers; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the clarification seam on the embedded challenge exchange +/// (, surfaced on the +/// high-level builder as +/// ). +/// Per §Clarification Chat a PS MAY return 202 + requirement=clarification +/// while resolving a resource-token exchange; an agent that wires the seam answers +/// the question (respond / cancel) and the exchange resumes — all within the single +/// signed request to the resource. This closes the gap where only the low-level +/// could participate in clarification. The full +/// builder path (WithChallengeHandling(o => o.OnClarificationRequired = ...)) +/// is exercised end-to-end by the SampleApp mission-call-chain Playwright spec. +/// +public class ChallengeClarificationSeamTests +{ + private const string ResourceUrl = "https://r.example"; + private const string Ps = "https://ps.example"; + + private static ChallengeHandler BuildChallengeHandler( + ClarifyingExchangeHandler exchangeHandler, + Func> onClarification, + Func? onInteraction = null) + { + var holder = new AAuthTokenHolder("initial-agent-token"); + var metaClient = new MetadataClient(new HttpClient(exchangeHandler)); + var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient); + + return new ChallengeHandler( + exchangeClient, holder, + personServer: Ps, + onInteractionRequired: onInteraction, + pollerOptions: null, + upstreamTokenProvider: null) + { + InnerHandler = new ChallengingResourceHandler(), + OnClarificationRequired = onClarification, + }; + } + + [Fact(DisplayName = "§Clarification Chat — the challenge seam answers a clarification then completes the exchange")] + public async Task ChallengeSeam_AnswersClarification_ThenRetriesTo200() + { + var exchangeHandler = new ClarifyingExchangeHandler(); + ClarificationRequirement? seen = null; + var challenge = BuildChallengeHandler(exchangeHandler, (clarification, _) => + { + seen = clarification; + return Task.FromResult(ClarificationResponse.Respond("Needed to summarize the inbox.")); + }); + + using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) }; + using var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(seen); + Assert.Equal("Why does this mission need this access?", seen!.Clarification); + Assert.Equal("Needed to summarize the inbox.", exchangeHandler.LastClarificationResponse); + } + + [Fact(DisplayName = "§Clarification Chat — the challenge seam surfaces a user interaction that follows the clarification")] + public async Task ChallengeSeam_ClarificationThenInteraction_SurfacesInteractionTo200() + { + var exchangeHandler = new ClarifyingExchangeHandler { EscalateToInteraction = true }; + Interaction? surfaced = null; + var challenge = BuildChallengeHandler( + exchangeHandler, + (_, _) => Task.FromResult(ClarificationResponse.Respond("Needed to summarize the inbox.")), + (interaction, _) => { surfaced = interaction; return Task.CompletedTask; }); + + using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) }; + using var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // The clarification was answered AND the follow-on user-interaction gate + // was surfaced (a bare poll would have swallowed it). + Assert.Equal("Needed to summarize the inbox.", exchangeHandler.LastClarificationResponse); + Assert.NotNull(surfaced); + } + + [Fact(DisplayName = "§Clarification Chat — the challenge seam declares the clarification capability")] + public async Task ChallengeSeam_DeclaresClarificationCapability() + { + var exchangeHandler = new ClarifyingExchangeHandler(); + var challenge = BuildChallengeHandler(exchangeHandler, (_, _) => + Task.FromResult(ClarificationResponse.Respond("ok"))); + + using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) }; + using var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("clarification", exchangeHandler.DeclaredCapabilities); + } + + [Fact(DisplayName = "§Cancel Request — the challenge seam can withdraw the request during clarification")] + public async Task ChallengeSeam_CancelDuringClarification_Throws() + { + var exchangeHandler = new ClarifyingExchangeHandler(); + var challenge = BuildChallengeHandler(exchangeHandler, (_, _) => + Task.FromResult(ClarificationResponse.Cancel())); + + using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) }; + + await Assert.ThrowsAsync( + () => client.GetAsync("/data")); + Assert.True(exchangeHandler.DeleteCalled); + } + + /// Resource handler: 401 challenge first, 200 once an auth token is exchanged. + private sealed class ChallengingResourceHandler : HttpMessageHandler + { + private int _callCount; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + if (Interlocked.Increment(ref _callCount) == 1) + { + var challenge = new HttpResponseMessage(HttpStatusCode.Unauthorized); + challenge.Headers.TryAddWithoutValidation( + AAuthRequirementHeader.Name, + AAuthRequirementHeader.FormatAuthToken("fake-resource-token")); + return Task.FromResult(challenge); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"ok\":true}", Encoding.UTF8, "application/json"), + }); + } + } + + /// + /// PS exchange mock: serves metadata, returns a single + /// 202 + requirement=clarification on the token request, then mints the + /// auth token once the agent answers on the pending URL. + /// + private sealed class ClarifyingExchangeHandler : HttpMessageHandler + { + public string? LastClarificationResponse { get; private set; } + public bool DeleteCalled { get; private set; } + public List DeclaredCapabilities { get; } = new(); + + /// When set, the PS moves to a user-interaction gate once the + /// clarification is answered (clarification then §User Interaction). + public bool EscalateToInteraction { get; init; } + + private bool _answered; + private bool _interactionServed; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + + if (path.Contains("well-known")) + { + return Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = Ps, + ["token_endpoint"] = Ps + "/token", + }); + } + + if (request.Method == HttpMethod.Delete) + { + DeleteCalled = true; + return new HttpResponseMessage(HttpStatusCode.OK); + } + + if (path == "/token" && request.Method == HttpMethod.Post) + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + if (body?["capabilities"] is JsonArray caps) + { + foreach (var c in caps) + { + if ((string?)c is { } v) { DeclaredCapabilities.Add(v); } + } + } + return Clarify(); + } + + if (path == "/pending/abc" && request.Method == HttpMethod.Post) + { + var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject(); + if (body?["clarification_response"] is { } cr) + { + LastClarificationResponse = (string?)cr; + } + _answered = true; + return new HttpResponseMessage(HttpStatusCode.OK); + } + + if (path == "/pending/abc" && request.Method == HttpMethod.Get) + { + if (!_answered) { return Clarify(); } + // After the answer, optionally escalate to a single user-interaction + // gate before minting the token (clarification then §User Interaction). + if (EscalateToInteraction && !_interactionServed) + { + _interactionServed = true; + return Interact(); + } + return Json(HttpStatusCode.OK, new JsonObject { ["auth_token"] = "fake-auth-token" }); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private static HttpResponseMessage Clarify() + { + var response = Json(HttpStatusCode.Accepted, new JsonObject + { + ["status"] = "pending", + ["clarification"] = "Why does this mission need this access?", + ["timeout"] = 120, + }); + response.Headers.Location = new Uri(Ps + "/pending/abc"); + response.Headers.TryAddWithoutValidation( + AAuthRequirementHeader.Name, "requirement=clarification"); + return response; + } + + private static HttpResponseMessage Interact() + { + // Mirrors the real PS: the polled interaction 202 carries the + // requirement header but NO Location (the pending URL is unchanged). + var response = Json(HttpStatusCode.Accepted, new JsonObject { ["status"] = "pending" }); + response.Headers.TryAddWithoutValidation( + AAuthRequirementHeader.Name, + Interaction.Format(Ps + "/interaction", "abc")); + return response; + } + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) + => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs index a8a6c9b..d0c7609 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs @@ -84,7 +84,7 @@ public async Task Session_Permission_ThreadsMissionClaim() var client = BuildBound(handler); var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")); - var result = await session.RequestPermissionAsync("SendEmail"); + var result = await session.RequestPermissionAsync(new MissionAction("SendEmail")); Assert.True(result.IsGranted); Assert.Equal(session.Mission.S256, (string?)handler.LastPermissionBody?["mission"]?["s256"]); @@ -101,7 +101,7 @@ public async Task Session_Permission_PreApprovedTool_ShortCircuits() Tools = new[] { new MissionTool("WebSearch", "Search the web") }, }); - var result = await session.RequestPermissionAsync("WebSearch"); + var result = await session.RequestPermissionAsync(new MissionAction("WebSearch")); Assert.True(result.IsGranted); // Pre-approved tools never reach the PS permission endpoint. @@ -115,7 +115,7 @@ public async Task Session_Audit_ThreadsMissionClaim() var client = BuildBound(handler); var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")); - await session.RecordAuditAsync("WebSearch", description: "Looked up flights"); + await session.RecordAuditAsync(new MissionAction("WebSearch"), description: "Looked up flights"); Assert.Equal(session.Mission.S256, (string?)handler.LastAuditBody?["mission"]?["s256"]); Assert.Equal("WebSearch", (string?)handler.LastAuditBody?["action"]); diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs index fd30c9b..fd63641 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs @@ -121,7 +121,7 @@ public async Task PermissionClient_Granted() var (signed, metadata) = Build(handler); var client = new PermissionClient(signed, metadata, Ps); - var result = await client.RequestAsync(new PermissionRequest("SendEmail") + var result = await client.RequestAsync(new PermissionRequest(new MissionAction("SendEmail")) { Description = "Send the itinerary", Mission = TestMission, @@ -137,7 +137,7 @@ public async Task PermissionClient_Denied() var (signed, metadata) = Build(handler); var client = new PermissionClient(signed, metadata, Ps); - var result = await client.RequestAsync(new PermissionRequest("DeleteAll")); + var result = await client.RequestAsync(new PermissionRequest(new MissionAction("DeleteAll"))); Assert.Equal(PermissionGrant.Denied, result.Grant); Assert.Equal("Out of scope.", result.Reason); @@ -160,7 +160,7 @@ public async Task PermissionClient_ApprovedTool_ShortCircuits() ApprovedTools = new[] { new MissionTool("WebSearch") }, }; - var result = await client.RequestAsync("WebSearch", mission); + var result = await client.RequestAsync(new MissionAction("WebSearch"), mission); Assert.True(result.IsGranted); Assert.False(handler.PermissionCalled); @@ -174,7 +174,7 @@ public async Task PermissionClient_MissionTerminated_Throws() var client = new PermissionClient(signed, metadata, Ps); var ex = await Assert.ThrowsAsync(() => - client.RequestAsync(new PermissionRequest("SendEmail") { Mission = TestMission })); + client.RequestAsync(new PermissionRequest(new MissionAction("SendEmail")) { Mission = TestMission })); Assert.Equal("terminated", ex.MissionStatus); } @@ -188,7 +188,7 @@ public async Task AuditClient_Records() var (signed, metadata) = Build(handler); var client = new AuditClient(signed, metadata, Ps); - await client.RecordAsync(new AuditRecord(TestMission, "WebSearch") + await client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch")) { Description = "Searched for flights", }); @@ -204,7 +204,20 @@ public async Task AuditClient_MissionTerminated_Throws() var client = new AuditClient(signed, metadata, Ps); await Assert.ThrowsAsync(() => - client.RecordAsync(new AuditRecord(TestMission, "WebSearch"))); + client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch")))); + } + + [Fact(DisplayName = "§Audit Response — a non-201 acknowledgment is rejected (F3)")] + public async Task AuditClient_Non201_Throws() + { + // The spec requires the PS to acknowledge with 201 Created; a 200 OK + // (or any other 2xx) must not be treated as success. + var handler = new GovernanceHandler { AuditStatus = HttpStatusCode.OK }; + var (signed, metadata) = Build(handler); + var client = new AuditClient(signed, metadata, Ps); + + await Assert.ThrowsAsync(() => + client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch")))); } // ---- §Interaction Endpoint ---- @@ -241,6 +254,7 @@ private sealed class GovernanceHandler : HttpMessageHandler public bool MissionTerminated { get; init; } public bool TamperMissionHeaderS256 { get; init; } public bool MissionNeedsClarification { get; init; } + public HttpStatusCode AuditStatus { get; init; } = HttpStatusCode.Created; public bool PermissionCalled { get; private set; } public bool AuditCalled { get; private set; } @@ -348,7 +362,7 @@ protected override async Task SendAsync( case "/audit": AuditCalled = true; - return new HttpResponseMessage(HttpStatusCode.Created); + return new HttpResponseMessage(AuditStatus); case "/interaction": { diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs index 2bb31c7..6e4ded2 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs @@ -276,6 +276,118 @@ public async Task Permission_Prompt_NoStore_Denied() await host.StopAsync(); } + [Fact(DisplayName = "§Interaction Response — a pending interaction relay parks and answers 202 with a poll Location, then completes")] + public async Task Interaction_PendingRelay_Parks202_ThenCompletes() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true })); + }); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "interaction", + ["url"] = "https://booking.example/confirm", + ["code"] = "X7K2-M9P4", + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var location = response.Headers.Location!.ToString(); + Assert.Contains("/governance-pending/", location); + + // The user has not completed the interaction yet — the poll holds at 202. + using var pendingPoll = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.Accepted, pendingPoll.StatusCode); + + // The user completes the interaction at the resource's interaction URL. + var id = location[(location.LastIndexOf('/') + 1)..]; + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: true); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.OK, done.StatusCode); + var json = await ReadJson(done); + Assert.Equal("ok", (string?)json?["status"]); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction Response — a pending payment relay also parks and answers 202")] + public async Task Interaction_PendingPayment_Parks202() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true })); + }); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "payment", + ["url"] = "https://pay.example/checkout", + ["code"] = "PAY-9931", + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Contains("/governance-pending/", response.Headers.Location!.ToString()); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction Response — a non-pending interaction relay resolves synchronously (200, no poll)")] + public async Task Interaction_NotPending_Returns200() + { + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = false })); + }); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "interaction", + ["url"] = "https://booking.example/confirm", + ["code"] = "X7K2-M9P4", + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Headers.Location); + var json = await ReadJson(response); + Assert.Equal("ok", (string?)json?["status"]); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction Response — without the deferred store a pending relay falls back to a synchronous 200")] + public async Task Interaction_PendingRelay_NoStore_Returns200() + { + using var host = await BuildHostAsync(s => + s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true }))); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "interaction", + ["url"] = "https://booking.example/confirm", + ["code"] = "X7K2-M9P4", + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Headers.Location); + var json = await ReadJson(response); + Assert.Equal("ok", (string?)json?["status"]); + + await host.StopAsync(); + } + private sealed class StubApprover(MissionApprovalDecision decision) : IMissionApprover { public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default) @@ -287,4 +399,10 @@ private sealed class StubDecider(PermissionDecision decision) : IPermissionDecid public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default) => Task.FromResult(decision); } + + private sealed class StubRelay(InteractionRelayResult result) : IInteractionRelay + { + public Task RelayAsync(InteractionRequest request, CancellationToken ct = default) + => Task.FromResult(result); + } } diff --git a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs index d4209f9..6232320 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs @@ -68,10 +68,10 @@ public async Task Facade_SubClients_AreFunctional() Assert.Equal("aauth:assistant@agent.example", mission.Agent); var permission = await facade.Permission.RequestAsync( - new PermissionRequest("SendEmail") { Mission = TestMission }); + new PermissionRequest(new MissionAction("SendEmail")) { Mission = TestMission }); Assert.True(permission.IsGranted); - await facade.Audit.RecordAsync(new AuditRecord(TestMission, "WebSearch")); + await facade.Audit.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch"))); Assert.True(handler.AuditCalled); var answer = await facade.Interaction.AskQuestionAsync("Refundable option?"); diff --git a/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs index 1f83e2f..641ac90 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs @@ -34,7 +34,7 @@ public void ParsePermission_MapsFields() var request = GovernanceEndpoints.ParsePermission(body); - Assert.Equal("SendEmail", request.Action); + Assert.Equal("SendEmail", request.Action.Name); Assert.Equal("Send the itinerary", request.Description); Assert.Equal("user@example.com", (string?)request.Parameters!["to"]); Assert.Equal(S256, request.Mission!.S256); @@ -58,7 +58,7 @@ public void ParseAudit_MapsFields() var record = GovernanceEndpoints.ParseAudit(body); Assert.Equal(S256, record.Mission.S256); - Assert.Equal("WebSearch", record.Action); + Assert.Equal("WebSearch", record.Action.Name); Assert.Equal("completed", (string?)record.Result!["status"]); } @@ -199,7 +199,7 @@ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateT }); var decider = new StubDecider(); - var request = new PermissionRequest("SendEmail") + var request = new PermissionRequest(new MissionAction("SendEmail")) { Mission = new AAuth.Tokens.MissionClaim("https://ps.example", S256), }; diff --git a/tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs b/tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs new file mode 100644 index 0000000..97343cb --- /dev/null +++ b/tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Crypto; +using Xunit; + +namespace AAuth.Conformance.Missions; + +/// +/// Conformance for the originating-agent mission seam +/// ( / +/// ). Per §Mission Context at Resources the +/// agent "includes the AAuth-Mission header when sending requests to +/// resources, unless the mission is already conveyed in an auth token", and per +/// the HTTP Message Signatures section it "adds aauth-mission to the signed +/// components". A client configured with WithMission(...) emits the header +/// from the agent's own approved mission and the signing pipeline covers it. +/// +public class MissionHeaderSeamTests +{ + private const string Approver = "https://ps.example"; + + private static Mission BuildMission() + { + var blob = new JsonObject + { + ["approver"] = Approver, + ["agent"] = "aauth:agent@example", + ["approved_at"] = "2026-06-06T00:00:00Z", + ["description"] = "Keep the inbox under control", + ["approved_tools"] = new JsonArray(), + }.ToJsonString(); + return Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(blob)); + } + + private sealed class CaptureHandler : HttpMessageHandler + { + public HttpRequestMessage? Captured { get; private set; } + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Captured = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + [Fact(DisplayName = "§Mission Context at Resources — WithMission emits the AAuth-Mission header")] + public async Task WithMission_EmitsMissionHeader() + { + var mission = BuildMission(); + var capture = new CaptureHandler(); + using var client = new AAuthClientBuilder(AAuthKey.Generate()) + .UseJwt("a.b.c") + .WithMission(mission) + .WithInnerHandler(capture) + .Build(); + + await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://r.example/path")); + + var value = capture.Captured!.Headers.GetValues(AAuthMissionHeader.Name).Single(); + Assert.Equal(AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256), value); + } + + [Fact(DisplayName = "§HTTP Message Signatures — the emitted mission header is covered as aauth-mission")] + public async Task WithMission_CoversAauthMissionComponent() + { + var mission = BuildMission(); + var capture = new CaptureHandler(); + using var client = new AAuthClientBuilder(AAuthKey.Generate()) + .UseJwt("a.b.c") + .WithMission(mission) + .WithInnerHandler(capture) + .Build(); + + await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://r.example/path")); + + var input = string.Join(',', capture.Captured!.Headers.GetValues("Signature-Input")); + Assert.Contains("\"aauth-mission\"", input); + } + + [Fact(DisplayName = "§Mission Context at Resources — the header is not emitted twice when already present")] + public async Task WithMission_DoesNotDuplicate_WhenAlreadyPresent() + { + var mission = BuildMission(); + var capture = new CaptureHandler(); + using var client = new AAuthClientBuilder(AAuthKey.Generate()) + .UseJwt("a.b.c") + .WithMission(mission) + .WithInnerHandler(capture) + .Build(); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://r.example/path"); + var preset = AAuthMissionHeader.FormatStructured(Approver, "preset-s256-value"); + request.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, preset); + + await client.SendAsync(request); + + var values = capture.Captured!.Headers.GetValues(AAuthMissionHeader.Name).ToArray(); + Assert.Single(values); + Assert.Equal(preset, values[0]); + } + + [Fact(DisplayName = "§Mission Context at Resources — no mission header without WithMission")] + public async Task WithoutMission_NoMissionHeader() + { + var capture = new CaptureHandler(); + using var client = new AAuthClientBuilder(AAuthKey.Generate()) + .UseJwt("a.b.c") + .WithInnerHandler(capture) + .Build(); + + await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://r.example/path")); + + Assert.False(capture.Captured!.Headers.Contains(AAuthMissionHeader.Name)); + } +} diff --git a/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs index 698c4d0..5e99ff9 100644 --- a/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs +++ b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs @@ -69,6 +69,41 @@ public async Task OptionalParams_OmittedWhenUnset() Assert.False(captured.ContainsKey("device")); } + [Fact(DisplayName = "§Agent Token Request — device is accepted at the 64-char boundary")] + public void Device_AtMaxLength_Accepted() + { + var device = new string('a', 64); + var request = new TokenExchangeRequest { Device = device }; + Assert.Equal(device, request.Device); + } + + [Fact(DisplayName = "§Agent Token Request — device longer than 64 chars is rejected")] + public void Device_TooLong_Throws() + { + var ex = Assert.Throws(() => + new TokenExchangeRequest { Device = new string('a', 65) }); + Assert.Equal("Device", ex.ParamName); + } + + [Theory(DisplayName = "§Agent Token Request — device with control characters is rejected")] + [InlineData("Chrome on\tmacOS")] + [InlineData("line\nbreak")] + [InlineData("null\0byte")] + [InlineData("bell\u0007")] + public void Device_ControlCharacters_Throws(string device) + { + var ex = Assert.Throws(() => + new TokenExchangeRequest { Device = device }); + Assert.Equal("Device", ex.ParamName); + } + + [Fact(DisplayName = "§Agent Token Request — printable device string is accepted")] + public void Device_Printable_Accepted() + { + var request = new TokenExchangeRequest { Device = "Chrome on macOS (M3)" }; + Assert.Equal("Chrome on macOS (M3)", request.Device); + } + private sealed class CaptureHandler : HttpMessageHandler { private readonly Action _onBody; diff --git a/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs index adfe681..9fef660 100644 --- a/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs +++ b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using AAuth.Agent; using AAuth.Agent.Governance; using AAuth.Server.Governance; using Microsoft.Extensions.DependencyInjection; @@ -44,7 +45,7 @@ public async Task DefaultPermissionDecider_PromptsForUnknownAction() { var decider = new DefaultPermissionDecider(); var context = new PermissionDecisionContext( - new PermissionRequest("SendEmail"), Mission: null, Log: System.Array.Empty()); + new PermissionRequest(new MissionAction("SendEmail")), Mission: null, Log: System.Array.Empty()); var decision = await decider.DecideAsync(context); diff --git a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs index 6a10894..703372d 100644 --- a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs +++ b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs @@ -212,7 +212,7 @@ public async Task Row09_PermissionApprovedTool_SilentGrant() var mission = await ProposeMissionAsync(agent, "row09 approved-tool mission", "send_email"); var result = await PermissionClientFor(agent) - .RequestAsync("send_email", mission); + .RequestAsync(new MissionAction("send_email"), mission); Assert.True(result.IsGranted); Assert.Equal(PermissionGrant.Granted, result.Grant); @@ -225,7 +225,7 @@ public async Task Row10_PermissionNonPreApproved_PromptThenGrant() await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = true }); var mission = await ProposeMissionAsync(agent, "row10 prompt-grant mission", "send_email"); - var request = new PermissionRequest("delete_file") + var request = new PermissionRequest(new MissionAction("delete_file")) { Mission = new MissionClaim(mission.Approver, mission.S256), }; @@ -242,7 +242,7 @@ public async Task Row11_PermissionNonPreApproved_PromptThenDeny() await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = false }); var mission = await ProposeMissionAsync(agent, "row11 prompt-deny mission", "send_email"); - var request = new PermissionRequest("delete_file") + var request = new PermissionRequest(new MissionAction("delete_file")) { Mission = new MissionClaim(mission.Approver, mission.S256), }; From de8ca74339ae607ee0d7527b106aad13097038e0 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 04:47:39 +0000 Subject: [PATCH 17/24] docs(missions): ground governance docs against SDK (Phase 11 review) Two independent review subagents validated this initiative post-commit: one grounding every SDK change against the spec, the other grounding all docs and sample snippets against the SDK. Remediate the documentation drift the second reviewer found (D-1..D-7); the SDK reviewer found no CRITICAL/HIGH issues. - mission-governance-clients.md, mission-governed-access.md: pass MissionAction (not bare strings) to RequestPermissionAsync/RecordAuditAsync so the snippets compile. - server/mission-governance.md: compare t.Name to context.Request.Action.Name; correct the interaction route comment to /mission-interaction. - reference/dependency-injection.md + reference/configuration.md: replace the non-existent AAuthAgentOptions members (UseHwk, InteractionHandling, etc.) in examples and tables with the real surface (OnResourceInteraction, OnApprovalPending, PollingTimeout); soften the no-op-seam prose. - advanced/error-handling.md: add MissionTerminated to the TokenErrorCode listing. SDK findings are adjudicated in the deviations log: DEV-12 (access_denied vs the spec's `denied`, Polling Error Codes) surfaced as a cross-cutting decision; DEV-13 (carrier-token 401 shape) and DEV-14 (forward-looking user_unreachable) logged intentional. Plan gains a Phase 11 section. No code changed; build 0/0. --- .../implementation-plan.md | 41 +++++++++++++++++++ .../issues-and-deviations.md | 4 ++ docs/advanced/error-handling.md | 1 + docs/advanced/mission-governance-clients.md | 12 +++--- docs/reference/configuration.md | 20 ++++----- docs/reference/dependency-injection.md | 36 +++++++--------- docs/server/mission-governance.md | 4 +- docs/workflows/mission-governed-access.md | 6 +-- 8 files changed, 78 insertions(+), 46 deletions(-) diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md index db3fe10..2f930eb 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -546,6 +546,47 @@ found non-compliant, fix it (DC6 still holds — fixes must not break existing f --- +## Phase 11 — Post-commit review remediation (two subagents) + +**Goal:** After the initiative was committed, run two independent review subagents — +one grounding **every SDK change against the spec**, the other grounding **all docs +and sample snippets against the SDK/repo** — then remediate the findings. Each fix is +spec- or source-grounded; major cross-cutting decisions are surfaced, not rushed. + +**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (§Polling Error Codes, +§Error Responses, §Token Endpoint Error Codes, §Interaction Response); SDK source of +truth `src/AAuth/`. + +### Approach + +- **SDK-vs-spec reviewer.** Walked the `origin/main..HEAD` diff under `src/AAuth/`, + grounding each mission/governance/clarification/interaction/call-chaining behavior + in a cited spec section. Verdict: **COMPLIANT-WITH-FINDINGS** — no CRITICAL/HIGH; + four findings (S-1 `access_denied`→`denied`, S-2 carrier-token 401 shape, S-3 + `user_unreachable` forward-looking, S-4 = the already-adjudicated DEV-10). +- **Docs/samples-vs-SDK reviewer.** Read all 12 changed docs + sample snippets and + verified every referenced symbol against `src/AAuth/`. Verdict: + **GROUNDED-WITH-FINDINGS** — seven drift items (D-1..D-7), the samples themselves + build 0/0 (grounded by construction; the docs had diverged from the SDK). +- **Remediate.** Doc drift fixed against the SDK (the source of truth). SDK findings + adjudicated against spec text: S-1 surfaced as a decision (cross-cutting, spans the + out-of-scope AccessServer path), S-2/S-3/S-4 documented as intentional. + +### Definition of Done + +- [x] Two reviewer subagents run; both reports captured and every finding adjudicated. +- [x] Doc/snippet drift fixed and grounded against `src/AAuth/` (DEV-11): `MissionAction` + bare-string snippets, `MyPermissionDecider` comparison, `AAuthAgentOptions` + examples + tables (dependency-injection + configuration), `TokenErrorCode` listing, + the `/mission-interaction` path comment, and the no-op-seam prose. +- [x] SDK findings adjudicated against spec: DEV-12 (S-1) surfaced as needs-decision; + DEV-13 (S-2) and DEV-14 (S-3) logged intentional; S-4 already DEV-10. +- [x] `issues-and-deviations.md` updated (DEV-11..DEV-14); `dotnet build AAuth.slnx` + 0/0 (only `.md` files changed — no code touched). +- [ ] DEV-12 (`access_denied`→`denied`) decision returned by the user. + +--- + ## Out of Scope | Item | Reason | diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md index 8e9be5c..1e93bfe 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md @@ -51,6 +51,10 @@ These judgment calls were made during Phase 1. **All confirmed by the user on | DEV-8 | 5 | e2e spec timing | `mission-call-chain.spec.ts`'s `approvePrompt` asserted an **exact** transient `toHaveCount(2)` after the step-2 approval. Unlike `mission.spec.ts` — where every gate parks on the next prompt so the count settles — step 3 here is **silent and final**: it advances with no gate and appends its card immediately, and Blazor **coalesces** the step-2 and step-3 `StateHasChanged` into one render batch (the DOM jumps 1→3, never showing 2). The exact count was therefore racy by construction; the page behaviour is correct (forcing artificial render flushes into product code to satisfy a test would be the anti-pattern). **Fix:** the helper now waits for the just-approved step's card via `expect(stepCard(page, expectedCards)).toBeVisible()` (i.e. ≥ expectedCards), matching the helper's own documented intent ("reach expectedCards"). The strict final `toHaveCount(3)` and all per-card content assertions are unchanged; `mission.spec.ts` is untouched. | (e2e test only) | fixed (Phase 5; two consecutive clean full-suite runs) | | DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"For `interaction` and `payment` types, the PS relays the interaction to the user and **returns a deferred response**… The agent polls until the user completes the interaction."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) | | DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional | +| DEV-11 | 11 | Docs & code snippets | The Phase 11 docs-vs-SDK grounding review (subagent) found doc snippets that would not compile against the current SDK: (a) `mission-governance-clients.md`, `mission-governed-access.md` passed **bare strings** to `RequestPermissionAsync`/`RecordAuditAsync`, which take a `MissionAction` (no implicit `string` conversion exists) — CS1503; (b) `server/mission-governance.md`'s `MyPermissionDecider` compared `t.Name == context.Request.Action` (string vs `MissionAction`) — CS0019; (c) `dependency-injection.md` + `configuration.md` documented `AAuthAgentOptions` members that do not exist (`UseHwk()`, `InteractionHandling`, `InteractionHandlingOptions`, `BaseAddress`, `ChallengeHandling*`, `RefreshThreshold`, `Capabilities`, `InnerHandler`, `CallChainProvider`, `SignatureKeyProvider`) and omitted the real ones (`OnResourceInteraction`, `OnApprovalPending`, `PollingTimeout`); (d) `error-handling.md`'s `TokenErrorCode` listing omitted `MissionTerminated`; (e) `server/mission-governance.md` comment said the interaction route is `/interaction` (actual default `/mission-interaction`); (f) `dependency-injection.md` claimed the PS-side seams are "always supplied by the PS" whereas `AddAAuthGovernance` registers no-op defaults via `TryAdd`. **Fix:** all corrected against `src/AAuth/` (the source of truth — samples already used `new MissionAction(...)`/`tool.ToAction()` and build 0/0). | (docs only — grounded against SDK) | fixed (Phase 11) | +| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code is emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. The SDK round-trips internally (the PS emits and the agent's classifier read the same string), so it works SDK-to-SDK, but a spec-conformant external PS returning `denied` would not be recognized. **Why not fixed inline:** the convention spans ~30 sites including the **out-of-scope** AccessServer path (`AccessServerClient`, `IAccessPolicy`, `IAccessPendingStore`), shared agent classifiers (`TokenExchangeClient.IsAccessDenied`, `DeferredExchange`), conformance/integration tests, and GuidedTour narration. A correct fix is a coordinated rename to `denied` with `access_denied` kept only as a read-side backward-compat alias, done as its own focused change with full test updates — a major cross-cutting decision surfaced for user input per the Working Agreement, not rushed into this PR. | §Polling Error Codes (L2023) | needs-decision | +| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Left as-is (LOW); changing the status/shape would churn our own just-written conformance tests for no protocol-observable gain. | §Error Responses | intentional | +| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking) | ## Notes diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md index 2a578f2..b538dc3 100644 --- a/docs/advanced/error-handling.md +++ b/docs/advanced/error-handling.md @@ -86,6 +86,7 @@ public enum TokenErrorCode ExpiredResourceToken, // Resource token exp has passed InteractionRequired, // User must approve (deferred consent, non-terminal 202) UserUnreachable, // No channel to the user; agent declared no interaction capability (terminal 400) + MissionTerminated, // Mission already terminated (terminal 403 mission_terminated) ServerError, // Internal server error (transient, retryable) } ``` diff --git a/docs/advanced/mission-governance-clients.md b/docs/advanced/mission-governance-clients.md index f1d791e..539cad7 100644 --- a/docs/advanced/mission-governance-clients.md +++ b/docs/advanced/mission-governance-clients.md @@ -108,14 +108,14 @@ else } ``` -The action is a `MissionAction` POCO; a bare `string` converts implicitly, so -`"email.send"` works directly. For an action not on the mission, the PS evaluates +The action is a `MissionAction` POCO — construct it with `new MissionAction("email.send")` +(or `tool.ToAction()` from a `MissionTool`). For an action not on the mission, the PS evaluates it against the mission log and may prompt the user. Supply `OnInteractionRequired` / `OnClarificationRequired` via `GovernanceOptions` to participate in any deferral. ```csharp PermissionResult outcome = await session.RequestPermissionAsync( - "files.delete", + new MissionAction("files.delete"), description: "Remove the stale draft the user mentioned.", parameters: new JsonObject { ["path"] = "/drafts/old.md" }); ``` @@ -129,7 +129,7 @@ surfaces as `AAuthMissionTerminatedException` (see ```csharp await session.RecordAuditAsync( - "email.send", + new MissionAction("email.send"), description: "Sent booking confirmation to 4 recipients.", result: new JsonObject { ["messageId"] = "msg-8842" }); ``` @@ -176,11 +176,11 @@ var session = await governance.ProposeMissionAsync( }); // 2. Permission for a pre-approved tool → granted silently -var perm = await session.RequestPermissionAsync("bookmarks.archive"); +var perm = await session.RequestPermissionAsync(new MissionAction("bookmarks.archive")); // 3. Do the work, then audit it await session.RecordAuditAsync( - "bookmarks.archive", + new MissionAction("bookmarks.archive"), result: new JsonObject { ["archived"] = 12 }); // 4. Close the mission out diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f3d573d..1725395 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -178,19 +178,13 @@ Standard `DelegatingHandler` — no configurable options. Requires an `ISignatur | Property | Type | Required | Description | |----------|------|:--------:|-------------| -| `Key` | `IAAuthKey` | Yes | Agent signing key | -| `BaseAddress` | `Uri?` | No | Target resource URL | -| `SignatureKeyProvider` | `ISignatureKeyProvider?` | No | Custom signature key provider | -| `PersonServer` | `string?` | No | Person Server URL (for challenge handling) | -| `ChallengeHandling` | `bool` | No | Enable challenge handling | -| `ChallengeHandlingOptions` | `Action?` | No | Configure challenge handling behavior | -| `InteractionHandling` | `bool` | No | Enable interaction handling | -| `InteractionHandlingOptions` | `Action?` | No | Configure interaction handling behavior | -| `TokenRefresher` | `ITokenRefresher?` | No | Custom token refresh logic | -| `RefreshThreshold` | `TimeSpan?` | No | Time before expiry to trigger refresh | -| `Capabilities` | `string[]?` | No | Agent capabilities to advertise | -| `InnerHandler` | `HttpMessageHandler?` | No | Custom inner HTTP handler | -| `CallChainProvider` | `Func?` | No | Provider for upstream auth token (call chaining) | +| `Key` | `IAAuthKey` | Yes | Agent signing key (must have private component) | +| `PersonServer` | `string?` | No | Person Server URL; with `TokenRefresher`, enables 401 challenge handling | +| `OnInteractionRequired` | `Func?` | No | PS interaction during token exchange (deferred consent) | +| `OnResourceInteraction` | `Func?` | No | Resource `202` + `requirement=interaction` (URL + code) | +| `OnApprovalPending` | `Func?` | No | Resource `202` + `requirement=approval` | +| `TokenRefresher` | `ITokenRefresher?` | No | Auto-refresh before token expiry (JWT identity); omit for HWK | +| `PollingTimeout` | `TimeSpan` | No | Max deferred polling time (default 5 minutes) | ### AAuthResourceOptions (AddAAuthResource) diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 57d9e6f..ec9f104 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -38,7 +38,7 @@ var key = AAuthKey.Generate(); // or load from persistent storage builder.Services.AddAAuthAgent("signing-only", options => { options.Key = key; - options.UseHwk(); + // No TokenRefresher set → the agent signs with HWK (pseudonymous) by default. }); ``` @@ -115,16 +115,13 @@ builder.Services.AddAAuthAgent("interactive", options => options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build(); - options.InteractionHandling = true; - options.InteractionHandlingOptions = io => + // A resource returning 202 + requirement=interaction surfaces here. + options.OnResourceInteraction = async (url, code, ct) => { - io.OnInteractionRequired = async (url, code, ct) => - { - // Present URL and code to user - logger.LogInformation("Approve at {Url} with code {Code}", url, code); - }; - io.PollingTimeout = TimeSpan.FromMinutes(3); + // Present URL and code to user + logger.LogInformation("Approve at {Url} with code {Code}", url, code); }; + options.PollingTimeout = TimeSpan.FromMinutes(3); }); ``` @@ -317,18 +314,12 @@ app.Run(); | Property | Type | Default | Description | |----------|------|---------|-------------| | `Key` | `IAAuthKey` | required | Agent signing key (must have private component) | -| `BaseAddress` | `Uri?` | `null` | Target resource URL | -| `SignatureKeyProvider` | `ISignatureKeyProvider?` | `null` | Custom signature key provider | -| `PersonServer` | `string?` | `null` | PS URL; with ChallengeHandling, enables challenge flow | -| `ChallengeHandling` | `bool` | `false` | Enable challenge handling | -| `ChallengeHandlingOptions` | `Action?` | `null` | Configure challenge handling behavior | -| `InteractionHandling` | `bool` | `false` | Enable interaction handling | -| `InteractionHandlingOptions` | `Action?` | `null` | Configure interaction handling behavior | -| `TokenRefresher` | `ITokenRefresher?` | `null` | Auto-refresh before token expiry | -| `RefreshThreshold` | `TimeSpan?` | `null` | Time before expiry to trigger refresh | -| `Capabilities` | `string[]?` | `null` | Agent capabilities to advertise | -| `InnerHandler` | `HttpMessageHandler?` | `null` | Custom inner HTTP handler | -| `CallChainProvider` | `Func?` | `null` | Provider for upstream auth token (call chaining) | +| `PersonServer` | `string?` | `null` | PS URL; with `TokenRefresher`, enables 401 challenge handling | +| `OnInteractionRequired` | `Func?` | `null` | PS interaction during token exchange (deferred consent) | +| `OnResourceInteraction` | `Func?` | `null` | Resource `202` + `requirement=interaction` (URL + code) | +| `OnApprovalPending` | `Func?` | `null` | Resource `202` + `requirement=approval` | +| `TokenRefresher` | `ITokenRefresher?` | `null` | Auto-refresh before token expiry (JWT identity); omit for HWK signing | +| `PollingTimeout` | `TimeSpan` | 5 minutes | Max deferred polling time | ### AAuthResourceOptions @@ -418,7 +409,8 @@ Server (`WithPersonServer`), and throws `InvalidOperationException` otherwise. S `AddAAuthGovernance()` registers the in-memory mission storage seams as singletons. It uses `TryAdd`, so register durable implementations first to override them. The policy and user-channel seams (`IPermissionDecider`, -`IAuditSink`, `IInteractionRelay`) are always supplied by the PS. +`IAuditSink`, `IInteractionRelay`) default to conservative no-op implementations; +a real PS overrides them. ```csharp builder.Services.AddAAuthGovernance(); // InMemoryMissionStore + InMemoryMissionLog diff --git a/docs/server/mission-governance.md b/docs/server/mission-governance.md index aac53db..0ab4a9a 100644 --- a/docs/server/mission-governance.md +++ b/docs/server/mission-governance.md @@ -68,7 +68,7 @@ decision to the seams: ```csharp var app = builder.Build(); -app.MapAAuthGovernance(); // /mission, /permission, /audit, /interaction + poll route +app.MapAAuthGovernance(); // /mission, /permission, /audit, /mission-interaction + poll route // Optional: override the default paths. app.MapAAuthGovernance(o => @@ -217,7 +217,7 @@ public sealed class MyPermissionDecider : IPermissionDecider // Pre-approved tool → granted silently. var blob = Mission.FromApprovalBytes(mission.Blob.Span); - if (blob.ApprovedTools.Any(t => t.Name == context.Request.Action)) + if (blob.ApprovedTools.Any(t => t.Name == context.Request.Action.Name)) { return new PermissionDecision(PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool); } diff --git a/docs/workflows/mission-governed-access.md b/docs/workflows/mission-governed-access.md index d9360fb..3ec1111 100644 --- a/docs/workflows/mission-governed-access.md +++ b/docs/workflows/mission-governed-access.md @@ -101,11 +101,11 @@ other action goes to the PS, which prompts the user when it is out of mission. ```csharp // Pre-approved tool → granted silently. -var send = await session.RequestPermissionAsync("email.send"); +var send = await session.RequestPermissionAsync(new MissionAction("email.send")); // Out-of-mission tool → the PS prompts the user (gate 3). var delete = await session.RequestPermissionAsync( - "files.delete", + new MissionAction("files.delete"), description: "Remove the duplicate receipt the user flagged."); if (!delete.IsGranted) @@ -121,7 +121,7 @@ fire-and-forget. ```csharp await session.RecordAuditAsync( - "email.send", + new MissionAction("email.send"), description: "Emailed the reconciliation summary to the user.", result: new JsonObject { ["recipients"] = 1 }); ``` From 71e0b1e085e9e151a305b958693ff56cd08451c8 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 05:11:58 +0000 Subject: [PATCH 18/24] =?UTF-8?q?fix(missions):=20align=20denial=20wire=20?= =?UTF-8?q?code=20with=20spec=20(access=5Fdenied=20=E2=86=92=20denied)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §Polling Error Codes defines the explicit-denial code as `denied`; `access_denied` (OAuth/RFC 6749 vocabulary) appears nowhere in the AAuth error tables. Rename every emitter, classifier, test, sample, narration, and doc snippet to `denied` with no backward-compat alias, since every emitter and classifier in this repo is ours and now round-trips `denied` end-to-end. Because the SDK's DeferredPoller already classifies the spec `denied` polling code as PollingErrorException(Denied), the high-level interaction wrappers (DeferredExchange.PollAsync, AccessServerClient.PollDeferredAsync) now translate that into the semantic AAuthInteractionDeniedException, preserving the public contract. The GuidedTour poll loop catches the same typed exception to record its terminal denied step. Resolves DEV-12. Build 0/0; AAuth.Tests 387/387; AAuth.Conformance 468/468; deny-path e2e (sample-app + guided-tour) green. --- .../implementation-plan.md | 5 ++- .../issues-and-deviations.md | 2 +- docs/advanced/interaction-chaining.md | 2 +- samples/GuidedTour/README.md | 2 +- samples/GuidedTour/TourSession.cs | 32 ++++++++++++------- .../playwright-tests/deferred.spec.ts | 2 +- .../playwright-tests/federated.spec.ts | 2 +- .../playwright-tests/mission.spec.ts | 4 +-- samples/MissionAgent/Program.cs | 4 +-- samples/MockAccessServer/Program.cs | 4 +-- samples/MockPersonServer/ConsentStore.cs | 4 +-- .../MockPersonServer/FederatedPendingStore.cs | 2 +- samples/MockPersonServer/Program.cs | 20 ++++++------ samples/MockPersonServer/README.md | 2 +- samples/Orchestrator/Program.cs | 6 ++-- .../SampleApp/Components/Pages/Mission.razor | 8 ++--- .../playwright-tests/mission.spec.ts | 6 ++-- .../Access/AAuthAccessServerEndpoints.cs | 6 ++-- src/AAuth/Access/AccessServerClient.cs | 18 ++++++++--- src/AAuth/Access/IAccessPendingStore.cs | 2 +- src/AAuth/Access/IAccessPolicy.cs | 4 +-- src/AAuth/Agent/AAuthInteractionExceptions.cs | 2 +- src/AAuth/Agent/DeferredExchange.cs | 12 +++++-- src/AAuth/Agent/TokenExchangeClient.cs | 10 +++--- ...hGovernanceApplicationBuilderExtensions.cs | 10 +++--- .../GovernanceDeferredConsentMapperTests.cs | 10 +++--- .../MockAccessServerKeycloakTests.cs | 6 ++-- .../Integration/MockAccessServerTests.cs | 4 +-- .../Integration/MockPersonServerTests.cs | 8 ++--- .../Integration/WhoAmIFlowTests.cs | 2 +- 30 files changed, 115 insertions(+), 86 deletions(-) diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md index 2f930eb..b91d85f 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -583,7 +583,10 @@ truth `src/AAuth/`. DEV-13 (S-2) and DEV-14 (S-3) logged intentional; S-4 already DEV-10. - [x] `issues-and-deviations.md` updated (DEV-11..DEV-14); `dotnet build AAuth.slnx` 0/0 (only `.md` files changed — no code touched). -- [ ] DEV-12 (`access_denied`→`denied`) decision returned by the user. +- [x] DEV-12 (`access_denied`→`denied`) full rename executed (user-approved, no alias): + all SDK emits/classifiers, AS path, sample mocks, SampleApp/GuidedTour narration, + conformance/integration tests, Playwright specs, and the docs snippet now use + `denied`; `dotnet build AAuth.slnx` 0/0. --- diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md index 1e93bfe..25b34aa 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md @@ -52,7 +52,7 @@ These judgment calls were made during Phase 1. **All confirmed by the user on | DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"For `interaction` and `payment` types, the PS relays the interaction to the user and **returns a deferred response**… The agent polls until the user completes the interaction."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) | | DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional | | DEV-11 | 11 | Docs & code snippets | The Phase 11 docs-vs-SDK grounding review (subagent) found doc snippets that would not compile against the current SDK: (a) `mission-governance-clients.md`, `mission-governed-access.md` passed **bare strings** to `RequestPermissionAsync`/`RecordAuditAsync`, which take a `MissionAction` (no implicit `string` conversion exists) — CS1503; (b) `server/mission-governance.md`'s `MyPermissionDecider` compared `t.Name == context.Request.Action` (string vs `MissionAction`) — CS0019; (c) `dependency-injection.md` + `configuration.md` documented `AAuthAgentOptions` members that do not exist (`UseHwk()`, `InteractionHandling`, `InteractionHandlingOptions`, `BaseAddress`, `ChallengeHandling*`, `RefreshThreshold`, `Capabilities`, `InnerHandler`, `CallChainProvider`, `SignatureKeyProvider`) and omitted the real ones (`OnResourceInteraction`, `OnApprovalPending`, `PollingTimeout`); (d) `error-handling.md`'s `TokenErrorCode` listing omitted `MissionTerminated`; (e) `server/mission-governance.md` comment said the interaction route is `/interaction` (actual default `/mission-interaction`); (f) `dependency-injection.md` claimed the PS-side seams are "always supplied by the PS" whereas `AddAAuthGovernance` registers no-op defaults via `TryAdd`. **Fix:** all corrected against `src/AAuth/` (the source of truth — samples already used `new MissionAction(...)`/`tool.ToAction()` and build 0/0). | (docs only — grounded against SDK) | fixed (Phase 11) | -| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code is emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. The SDK round-trips internally (the PS emits and the agent's classifier read the same string), so it works SDK-to-SDK, but a spec-conformant external PS returning `denied` would not be recognized. **Why not fixed inline:** the convention spans ~30 sites including the **out-of-scope** AccessServer path (`AccessServerClient`, `IAccessPolicy`, `IAccessPendingStore`), shared agent classifiers (`TokenExchangeClient.IsAccessDenied`, `DeferredExchange`), conformance/integration tests, and GuidedTour narration. A correct fix is a coordinated rename to `denied` with `access_denied` kept only as a read-side backward-compat alias, done as its own focused change with full test updates — a major cross-cutting decision surfaced for user input per the Working Agreement, not rushed into this PR. | §Polling Error Codes (L2023) | needs-decision | +| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code was emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. **Fix (user-approved full rename):** every site was renamed to `denied` with **no** backward-compat alias, since every emitter and classifier in this repo is ours and now round-trips `denied` end-to-end, keeping the system internally consistent and 100% spec-aligned. Scope covered: the SDK PS governance emits (`AAuthGovernanceApplicationBuilderExtensions`), the four-party AS path (`AAuthAccessServerEndpoints` 3 emits, `AccessServerClient.IsDeniedAsync` classifier — confirmed in-scope as those `403`/`AccessDecisionKind.Deny` responses are AAuth polling denials), the agent classifiers (`TokenExchangeClient.IsDeniedAsync`, `DeferredExchange` comments), interface/exception doc comments (`IAccessPolicy`, `IAccessPendingStore`, `AAuthInteractionExceptions`), all sample mocks (`MockPersonServer`, `Orchestrator`, `MockAccessServer`, `MissionAgent`), the SampleApp `Mission.razor` payload+narration, `GuidedTour` narration + classifier, all conformance/integration test asserts (incl. method `Pending_Returns403Denied_AfterDeny` and the Keycloak test emit), the Playwright specs, and `docs/advanced/interaction-chaining.md`. Spec is unaffected (`denied` is already the only denial code there). | §Polling Error Codes (L2023) | fixed (build 0/0) | | DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Left as-is (LOW); changing the status/shape would churn our own just-written conformance tests for no protocol-observable gain. | §Error Responses | intentional | | DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking) | diff --git a/docs/advanced/interaction-chaining.md b/docs/advanced/interaction-chaining.md index c4126c7..5ec093b 100644 --- a/docs/advanced/interaction-chaining.md +++ b/docs/advanced/interaction-chaining.md @@ -120,7 +120,7 @@ app.MapGet("/pending/{id}", async (HttpContext ctx, string id, PendingStore pend catch (AAuthInteractionDeniedException) { pending.Remove(id); - return Results.Json(new { error = "access_denied" }, statusCode: 403); + return Results.Json(new { error = "denied" }, statusCode: 403); } }); ``` diff --git a/samples/GuidedTour/README.md b/samples/GuidedTour/README.md index 9061f46..8abec96 100644 --- a/samples/GuidedTour/README.md +++ b/samples/GuidedTour/README.md @@ -115,7 +115,7 @@ Steps 1–4 are the same as **Direct Grant**. From step 5 onward: sequence diagram shows a loop box with a live spinner and poll count. The loop resolves in one of three ways: * **Approve** → 200 + `auth_token`; the loop box turns solid green. - * **Deny** → 403 + `{"error":"access_denied"}` → SDK throws + * **Deny** → 403 + `{"error":"denied"}` → SDK throws `AAuthInteractionDeniedException`; the loop box turns red. * **Polling budget expires** (5 minutes by default) → SDK throws `AAuthInteractionTimeoutException`; the loop box turns amber. diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 4ba0e49..b737e07 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -7,6 +7,7 @@ using AAuth.Agent; using AAuth.Crypto; using AAuth.Discovery; +using AAuth.Errors; using AAuth.Headers; using AAuth.HttpSig; using AAuth.Tokens; @@ -930,7 +931,7 @@ public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default) "consent against the mission via `POST /interaction/approve`; the " + "decision now accrues to the mission, so the agent may reuse this " + "scope for the rest of the session. The agent learns the verdict on " + - "its next poll. (A **Deny** here yields `access_denied`.)" + "its next poll. (A **Deny** here yields `denied`.)" : "The tour opened the PS's permission page in a new browser tab. The " + "Person Server showed that the agent wants to run **delete_inbox** \u2014 " + "an action that is **not** among the mission's pre-approved tools \u2014 " + @@ -1572,7 +1573,7 @@ private Task StepPollPendingAsync(CancellationToken ct) => "recorded the PS responds with `200 OK` and the long-awaited " + "`aa-auth+jwt`, bound (via `cnf.jwk`) to the agent's signing key. " + "If the user clicks **Deny** instead, this step records a " + - "`403 access_denied` and the flow aborts.", + "`403 denied` and the flow aborts.", RequestLine = $"{last.RequestLine} → {_pendingUrl}", RequestHeaders = last.RequestHeaders, SignatureBase = capturedBase, @@ -1590,7 +1591,7 @@ private Task StepPollPendingAsync(CancellationToken ct) => /// Shared pending-URL poll loop. Signs with , /// long-polls , and on terminal success invokes /// to add the flow-specific step record. - /// Denial (403 access_denied) and timeout are recorded uniformly and abort + /// Denial (403 denied) and timeout are recorded uniformly and abort /// the flow. / drive the actors /// on the denied/timeout step records. /// @@ -1651,13 +1652,13 @@ private async Task RunPendingPollAsync( { terminal = await poller.PollAsync(new Uri(_pendingUrl), ct); - // 403 access_denied → user clicked Deny on the PS consent + // 403 denied → user clicked Deny on the PS consent // page. Record a terminal "denied" step and abort the flow. if (terminal.StatusCode == HttpStatusCode.Forbidden) { var deniedBody = await terminal.Content.ReadAsStringAsync(ct); var deniedJson = JsonNode.Parse(deniedBody) as JsonObject; - if ((string?)deniedJson?["error"] == "access_denied") + if ((string?)deniedJson?["error"] == "denied") { RecordDeniedStep(capture.Last!, capturedBase, deniedBody, from, to); _aborted = true; @@ -1667,6 +1668,15 @@ private async Task RunPendingPollAsync( recordSuccess(capture.Last!, capturedBase); } + catch (PollingErrorException pex) when (pex.ErrorCode == PollingErrorCode.Denied) + { + // §Polling Error Codes: `denied` (403) is the explicit-denial code — + // the SDK's DeferredPoller raises it as a typed PollingErrorException. + // Record the terminal "denied" step and abort the flow. + RecordDeniedStep( + capture.Last!, capturedBase, "{\"error\":\"denied\"}", from, to); + _aborted = true; + } catch (TimeoutException tex) { // The user neither approved nor denied within the polling @@ -1769,13 +1779,13 @@ private void RecordDeniedStep( Steps.Add(new StepRecord { Number = Steps.Count + 1, - Title = "Poll pending URL → 403 access_denied (user denied)", + Title = "Poll pending URL → 403 denied (user denied)", From = from, To = to, Narrative = "The user clicked **Deny** on the PS's interaction page. The PS marked " + "the pending entry as denied and the next poll receives " + - "`403 Forbidden` with `error: \"access_denied\"`. The agent's SDK " + + "`403 Forbidden` with `error: \"denied\"`. The agent's SDK " + "raises `AAuthInteractionDeniedException` so callers can distinguish " + "denial from an unknown / expired pending id (which would be `404`). " + "The tour is now in a terminal state — click **Reset** to start over.", @@ -1860,7 +1870,7 @@ public async Task SimulateUserDenyAsync(CancellationToken ct = default) "The tour simulated the user clicking **Deny** on the PS's consent " + "page (`POST /interaction/deny` with the single-use code). The PS " + "marks the pending entry as denied; the next poll iteration will see " + - "`403 access_denied` and the flow will terminate.", + "`403 denied` and the flow will terminate.", ResponseBody = denyUrl, TokenDecoded = $"Simulated POST /interaction/deny (form: code={_interactionCode})\n" + @@ -2795,7 +2805,7 @@ private Task StepMissionPollCreateAsync(CancellationToken ct) => "(stored byte-for-byte) plus an `AAuth-Mission` header carrying the " + "`s256` thumbprint. The agent verifies `s256 == base64url(SHA-256(" + "blob))` and now holds a durable mission it can bind to later requests. " + - "If the user clicks **Deny**, this step records `403 access_denied`.", + "If the user clicks **Deny**, this step records `403 denied`.", RequestLine = $"{last.RequestLine} → {_pendingUrl}", RequestHeaders = last.RequestHeaders, SignatureBase = capturedBase, @@ -3046,7 +3056,7 @@ private async Task StepMissionElevatedExchangeAsync(CancellationToken ct) "fit. Unlike gate 2, the PS cannot mint silently: out-of-mission scopes " + "are **not** auto-denied, so it parks the request and returns `202` + an " + "interaction URL for the user to decide (gate 3). Only an explicit user " + - "**Deny** would yield `access_denied`.", + "**Deny** would yield `denied`.", RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}", RequestHeaders = ex.RequestHeaders, RequestBody = PrettyJson(ex.RequestBody), @@ -3076,7 +3086,7 @@ private Task StepMissionElevatedPollAsync(CancellationToken ct) => "`auth_token` carrying `whoami:elevated_scope`, bound to the agent's " + "signing key. The consent now accrues to the mission, so a later " + "elevated request would be silent. A **Deny** here records " + - "`403 access_denied`.", + "`403 denied`.", RequestLine = $"{last.RequestLine} → {_pendingUrl}", RequestHeaders = last.RequestHeaders, SignatureBase = capturedBase, diff --git a/samples/GuidedTour/playwright-tests/deferred.spec.ts b/samples/GuidedTour/playwright-tests/deferred.spec.ts index 869a47d..f97e536 100644 --- a/samples/GuidedTour/playwright-tests/deferred.spec.ts +++ b/samples/GuidedTour/playwright-tests/deferred.spec.ts @@ -84,7 +84,7 @@ test.describe('Deferred (Guided Tour)', () => { await denyInPopup(popup); // The flow aborts: the primary button locks to "Aborted" and the poll loop - // records a terminal denied step (403 access_denied). + // records a terminal denied step (403 denied). await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 }); await expect(doneSteps(page).last()).toContainText(/denied/i); }); diff --git a/samples/GuidedTour/playwright-tests/federated.spec.ts b/samples/GuidedTour/playwright-tests/federated.spec.ts index b734d1f..8425376 100644 --- a/samples/GuidedTour/playwright-tests/federated.spec.ts +++ b/samples/GuidedTour/playwright-tests/federated.spec.ts @@ -94,7 +94,7 @@ test.describe('Federated (Guided Tour)', () => { await denyInPopup(popup); // The flow aborts: the primary button locks to "Aborted" and the poll loop - // records a terminal denied step (403 access_denied). + // records a terminal denied step (403 denied). await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 }); await expect(doneSteps(page).last()).toContainText(/denied/i); }); diff --git a/samples/GuidedTour/playwright-tests/mission.spec.ts b/samples/GuidedTour/playwright-tests/mission.spec.ts index 46ecc65..49c3821 100644 --- a/samples/GuidedTour/playwright-tests/mission.spec.ts +++ b/samples/GuidedTour/playwright-tests/mission.spec.ts @@ -104,7 +104,7 @@ test.describe('Mission (Guided Tour)', () => { expect(elevated.scope).toEqual(['whoami:elevated_scope']); }); - test('deny at the elevated-scope gate yields access_denied', async ({ page, context }) => { + test('deny at the elevated-scope gate yields denied', async ({ page, context }) => { await openTour(page); await selectFlow(page, TourMode.Mission); @@ -130,7 +130,7 @@ test.describe('Mission (Guided Tour)', () => { await denyInPopup(elevatedPopup); // The flow aborts: the primary button locks to "Aborted" and the poll loop - // records a terminal denied step (403 access_denied). + // records a terminal denied step (403 denied). await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 }); await expect(doneSteps(page).last()).toContainText(/denied/i); }); diff --git a/samples/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs index dea050c..b257cca 100644 --- a/samples/MissionAgent/Program.cs +++ b/samples/MissionAgent/Program.cs @@ -241,7 +241,7 @@ await ScriptAsync(new JsonObject // cover it. The PS cannot grant it silently: out-of-mission scopes are NOT // auto-denied — the PS prompts the user (§Agent Token Request gate 3, §Scopes). // Approve in the browser and the consent accrues to the mission; deny and the -// exchange throws AAuthInteractionDeniedException (access_denied). Declaring +// exchange throws AAuthInteractionDeniedException (denied). Declaring // whoami:elevated_scope mission-approved (--mission-approved) makes this silent. if (elevatedScopeMissionApproved) { @@ -255,7 +255,7 @@ await ScriptAsync(new JsonObject } catch (AAuthInteractionDeniedException) { - Console.WriteLine(" elevated scope : denied by the user (access_denied) — the gate-2 token is unaffected"); + Console.WriteLine(" elevated scope : denied by the user (denied) — the gate-2 token is unaffected"); } Section("6. Request a permission for a pre-approved tool — silent"); diff --git a/samples/MockAccessServer/Program.cs b/samples/MockAccessServer/Program.cs index 1a2e97f..bbd3b0b 100644 --- a/samples/MockAccessServer/Program.cs +++ b/samples/MockAccessServer/Program.cs @@ -197,7 +197,7 @@ // ----------------------------------------------------------------------- // POST /interaction/deny — the stub AS consent screen's Deny button. Marks -// the pending entry Denied so the agent's next poll receives 403 access_denied. +// the pending entry Denied so the agent's next poll receives 403 denied. // ----------------------------------------------------------------------- app.MapPost("/interaction/deny", async (HttpContext ctx) => { @@ -374,7 +374,7 @@ public static string Denied(string agent, string resource, string scope) => "

Denied

" + $"

You denied {Enc(agent)}'s federated request for " + $"{Enc(resource)} at the Access Server. " - + "The agent's next poll will receive 403 access_denied.

" + + "The agent's next poll will receive 403 denied.

" + "

You can close this tab.

"); public static string NotFound() => diff --git a/samples/MockPersonServer/ConsentStore.cs b/samples/MockPersonServer/ConsentStore.cs index c247c48..38aaacf 100644 --- a/samples/MockPersonServer/ConsentStore.cs +++ b/samples/MockPersonServer/ConsentStore.cs @@ -45,7 +45,7 @@ public sealed record Entry( { /// /// True once the user has explicitly denied this request. The - /// pending endpoint returns 403 access_denied in that case + /// pending endpoint returns 403 denied in that case /// so the agent can distinguish denial from "unknown / expired". /// public bool Denied { get; internal set; } @@ -68,7 +68,7 @@ public Entry Add(string agent, string resource, string scope, string resourceTok /// /// Mark an entry as denied. Unlike , the /// entry stays in the store so the agent's poller gets a - /// deterministic 403 access_denied rather than an ambiguous + /// deterministic 403 denied rather than an ambiguous /// 404. /// public bool Deny(string id) diff --git a/samples/MockPersonServer/FederatedPendingStore.cs b/samples/MockPersonServer/FederatedPendingStore.cs index 5d032ea..6a6430b 100644 --- a/samples/MockPersonServer/FederatedPendingStore.cs +++ b/samples/MockPersonServer/FederatedPendingStore.cs @@ -46,7 +46,7 @@ public sealed class FederatedPendingEntry /// The AS-issued auth token, set once federation succeeds. public string? AuthToken { get; set; } - /// Relayed error code (e.g. access_denied) on failure. + /// Relayed error code (e.g. denied) on failure. public string? Error { get; set; } /// HTTP status to relay to the agent for . diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index a0ae272..8513b43 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -356,7 +356,7 @@ static bool IsAdminAgent(string agentId) => } catch (AAuthInteractionDeniedException) { - entry.Error = "access_denied"; + entry.Error = "denied"; entry.ErrorStatus = StatusCodes.Status403Forbidden; entry.Status = FederatedPendingStatus.Denied; } @@ -615,13 +615,13 @@ await missionLog.AppendAsync(new MissionLogEntry( return Results.NotFound(new { error = "unknown_pending", id }); } - // Explicit denial — return 403 access_denied so the agent can + // Explicit denial — return 403 denied so the agent can // distinguish "user said no" from "timed out / unknown id". if (entry.Denied) { ctx.Response.Headers["Cache-Control"] = "no-store"; return Results.Json( - new { error = "access_denied", detail = "the user denied this request" }, + new { error = "denied", detail = "the user denied this request" }, statusCode: StatusCodes.Status403Forbidden); } @@ -648,7 +648,7 @@ await missionLog.AppendAsync(new MissionLogEntry( // /token. While the PS's background FederateAsync drives the AS interaction // to completion, returns 202 + the relayed AS interaction requirement. Once // federation resolves it returns the AS-issued auth token (200) or the -// relayed AS error (403 access_denied / 402 / 502). +// relayed AS error (403 denied / 402 / 502). // ----------------------------------------------------------------------- app.MapGet("/federated-pending/{id}", (HttpContext ctx, string id, FederatedPendingStore fedPending) => { @@ -739,7 +739,7 @@ await missionLog.AppendAsync(new MissionLogEntry( if (!script.ApproveMissionProposal) { - return Results.Json(new { error = "access_denied" }, statusCode: StatusCodes.Status403Forbidden); + return Results.Json(new { error = "denied" }, statusCode: StatusCodes.Status403Forbidden); } // Interactive mode (§Mission Creation): mission approval is the most @@ -806,7 +806,7 @@ await missionLog.AppendAsync(new MissionLogEntry( { ctx.Response.Headers["Cache-Control"] = "no-store"; return Results.Json( - new { error = "access_denied", detail = "the user declined this mission" }, + new { error = "denied", detail = "the user declined this mission" }, statusCode: StatusCodes.Status403Forbidden); } @@ -1072,7 +1072,7 @@ await log.AppendAsync(new MissionLogEntry( { ctx.Response.Headers["Cache-Control"] = "no-store"; return Results.Json( - new { error = "access_denied", detail = "the user denied this request" }, + new { error = "denied", detail = "the user denied this request" }, statusCode: StatusCodes.Status403Forbidden); } var token = IssueAuthToken( @@ -1534,7 +1534,7 @@ await log.AppendAsync(new MissionLogEntry( // Deny handler. Marks the pending entry as denied (rather than removing // it) so the agent's next poll receives a deterministic -// `403 access_denied` instead of an ambiguous `404 unknown_pending`. +// `403 denied` instead of an ambiguous `404 unknown_pending`. app.MapPost("/interaction/deny", async (HttpContext ctx, PendingStore pending, MissionPendingStore missionPending) => { var code = (await ctx.Request.ReadFormAsync())["code"].ToString(); @@ -1556,7 +1556,7 @@ await log.AppendAsync(new MissionLogEntry( + ".badge .dot{width:.6rem;height:.6rem;border-radius:50%;background:#ddd6fe}" + "
Person Server — mission governance
" + "

Denied

" - + $"

You denied {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent's next poll will receive 403 access_denied.

" + + $"

You denied {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent's next poll will receive 403 denied.

" + "

You can close this tab.

", contentType: "text/html"); } @@ -1574,7 +1574,7 @@ await log.AppendAsync(new MissionLogEntry( + ".badge .dot{width:.6rem;height:.6rem;border-radius:50%;background:#bfdbfe}" + "
Person Server
" + "

Denied

" - + $"

You denied {System.Net.WebUtility.HtmlEncode(entry.Agent)}'s request at the Person Server. The agent's next poll will receive 403 access_denied.

" + + $"

You denied {System.Net.WebUtility.HtmlEncode(entry.Agent)}'s request at the Person Server. The agent's next poll will receive 403 denied.

" + "

You can close this tab.

", contentType: "text/html"); }).DisableAntiforgery(); diff --git a/samples/MockPersonServer/README.md b/samples/MockPersonServer/README.md index f2d484c..b779ac3 100644 --- a/samples/MockPersonServer/README.md +++ b/samples/MockPersonServer/README.md @@ -19,7 +19,7 @@ A minimal AAuth Person Server for end-to-end demos and integration tests. - **Approve** (`POST /interaction/approve`, or `POST /admin/consent` from a script) → next poll returns `200` with the `auth_token`. - **Deny** (`POST /interaction/deny`) → next poll returns `403` with - `{"error":"access_denied"}`. + `{"error":"denied"}`. - No action → the agent's polling budget eventually expires. - `GET /interaction` renders a tiny built-in consent page used by the `GuidedTour` "Open consent page" button. diff --git a/samples/Orchestrator/Program.cs b/samples/Orchestrator/Program.cs index 7948296..05038bd 100644 --- a/samples/Orchestrator/Program.cs +++ b/samples/Orchestrator/Program.cs @@ -253,7 +253,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) // at the PS). Returns: // * 202 + same requirement=interaction while still unconsented downstream // * 200 + combined chain result once the downstream auth token resolves -// * 403 access_denied if the user denied +// * 403 denied if the user denied // * 404 if the pending id is unknown // ----------------------------------------------------------------------- app.MapGet("/pending/{id}", async (HttpContext ctx, string id, PendingStore pending) => @@ -281,7 +281,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) pending.Remove(id); ctx.Response.Headers["Cache-Control"] = "no-store"; return Results.Json( - new { error = "access_denied", detail = "the user denied this request" }, + new { error = "denied", detail = "the user denied this request" }, statusCode: StatusCodes.Status403Forbidden); } }); @@ -312,7 +312,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) pending.Remove(id); ctx.Response.Headers["Cache-Control"] = "no-store"; return Results.Json( - new { error = "access_denied", detail = "the user denied this request" }, + new { error = "denied", detail = "the user denied this request" }, statusCode: StatusCodes.Status403Forbidden); } }); diff --git a/samples/SampleApp/Components/Pages/Mission.razor b/samples/SampleApp/Components/Pages/Mission.razor index 3571bed..2c497c7 100644 --- a/samples/SampleApp/Components/Pages/Mission.razor +++ b/samples/SampleApp/Components/Pages/Mission.razor @@ -103,7 +103,7 @@ var who = await ok.Content.ReadFromJsonAsync<JsonObject>(); // the profi // challenge -> exchange -> retry, but against an endpoint requiring // `whoami:elevated_scope`. The mission's intent does not cover this // scope, so the PS cannot grant it silently: it prompts the user. -// A user deny throws AAuthInteractionDeniedException (access_denied); +// A user deny throws AAuthInteractionDeniedException (denied); // otherwise the consent accrues to the mission (§Scopes, gate 3). var elevated = await ExchangeForScopeAsync( $"{resourceOrigin}/protected_endpoint/elevated", mission, governanceOptions); @@ -453,10 +453,10 @@ if (deleteInbox.IsGranted) _steps.Add(new GateStep(3, "Resource token (elevated)", Prompted: true, Outcome: "denied", Summary: "This scope falls outside the mission's intent; the PS prompted the user, " + - "who clicked Deny — so no auth_token was issued (access_denied).", - Note: "Only an explicit user deny (or a terminated mission) yields access_denied; " + + "who clicked Deny — so no auth_token was issued (denied).", + Note: "Only an explicit user deny (or a terminated mission) yields denied; " + "an out-of-mission scope on its own just prompts (§Scopes).", - Payload: "{\n \"error\": \"access_denied\"\n}", + Payload: "{\n \"error\": \"denied\"\n}", PayloadLabel: "Token exchange outcome")); } await InvokeAsync(StateHasChanged); diff --git a/samples/SampleApp/playwright-tests/mission.spec.ts b/samples/SampleApp/playwright-tests/mission.spec.ts index 301fc70..c930d0c 100644 --- a/samples/SampleApp/playwright-tests/mission.spec.ts +++ b/samples/SampleApp/playwright-tests/mission.spec.ts @@ -116,7 +116,7 @@ test.describe('Mission (SampleApp)', () => { await expect(gateCard(page, 5).locator('.badge.bg-success').last()).toHaveText('granted'); }); - test('deny at the elevated-scope gate records access_denied without affecting the prior token', async ({ page, context }) => { + test('deny at the elevated-scope gate records denied without affecting the prior token', async ({ page, context }) => { await page.goto('/mission'); await expect(page.locator('h2')).toContainText('Mission'); await waitForInteractive(page, 'button.btn-primary'); @@ -140,10 +140,10 @@ test.describe('Mission (SampleApp)', () => { // an earlier token). await expect(gateCard(page, 2).locator('.badge.bg-success').last()).toHaveText('granted'); - // Gate 3 — PROMPT, denied → access_denied. + // Gate 3 — PROMPT, denied → denied. await expect(gateCard(page, 3).locator('.badge.bg-warning')).toHaveText('prompt'); await expect(gateCard(page, 3).locator('.badge.bg-danger')).toHaveText('denied'); - await expect(gateCard(page, 3)).toContainText('access_denied'); + await expect(gateCard(page, 3)).toContainText('denied'); // Gate 5 — PROMPT, granted: denying gate 3 did not abort the mission. await expect(gateCard(page, 5).locator('.badge.bg-success').last()).toHaveText('granted'); diff --git a/src/AAuth/Access/AAuthAccessServerEndpoints.cs b/src/AAuth/Access/AAuthAccessServerEndpoints.cs index f21454a..c8fee2a 100644 --- a/src/AAuth/Access/AAuthAccessServerEndpoints.cs +++ b/src/AAuth/Access/AAuthAccessServerEndpoints.cs @@ -339,7 +339,7 @@ string Mint( { case AccessDecisionKind.Deny: return Results.Json( - new { error = "access_denied", detail = decision.Reason }, + new { error = "denied", detail = decision.Reason }, statusCode: StatusCodes.Status403Forbidden); case AccessDecisionKind.NeedsPayment: { @@ -425,7 +425,7 @@ string Mint( } case AccessPendingStatus.Denied: return Results.Json( - new { error = "access_denied", detail = entry.DenyReason }, + new { error = "denied", detail = entry.DenyReason }, statusCode: StatusCodes.Status403Forbidden); case AccessPendingStatus.Pending: default: @@ -525,7 +525,7 @@ string Mint( case AccessDecisionKind.Deny: pending.MarkDenied(entry.Id, decision.Reason ?? "access denied"); return Results.Json( - new { error = "access_denied", detail = decision.Reason }, + new { error = "denied", detail = decision.Reason }, statusCode: StatusCodes.Status403Forbidden); case AccessDecisionKind.NeedsClaims: default: diff --git a/src/AAuth/Access/AccessServerClient.cs b/src/AAuth/Access/AccessServerClient.cs index afb212f..19f57ee 100644 --- a/src/AAuth/Access/AccessServerClient.cs +++ b/src/AAuth/Access/AccessServerClient.cs @@ -203,12 +203,12 @@ public async Task FederateAsync( else { // The push response itself carries the verdict (200 - // auth_token, 403 access_denied, or a structured error). + // auth_token, 403 `denied`, or a structured error). response = pushResponse; } if (response.StatusCode == HttpStatusCode.Forbidden - && await IsAccessDeniedAsync(response, cancellationToken).ConfigureAwait(false)) + && await IsDeniedAsync(response, cancellationToken).ConfigureAwait(false)) { response.Dispose(); throw new AAuthInteractionDeniedException( @@ -236,7 +236,7 @@ public async Task FederateAsync( response = await PollDeferredAsync(pendingUrl, request.PollerOptions, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Forbidden - && await IsAccessDeniedAsync(response, cancellationToken).ConfigureAwait(false)) + && await IsDeniedAsync(response, cancellationToken).ConfigureAwait(false)) { response.Dispose(); throw new AAuthInteractionDeniedException( @@ -382,6 +382,14 @@ private async Task PollDeferredAsync( return await new DeferredPoller(_signedClient, options) .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); } + catch (PollingErrorException ex) when (ex.ErrorCode == PollingErrorCode.Denied) + { + // §Polling Error Codes: `denied` (403) is an explicit denial. Surface + // the semantic interaction-denied exception so callers can distinguish + // it from a transport-level polling failure. + throw new AAuthInteractionDeniedException( + "The Access Server denied the request.", ex); + } catch (TimeoutException ex) { throw new AAuthInteractionTimeoutException( @@ -424,7 +432,7 @@ private static Uri ResolveSameOriginLocation(HttpResponseMessage response, Uri @ return pendingUrl; } - private static async Task IsAccessDeniedAsync( + private static async Task IsDeniedAsync( HttpResponseMessage response, CancellationToken cancellationToken) { // Buffer the body so a subsequent ReadAuthTokenAsync still sees it, @@ -448,7 +456,7 @@ private static async Task IsAccessDeniedAsync( try { var json = JsonNode.Parse(body) as JsonObject; - return (string?)json?["error"] == "access_denied"; + return (string?)json?["error"] == "denied"; } catch (System.Text.Json.JsonException) { diff --git a/src/AAuth/Access/IAccessPendingStore.cs b/src/AAuth/Access/IAccessPendingStore.cs index 54b7bd7..afad308 100644 --- a/src/AAuth/Access/IAccessPendingStore.cs +++ b/src/AAuth/Access/IAccessPendingStore.cs @@ -43,7 +43,7 @@ public enum AccessPendingStatus /// Approved — the next poll mints the auth token. Allowed, - /// Denied — the next poll returns 403 access_denied. + /// Denied — the next poll returns 403 denied. Denied, } diff --git a/src/AAuth/Access/IAccessPolicy.cs b/src/AAuth/Access/IAccessPolicy.cs index 3469e00..ea7a22f 100644 --- a/src/AAuth/Access/IAccessPolicy.cs +++ b/src/AAuth/Access/IAccessPolicy.cs @@ -10,7 +10,7 @@ namespace AAuth.Access; /// Policy Decision Point for the AS token endpoint: given the verified request /// context it returns an the /// MapAAuthAccessServer host helper turns into the spec-mandated wire -/// response (mint, 403 access_denied, 202 requirement=claims, +/// response (mint, 403 denied, 202 requirement=claims, /// 202 requirement=interaction, or 402 Payment Required). AAuth /// crypto stays in the host; the policy only decides. ///
@@ -69,7 +69,7 @@ public enum AccessDecisionKind /// Grant access — mint the auth token. Allow, - /// Deny access — 403 access_denied. + /// Deny access — 403 denied. Deny, /// An interactive user login/consent is required (§Trust Establishment). diff --git a/src/AAuth/Agent/AAuthInteractionExceptions.cs b/src/AAuth/Agent/AAuthInteractionExceptions.cs index 4277f7b..4548eb0 100644 --- a/src/AAuth/Agent/AAuthInteractionExceptions.cs +++ b/src/AAuth/Agent/AAuthInteractionExceptions.cs @@ -6,7 +6,7 @@ namespace AAuth.Agent; /// /// Thrown when a deferred AAuth interaction terminates with explicit /// user denial. Surfaced when the PS responds to a pending-URL poll -/// with 403 and a body containing error: "access_denied". +/// with 403 and a body containing error: "denied". /// /// /// Distinct from a generic diff --git a/src/AAuth/Agent/DeferredExchange.cs b/src/AAuth/Agent/DeferredExchange.cs index fd40a9a..928c861 100644 --- a/src/AAuth/Agent/DeferredExchange.cs +++ b/src/AAuth/Agent/DeferredExchange.cs @@ -44,7 +44,7 @@ internal sealed class DeferredExchangeOptions /// /// Invoked after each poll in the interaction branch, before the loop /// re-checks for a 202. Token exchange uses this to classify a polled - /// 403 access_denied; the callback may throw. = + /// 403 denied; the callback may throw. = /// no-op. /// public Func? OnPolledResponse { get; init; } @@ -197,7 +197,7 @@ internal async Task PostAsync( response = await PollAsync(pendingUrl, options.PollerOptions, cancellationToken).ConfigureAwait(false); - // Token exchange classifies a polled 403 access_denied here (only + // Token exchange classifies a polled 403 denied here (only // after an interaction poll, matching the original placement). if (options.OnPolledResponse is not null) { @@ -238,6 +238,14 @@ private async Task PollAsync( return await new DeferredPoller(_signedClient, composed) .PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false); } + catch (PollingErrorException ex) when (ex.ErrorCode == PollingErrorCode.Denied) + { + // §Polling Error Codes: `denied` (403) is an explicit user/approver + // denial. Surface the semantic interaction-denied exception so callers + // can distinguish it from a transport-level polling failure. + throw new AAuthInteractionDeniedException( + "The user denied the AAuth interaction request.", ex); + } catch (TimeoutException ex) { throw new AAuthInteractionTimeoutException( diff --git a/src/AAuth/Agent/TokenExchangeClient.cs b/src/AAuth/Agent/TokenExchangeClient.cs index 8bec9d7..3b431e9 100644 --- a/src/AAuth/Agent/TokenExchangeClient.cs +++ b/src/AAuth/Agent/TokenExchangeClient.cs @@ -123,13 +123,13 @@ public async Task ExchangeAsync( // Token exchange cannot complete consent without an interaction // callback, so any deferred 202 with no callback fails fast. RequireInteractionCallback = true, - // §User Interaction: a user denial surfaces as 403 access_denied on + // §Polling Error Codes: a user denial surfaces as 403 `denied` on // the poll. Classify it only after an interaction poll (matching the // original placement) so a direct/clarification 403 stays a token error. OnPolledResponse = async (resp, ct) => { if (resp.StatusCode == HttpStatusCode.Forbidden - && await IsAccessDeniedAsync(resp, ct).ConfigureAwait(false)) + && await IsDeniedAsync(resp, ct).ConfigureAwait(false)) { throw new AAuthInteractionDeniedException( "The user denied the AAuth interaction request."); @@ -169,16 +169,16 @@ private static IReadOnlyList InferCapabilities( return capabilities; } - private static async Task IsAccessDeniedAsync( + private static async Task IsDeniedAsync( HttpResponseMessage response, CancellationToken cancellationToken) { // Buffer the body so the subsequent ReadAuthTokenAsync (if we - // decide it isn't access_denied) still sees it. + // decide it isn't a denial) still sees it. var body = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); try { var json = JsonNode.Parse(body) as JsonObject; - return (string?)json?["error"] == "access_denied"; + return (string?)json?["error"] == "denied"; } catch (System.Text.Json.JsonException) { diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs index 14dd9f7..46c6ee1 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs @@ -107,7 +107,7 @@ private static async Task HandleMissionAsync( { case MissionApprovalOutcome.Declined: return Results.Json( - new { error = "access_denied", detail = decision.Message }, + new { error = "denied", detail = decision.Message }, statusCode: StatusCodes.Status403Forbidden); case MissionApprovalOutcome.Prompt: @@ -117,7 +117,7 @@ private static async Task HandleMissionAsync( { // No user channel: a prompt cannot be resolved — decline. return Results.Json( - new { error = "access_denied" }, statusCode: StatusCodes.Status403Forbidden); + new { error = "denied" }, statusCode: StatusCodes.Status403Forbidden); } var parked = await store.ParkAsync(new DeferredConsent { @@ -324,7 +324,7 @@ await log.AppendAsync(new MissionLogEntry( // Resolve a parked deferred consent once the user has decided (§Deferred // Consent). Pending → 202 again; approved/declined → the final governance - // response (mission blob / permission decision / access_denied). + // response (mission blob / permission decision / denied). private static async Task HandlePendingAsync( HttpContext ctx, string id, @@ -358,7 +358,7 @@ private static async Task HandlePendingAsync( { ctx.Response.Headers.CacheControl = "no-store"; return Results.Json( - new { error = "access_denied", detail = "the user declined this mission" }, + new { error = "denied", detail = "the user declined this mission" }, statusCode: StatusCodes.Status403Forbidden); } var proposal = entry.Proposal!; @@ -375,7 +375,7 @@ private static async Task HandlePendingAsync( return Results.Json(new { status = "ok" }); } - // Permission: the endpoint always returns a decision (200), never access_denied. + // Permission: the endpoint always returns a decision (200), never a denial. var request = entry.Permission!; var granted = entry.Decision.Value; if (request.Mission is not null) diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs index 6e4ded2..55e761a 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs @@ -124,7 +124,7 @@ public async Task Mission_NoAgentToken_Unauthorized() ((IDisposable)app).Dispose(); } - [Fact(DisplayName = "§Mission Creation — a declining approver yields 403 access_denied")] + [Fact(DisplayName = "§Mission Creation — a declining approver yields 403 denied")] public async Task Mission_DecliningApprover_Forbidden() { using var host = await BuildHostAsync(s => @@ -136,7 +136,7 @@ public async Task Mission_DecliningApprover_Forbidden() Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); var json = await ReadJson(response); - Assert.Equal("access_denied", (string?)json?["error"]); + Assert.Equal("denied", (string?)json?["error"]); await host.StopAsync(); } @@ -176,7 +176,7 @@ public async Task Mission_Prompt_Parks202_ThenApprovalCompletes() await host.StopAsync(); } - [Fact(DisplayName = "§Deferred Consent — a declined mission poll resolves to 403 access_denied")] + [Fact(DisplayName = "§Deferred Consent — a declined mission poll resolves to 403 denied")] public async Task Mission_Prompt_Declined_Forbidden() { using var host = await BuildHostAsync(s => @@ -197,7 +197,7 @@ public async Task Mission_Prompt_Declined_Forbidden() using var done = await client.GetAsync("https://localhost" + location); Assert.Equal(HttpStatusCode.Forbidden, done.StatusCode); var json = await ReadJson(done); - Assert.Equal("access_denied", (string?)json?["error"]); + Assert.Equal("denied", (string?)json?["error"]); await host.StopAsync(); } @@ -231,7 +231,7 @@ public async Task Permission_Prompt_Parks202_ThenGrant() await host.StopAsync(); } - [Fact(DisplayName = "§Deferred Consent — a declined permission poll resolves to a denied decision (200, not access_denied)")] + [Fact(DisplayName = "§Deferred Consent — a declined permission poll resolves to a denied decision (200, not denied)")] public async Task Permission_Prompt_Declined_ReturnsDenied() { using var host = await BuildHostAsync(s => diff --git a/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs b/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs index 7a0d83e..d28d327 100644 --- a/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs +++ b/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs @@ -28,7 +28,7 @@ namespace AAuth.Tests.Integration; /// 2. The user's browser completes /interaction/callback (the AS /// exchanges the code and asks Keycloak for the uma-ticket decision). /// 3. The PS polls /pending/{id}200 auth_token (allow) or -/// 403 access_denied (deny), mirroring the PS deferred shape. +/// 403 denied (deny), mirroring the PS deferred shape. /// The stub Keycloak grants whoami to anyone and whoami:admin /// only when the claim_token carries the whoami-admin role. ///
@@ -107,7 +107,7 @@ public async Task InteractiveFlow_DeniesAdminScope_ForNonAdminAgent() Assert.Equal(HttpStatusCode.Forbidden, poll.StatusCode); var body = await poll.Content.ReadFromJsonAsync(); - Assert.Equal("access_denied", (string?)body!["error"]); + Assert.Equal("denied", (string?)body!["error"]); } [Fact] @@ -296,7 +296,7 @@ protected override async Task SendAsync( var hasAdminRole = HasAdminRole(form.GetValueOrDefault("claim_token")); return (!elevated || hasAdminRole) ? Json(HttpStatusCode.OK, new JsonObject { ["result"] = true }) - : Json(HttpStatusCode.Forbidden, new JsonObject { ["error"] = "access_denied" }); + : Json(HttpStatusCode.Forbidden, new JsonObject { ["error"] = "denied" }); } return new HttpResponseMessage(HttpStatusCode.BadRequest); diff --git a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs index 1c7b630..d12b600 100644 --- a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs +++ b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs @@ -184,7 +184,7 @@ public async Task Token_GrantsElevatedScope_ForAdminAgent() public async Task Token_DeniesElevatedScope_ForNonAdminAgent() { // A non-admin agent requesting whoami:admin is denied by the stub - // policy (no whoami-admin role) → 403 access_denied. + // policy (no whoami-admin role) → 403 denied. const string GuestId = "aauth:guest@ap.test"; var agentKey = AAuthKey.Generate(); var agentToken = BuildAgentToken(agentKey, GuestId); @@ -199,7 +199,7 @@ public async Task Token_DeniesElevatedScope_ForNonAdminAgent() Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); - Assert.Equal("access_denied", (string?)body!["error"]); + Assert.Equal("denied", (string?)body!["error"]); } [Fact] diff --git a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs index 154ee92..c9bf122 100644 --- a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs +++ b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs @@ -468,12 +468,12 @@ public async Task Interaction_PostApproveWithUnknownCode_Returns404() } [Fact] - public async Task Pending_Returns403AccessDenied_AfterDeny() + public async Task Pending_Returns403Denied_AfterDeny() { // Verifies the deny path: POST /interaction/deny marks the // pending entry as denied (rather than removing it), and the // subsequent /pending/{id} poll surfaces a deterministic 403 - // with body { error: "access_denied" }. This is what + // with body { error: "denied" }. This is what // AAuthInteractionDeniedException is keyed off in the SDK. var agentKey = AAuthKey.Generate(); var agentId = "aauth:denier@ap.example"; @@ -497,12 +497,12 @@ public async Task Pending_Returns403AccessDenied_AfterDeny() })); Assert.True(deny.IsSuccessStatusCode); - // Agent's next poll → 403 access_denied (not 404 / not 202). + // Agent's next poll → 403 denied (not 404 / not 202). var pendingPath = initial.Headers.Location!.OriginalString; using var pending = await signedClient.GetAsync(pendingPath); Assert.Equal(HttpStatusCode.Forbidden, pending.StatusCode); var body = await pending.Content.ReadFromJsonAsync(); - Assert.Equal("access_denied", (string?)body!["error"]); + Assert.Equal("denied", (string?)body!["error"]); } // ---------------------------------------------------------------- diff --git a/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs b/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs index 9a4f8bd..4a70f62 100644 --- a/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs +++ b/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs @@ -516,7 +516,7 @@ public async Task ThreePartyUserConsentFlow_ThrowsAAuthInteractionDenied_WhenUse // Same plumbing as the approval test, but the interaction // callback simulates the user clicking Deny instead of Approve. // The PS marks the pending entry as denied and the agent's - // next /pending/{id} poll receives 403 + access_denied. The + // next /pending/{id} poll receives 403 + denied. The // SDK must surface that as AAuthInteractionDeniedException // rather than a generic HttpRequestException. WebApplicationFactory? consentWhoAmI = null; From 9cab71fab5b3acdbe8861ae4e8cd72ad4dc9ef8c Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 16:51:56 +0000 Subject: [PATCH 19/24] feat(missions): add one-call Person Server SDK (MapAAuthPersonServer) Introduce a DI-friendly Person Server helper that mints AAuth person tokens in a single mapped endpoint, routing on the resource token audience for three-party (PS-issued) and four-party (trusted-AS federation) flows. - Person/AAuthPersonServerEndpoints.cs: MapAAuthPersonServer extension + AAuthPersonServerOptions; carrier-type guard, mission three-gate packaging, NeedsConsent -> 202 interaction requirement - Person/IIdentityClaimsAsserter.cs: identity assertion seam (Assert/Deny/NeedsConsent) with a default always-assert implementation - Person/IPersonPendingStore.cs: pending interaction store for deferred consent resolution - Server/Governance/DelegateInteractionRelay.cs + DI extensions: AddAAuthInteractionRelay lambda registration - Wire MockPersonServer onto the SDK helper - Conformance: Person/PersonServerMapperTests (8) covering both branches, carrier guard, deny, deferred consent, and mission gates --- samples/MockPersonServer/Program.cs | 8 +- src/AAuth/Agent/DeferredExchange.cs | 13 +- ...hGovernanceApplicationBuilderExtensions.cs | 42 +- ...thGovernanceServiceCollectionExtensions.cs | 20 + .../Person/AAuthPersonServerEndpoints.cs | 698 ++++++++++++++++++ src/AAuth/Person/IIdentityClaimsAsserter.cs | 171 +++++ src/AAuth/Person/IPersonPendingStore.cs | 235 ++++++ .../Governance/DelegateInteractionRelay.cs | 30 + .../Governance/IDeferredConsentStore.cs | 8 + .../GovernanceDeferredConsentMapperTests.cs | 133 +++- .../Person/PersonServerMapperTests.cs | 335 +++++++++ .../Agent/InteractionChainingTests.cs | 9 +- .../Integration/MockPersonServerTests.cs | 2 +- 13 files changed, 1691 insertions(+), 13 deletions(-) create mode 100644 src/AAuth/Person/AAuthPersonServerEndpoints.cs create mode 100644 src/AAuth/Person/IIdentityClaimsAsserter.cs create mode 100644 src/AAuth/Person/IPersonPendingStore.cs create mode 100644 src/AAuth/Server/Governance/DelegateInteractionRelay.cs create mode 100644 tests/AAuth.Conformance/Person/PersonServerMapperTests.cs diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index 8513b43..9167a5e 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -207,14 +207,14 @@ static bool IsAdminAgent(string agentId) => { return Results.Json( new { error = "invalid_carrier_token", detail = $"expected {AAuthConstants.TokenTypes.AgentToken}, got {tokenType}" }, - statusCode: StatusCodes.Status401Unauthorized); + statusCode: StatusCodes.Status403Forbidden); } var agentId = (string?)parsed.Payload?["sub"]; if (string.IsNullOrEmpty(agentId)) { return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" }, - statusCode: StatusCodes.Status401Unauthorized); + statusCode: StatusCodes.Status403Forbidden); } JsonObject? body; @@ -705,12 +705,12 @@ await missionLog.AppendAsync(new MissionLogEntry( var parsed = ctx.GetAAuthParsedKey()!; if (ctx.GetAAuthTokenType() != AAuthTokenType.AgentToken) { - return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status401Unauthorized); + return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status403Forbidden); } var agentId = (string?)parsed.Payload?["sub"]; if (string.IsNullOrEmpty(agentId)) { - return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" }, statusCode: StatusCodes.Status401Unauthorized); + return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" }, statusCode: StatusCodes.Status403Forbidden); } JsonObject? body; diff --git a/src/AAuth/Agent/DeferredExchange.cs b/src/AAuth/Agent/DeferredExchange.cs index 928c861..031bf66 100644 --- a/src/AAuth/Agent/DeferredExchange.cs +++ b/src/AAuth/Agent/DeferredExchange.cs @@ -179,8 +179,17 @@ internal async Task PostAsync( { var status = (int)response.StatusCode; response.Dispose(); - throw new HttpRequestException( - $"PS returned {status} (deferred response) but no onInteractionRequired callback was provided."); + // The PS deferred for user interaction but the agent supplied no + // interaction callback and did not declare the `interaction` + // capability — there is no channel to the user. Surface the + // terminal `user_unreachable` error (draft-02 §Token Endpoint + // Error Codes) so callers can branch on it, instead of a generic + // transport failure. + throw new AAuthTokenExchangeException( + new TokenErrorResponse(TokenErrorCode.UserUnreachable).ErrorCode, + $"PS returned {status} (deferred response) but no onInteractionRequired callback was provided.", + statusCode: 400, + isTerminal: true); } var interaction = requirement is null ? null : Interaction.FromRequirement(requirement); diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs index 46c6ee1..d670265 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs @@ -80,7 +80,12 @@ private static async Task HandleMissionAsync( var verification = ctx.GetAAuthVerification(); if (verification?.TokenType != AAuthTokenType.AgentToken || string.IsNullOrEmpty(verification.Agent)) { - return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status401Unauthorized); + // The signature already verified (this is past the verification + // middleware); presenting a non-agent token is a semantic authorization + // refusal, not a signature-authentication failure, so it is a 403 — the + // §Error Responses 401/`Signature-Error` rule is reserved for the + // §Verification (Server) signature-failure steps. + return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status403Forbidden); } var body = await ReadJsonAsync(ctx).ConfigureAwait(false); @@ -291,6 +296,26 @@ await log.AppendAsync(new MissionLogEntry( return Results.Json(new { answer = result.Answer ?? string.Empty }); case InteractionType.Completion: + // §Interaction Response: "The PS returns a deferred response while + // the user reviews." When the relay is still pending and a store is + // registered, park the completion and answer 202 + poll; the poll + // resolves to terminated (accepted) or active (follow-up). Without a + // store there is no user channel, so the relay's synchronous + // Accepted result is honored directly. + if (result.Pending) + { + var completionStore = ctx.RequestServices.GetService(); + if (completionStore is not null) + { + var parkedCompletion = await completionStore.ParkAsync(new DeferredConsent + { + Kind = DeferredConsentKind.Completion, + Approver = request.Mission?.Approver ?? string.Empty, + Interaction = request, + }, ctx.RequestAborted).ConfigureAwait(false); + return DeferredAccepted(ctx, options, parkedCompletion.Id); + } + } if (result.Accepted == true && request.Mission is not null) { await missions.SetStateAsync(request.Mission.S256, MissionState.Terminated).ConfigureAwait(false); @@ -375,6 +400,21 @@ private static async Task HandlePendingAsync( return Results.Json(new { status = "ok" }); } + if (entry.Kind == DeferredConsentKind.Completion) + { + // The user finished reviewing the completion summary (§Interaction + // Response). Accept → terminate the mission; follow-up/decline → the + // mission stays active. + var accepted = entry.Decision.Value; + var completionMission = entry.Interaction?.Mission; + if (accepted && completionMission is not null) + { + await missions.SetStateAsync(completionMission.S256, MissionState.Terminated).ConfigureAwait(false); + return Results.Json(new { mission_status = "terminated" }); + } + return Results.Json(new { mission_status = "active" }); + } + // Permission: the endpoint always returns a decision (200), never a denial. var request = entry.Permission!; var granted = entry.Decision.Value; diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs index c282f70..37fe943 100644 --- a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ using System; +using AAuth.Agent.Governance; using AAuth.Server.Governance; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.DependencyInjection; @@ -37,6 +40,23 @@ public static IServiceCollection AddAAuthGovernance(this IServiceCollection serv return services; } + /// + /// Register an backed by a + /// delegate, so a PS can supply its user channel with a lambda instead of a full + /// class (§Interaction Endpoint). Replaces any relay registered earlier (including + /// the no-op ). + /// + public static IServiceCollection AddAAuthInteractionRelay( + this IServiceCollection services, + Func> relay) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(relay); + services.RemoveAll(); + services.AddSingleton(new DelegateInteractionRelay(relay)); + return services; + } + /// /// Opt the governance mapper into the deferred-consent (202 poll) flow /// for / diff --git a/src/AAuth/Person/AAuthPersonServerEndpoints.cs b/src/AAuth/Person/AAuthPersonServerEndpoints.cs new file mode 100644 index 0000000..105abaa --- /dev/null +++ b/src/AAuth/Person/AAuthPersonServerEndpoints.cs @@ -0,0 +1,698 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AAuth.Access; +using AAuth.Agent; +using AAuth.Crypto; +using AAuth.Discovery; +using AAuth.Errors; +using AAuth.Headers; +using AAuth.HttpSig; +using AAuth.Server.Governance; +using AAuth.Server.Metadata; +using AAuth.Server.Verification; +using AAuth.Tokens; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AAuth.Person; + +/// +/// Configuration for . +/// +public sealed class AAuthPersonServerOptions +{ + /// HTTPS URL of this Person Server (iss of minted auth tokens). + public required string Issuer { get; init; } + + /// + /// The PS signing keys, keyed by kid. Published at the JWKS and used + /// to sign minted auth tokens (the first entry signs). + /// + public required IReadOnlyDictionary SigningKeys { get; init; } + + /// The token endpoint path. Default /token. + public string TokenPath { get; init; } = "/token"; + + /// The pending (poll) path prefix. Default /pending. + public string PendingPathPrefix { get; init; } = "/pending"; + + /// + /// The fallback scope when the resource token carries none. Default + /// whoami. + /// + public string DefaultScope { get; init; } = "whoami"; + + /// + /// The PS-hosted interaction/consent path advertised on + /// requirement=interaction. Default /interaction. The caller + /// maps this endpoint and resolves the verdict against the shared + /// . + /// + public string InteractionPath { get; init; } = "/interaction"; + + /// + /// Access Server URLs this PS will federate to (four-party). When a resource + /// token's aud identifies one of these, the PS forwards a signed + /// PS→AS request via instead of minting + /// itself. Empty disables the four-party branch (every request must be + /// audienced to this PS). + /// + public IReadOnlyCollection? TrustedAccessServers { get; init; } +} + +/// +/// Maps the Person Server token endpoint, pending poll endpoint, and well-known +/// metadata in one call — the three-/four-party counterpart to +/// MapAAuthAccessServer. The AAuth crypto (signature verification, +/// resource-token verification, the auth-token mint, the §Auth Token Delivery +/// check, and PS→AS federation) lives here; only the identity + consent +/// decision is delegated to the DI-registered +/// . When a request carries a mission +/// claim, the host packages the mission three-gate model (terminated rejection, +/// prior-consent silent grant, and park-and-prompt) over the +/// / primitives. +/// +public static class AAuthPersonServerEndpoints +{ + /// + /// Configure the PS pipeline: publish /.well-known/aauth-person.json + /// + JWKS, add the request-signature verification middleware (excluding the + /// well-known and interaction paths), and map the token + pending endpoints. + /// Resolves , , + /// , , and + /// from DI. The mission gate additionally + /// resolves and ; + /// call-chaining resolves ; the + /// four-party branch resolves . + /// + public static WebApplication MapAAuthPersonServer( + this WebApplication app, + AAuthPersonServerOptions options) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(options); + + if (options.SigningKeys.Count == 0) + { + throw new InvalidOperationException("AAuthPersonServerOptions.SigningKeys must contain at least one key."); + } + + string signingKid = string.Empty; + AAuthKey signingKey = null!; + foreach (var (kid, key) in options.SigningKeys) + { + signingKid = kid; + signingKey = key; + break; + } + + var issuer = options.Issuer.TrimEnd('/'); + var interactionPath = "/" + options.InteractionPath.Trim('/'); + var interactionPrefix = interactionPath.Split('/', StringSplitOptions.RemoveEmptyEntries) is { Length: > 0 } seg + ? "/" + seg[0] + : interactionPath; + var interactionUrl = $"{issuer}{interactionPath}"; + + var trustedAccessServers = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var asUrl in options.TrustedAccessServers ?? Array.Empty()) + { + trustedAccessServers.Add(asUrl.TrimEnd('/')); + } + + // 1. Well-known metadata + JWKS (reachable without a signature). + WellKnownEndpoints.MapAAuthPersonServerWellKnown(app, new AAuthPersonServerMetadataOptions + { + Issuer = options.Issuer, + TokenEndpoint = $"{issuer}{options.TokenPath}", + SigningKeys = new Dictionary(options.SigningKeys), + InteractionEndpoint = interactionUrl, + }); + + // 2. Verification middleware. The agent signs with the jwt scheme + // (RequireIssuerVerification=false); the browser-facing interaction + // endpoint carries no signature, so exclude it. + app.UseWhen( + ctx => !ctx.Request.Path.StartsWithSegments("/.well-known") + && !ctx.Request.Path.StartsWithSegments(interactionPrefix), + branch => branch.UseAAuthVerification(new AAuthVerificationOptions + { + RequireIssuerVerification = false, + })); + + var tokenVerifier = app.Services.GetRequiredService(); + var metadataClient = app.Services.GetRequiredService(); + var jwksClient = app.Services.GetRequiredService(); + var asserter = app.Services.GetRequiredService(); + var pending = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger("AAuth.PersonServer"); + + string MintEntry(PersonPendingEntry entry) => Mint( + entry.ResourceUrl, entry.AgentId, entry.Scope, entry.AgentConfirmationKey!, + entry.Subject ?? "pairwise-sub", entry.Tenant, entry.Roles, entry.Groups, + entry.AdditionalClaims, entry.UpstreamAct, entry.Mission); + + string Mint( + string resourceUrl, string agentId, string scope, IAAuthKey confirmationKey, + string subject, string? tenant, IReadOnlyList? roles, IReadOnlyList? groups, + IReadOnlyDictionary? additionalClaims, JsonObject? upstreamAct, MissionClaim? mission) => + new AuthTokenBuilder + { + Issuer = options.Issuer, + Audience = resourceUrl, + Agent = agentId, + AgentConfirmationKey = confirmationKey, + Key = signingKey, + KeyId = signingKid, + Subject = subject, + Scope = scope, + Tenant = tenant, + Roles = roles, + Groups = groups, + AdditionalClaims = additionalClaims, + UpstreamAct = upstreamAct, + Mission = mission, + }.Build(); + + // ------------------------------------------------------------------- + // POST {TokenPath} — the PS token endpoint (§Agent Token Request). + // ------------------------------------------------------------------- + app.MapPost(options.TokenPath, async (HttpContext ctx) => + { + var parsed = ctx.GetAAuthParsedKey()!; + + // Only an agent token may exchange — a signature-verified carrier of + // the wrong type is an authorization refusal (403), not a 401 + // signature failure (§Error Responses reserves 401 + Signature-Error + // for the §Verification steps, which already passed). + if (ctx.GetAAuthTokenType() != AAuthTokenType.AgentToken) + { + return Results.Json( + new { error = "invalid_carrier_token", detail = $"expected {AAuthConstants.TokenTypes.AgentToken}, got {ctx.GetAAuthTokenType()}" }, + statusCode: StatusCodes.Status403Forbidden); + } + + var agentId = (string?)parsed.Payload?["sub"]; + if (string.IsNullOrEmpty(agentId)) + { + return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" }, + statusCode: StatusCodes.Status403Forbidden); + } + + JsonObject? body; + try + { + body = await ctx.Request.ReadFromJsonAsync(); + } + catch (System.Text.Json.JsonException) + { + return Results.Json(new { error = "invalid_request", detail = "body is not valid JSON" }, + statusCode: StatusCodes.Status400BadRequest); + } + + var resourceTokenJwt = (string?)body?["resource_token"]; + if (string.IsNullOrEmpty(resourceTokenJwt)) + { + return Results.Json(new { error = "invalid_request", detail = "missing resource_token" }, + statusCode: StatusCodes.Status400BadRequest); + } + + var upstreamTokenJwt = (string?)body?["upstream_token"]; + + // Route on the resource token's `aud` (peeked, not trusted; both + // branches fully verify the token afterwards). `aud == this PS` → + // three-party collapsed mint; `aud == an AS` → four-party federation. + var resourceAudience = PeekJwtAudience(resourceTokenJwt); + if (resourceAudience is not null + && !string.Equals(resourceAudience.TrimEnd('/'), issuer, StringComparison.OrdinalIgnoreCase)) + { + return await HandleFederatedAsync( + ctx, parsed, agentId, resourceTokenJwt, upstreamTokenJwt, resourceAudience); + } + + return await HandleThreePartyAsync( + ctx, parsed, agentId, resourceTokenJwt, upstreamTokenJwt); + }); + + // ------------------------------------------------------------------- + // GET {PendingPathPrefix}/{id} — the agent polls the deferred verdict. + // ------------------------------------------------------------------- + app.MapGet($"{options.PendingPathPrefix}/{{id}}", async (HttpContext ctx, string id) => + { + var entry = pending.Get(id); + if (entry is null) + { + return Results.NotFound(new { error = "unknown_interaction" }); + } + + // Four-party entries resolve via the background federation task. + if (entry.AgentConfirmationKey is null) + { + if (entry.Status == PersonPendingStatus.Allowed && entry.AuthToken is not null) + { + return Results.Ok(new { auth_token = entry.AuthToken }); + } + if (entry.Status == PersonPendingStatus.Denied) + { + if (!string.IsNullOrEmpty(entry.ErrorLocation)) + { + ctx.Response.Headers.Location = entry.ErrorLocation; + } + return Results.Json( + new { error = entry.Error ?? "denied" }, + statusCode: entry.ErrorStatus ?? StatusCodes.Status403Forbidden); + } + return Pending202(ctx, entry, options, interactionUrl); + } + + // Three-party entries resolve when the host's interaction page marks + // the verdict against the shared store. + switch (entry.Status) + { + case PersonPendingStatus.Allowed: + if (entry.Mission is not null) + { + await AppendMissionGrantAsync(app, entry, "Consent"); + } + return Results.Ok(new { auth_token = MintEntry(entry) }); + case PersonPendingStatus.Denied: + return Results.Json( + new { error = "denied", detail = entry.DenyReason }, + statusCode: StatusCodes.Status403Forbidden); + case PersonPendingStatus.Pending: + default: + return Pending202(ctx, entry, options, interactionUrl); + } + }); + + return app; + + // ---- three-party (PS-asserted) handler ----------------------------- + async Task HandleThreePartyAsync( + HttpContext ctx, SignatureKeyParser.ParsedSignatureKeyInfo parsed, + string agentId, string resourceTokenJwt, string? upstreamTokenJwt) + { + // Call-chaining: validate upstream_token (§Upstream Token Verification). + JsonObject? upstreamAct = null; + if (!string.IsNullOrEmpty(upstreamTokenJwt)) + { + var validator = app.Services.GetRequiredService(); + var intermediaryResourceUrl = (string?)parsed.Payload?["iss"] + ?? throw new InvalidOperationException("Agent token missing 'iss' claim."); + var result = await validator.ValidateAsync( + upstreamTokenJwt, + expectedAudience: intermediaryResourceUrl, + new HashSet { issuer }); + if (!result.IsValid) + { + return Results.Json(new { error = "invalid_upstream_token", detail = result.Error }, + statusCode: StatusCodes.Status400BadRequest); + } + upstreamAct = result.UpstreamAct; + } + + // Verify the resource token (§Resource Token Verification). `iss` + // becomes the auth token's `aud`; `scope` is echoed; `mission` (if + // present) governs the request. + string audience; + var requestedScope = options.DefaultScope; + MissionClaim? missionClaim; + try + { + var verified = await tokenVerifier.VerifyResourceTokenAsync( + resourceTokenJwt, + expectedAudience: options.Issuer, + expectedAgentId: agentId, + expectedAgentJkt: parsed.ConfirmationKey!.ComputeJwkThumbprint(), + metadataClient, jwksClient); + + audience = (string?)verified.Payload["iss"] + ?? throw new TokenVerificationException("resource_token missing iss"); + var scopeClaim = (string?)verified.Payload["scope"]; + if (!string.IsNullOrWhiteSpace(scopeClaim)) + { + requestedScope = scopeClaim; + } + missionClaim = MissionClaim.FromPayload(verified.Payload); + } + catch (TokenVerificationException ex) + { + var expired = ex.Message.Contains("expired", StringComparison.OrdinalIgnoreCase); + return Results.Json( + new { error = expired ? "expired_resource_token" : "invalid_resource_token", detail = ex.Message }, + statusCode: StatusCodes.Status401Unauthorized); + } + + // Mission gate (§Agent Token Request, three-gate model). + if (missionClaim is not null) + { + var missionStore = app.Services.GetRequiredService(); + var missionLog = app.Services.GetRequiredService(); + var s256 = missionClaim.S256; + + // Gate 1: a terminated mission is rejected outright. + var stored = await missionStore.GetAsync(s256); + if (stored is { State: MissionState.Terminated }) + { + return GovernanceEndpoints.MissionTerminated(); + } + + // Gate 2b: prior consent for this (resource, scope) → silent grant. + if (await missionLog.HasPriorConsentAsync(s256, audience, requestedScope)) + { + await AppendMissionTokenAsync(missionLog, s256, audience, requestedScope, "PriorConsent"); + var asserted = await asserter.AssertAsync(new IdentityAssertionRequest + { + ResourceUrl = audience, + Scope = requestedScope, + AgentId = agentId, + Mission = missionClaim, + }); + return MintFromAssertion( + asserted, audience, agentId, requestedScope, + parsed.ConfirmationKey!, upstreamAct, missionClaim) + ?? Results.Json(new { error = "denied", detail = asserted.Reason }, + statusCode: StatusCodes.Status403Forbidden); + } + + // Gate 2a / 3: the asserter decides in-scope (silent) vs prompt. + var decision = await asserter.AssertAsync(new IdentityAssertionRequest + { + ResourceUrl = audience, + Scope = requestedScope, + AgentId = agentId, + Mission = missionClaim, + }); + switch (decision.Kind) + { + case IdentityAssertionKind.Assert: + await AppendMissionTokenAsync(missionLog, s256, audience, requestedScope, "InScope"); + return Results.Ok(new + { + auth_token = Mint( + audience, agentId, requestedScope, parsed.ConfirmationKey!, + decision.Subject ?? "pairwise-sub", decision.Tenant, decision.Roles, + decision.Groups, decision.AdditionalClaims, upstreamAct, missionClaim), + }); + case IdentityAssertionKind.Deny: + return Results.Json(new { error = "denied", detail = decision.Reason }, + statusCode: StatusCodes.Status403Forbidden); + case IdentityAssertionKind.NeedsConsent: + default: + var missionEntry = pending.Add( + audience, requestedScope, agentId, parsed.ConfirmationKey, upstreamAct, missionClaim); + return Pending202(ctx, missionEntry, options, interactionUrl); + } + } + + // Non-mission three-party path. + var assertion = await asserter.AssertAsync(new IdentityAssertionRequest + { + ResourceUrl = audience, + Scope = requestedScope, + AgentId = agentId, + }); + switch (assertion.Kind) + { + case IdentityAssertionKind.Assert: + return Results.Ok(new + { + auth_token = Mint( + audience, agentId, requestedScope, parsed.ConfirmationKey!, + assertion.Subject ?? "pairwise-sub", assertion.Tenant, assertion.Roles, + assertion.Groups, assertion.AdditionalClaims, upstreamAct, mission: null), + }); + case IdentityAssertionKind.Deny: + return Results.Json(new { error = "denied", detail = assertion.Reason }, + statusCode: StatusCodes.Status403Forbidden); + case IdentityAssertionKind.NeedsConsent: + default: + var entry = pending.Add(audience, requestedScope, agentId, parsed.ConfirmationKey, upstreamAct); + return Pending202(ctx, entry, options, interactionUrl); + } + } + + // ---- four-party (federated) handler -------------------------------- + async Task HandleFederatedAsync( + HttpContext ctx, SignatureKeyParser.ParsedSignatureKeyInfo parsed, + string agentId, string resourceTokenJwt, string? upstreamTokenJwt, string resourceAudience) + { + if (trustedAccessServers.Count == 0 + || !trustedAccessServers.Contains(resourceAudience.TrimEnd('/'))) + { + return Results.Json( + new { error = "untrusted_access_server", detail = $"'{resourceAudience}' is not a trusted Access Server." }, + statusCode: StatusCodes.Status403Forbidden); + } + + // Verify the resource token's agent binding before forwarding it. + string resourceUrl; + var federatedScope = options.DefaultScope; + try + { + var verified = await tokenVerifier.VerifyResourceTokenAsync( + resourceTokenJwt, + expectedAudience: resourceAudience, + expectedAgentId: agentId, + expectedAgentJkt: parsed.ConfirmationKey!.ComputeJwkThumbprint(), + metadataClient, jwksClient); + + resourceUrl = (string?)verified.Payload["iss"] + ?? throw new TokenVerificationException("resource_token missing iss"); + var scopeClaim = (string?)verified.Payload["scope"]; + if (!string.IsNullOrWhiteSpace(scopeClaim)) + { + federatedScope = scopeClaim; + } + } + catch (TokenVerificationException ex) + { + var expired = ex.Message.Contains("expired", StringComparison.OrdinalIgnoreCase); + return Results.Json( + new { error = expired ? "expired_resource_token" : "invalid_resource_token", detail = ex.Message }, + statusCode: StatusCodes.Status401Unauthorized); + } + + var federation = app.Services.GetRequiredService(); + var entry = pending.Add(resourceUrl, federatedScope, agentId, agentConfirmationKey: null); + + var agentTokenJwt = parsed.Jwt + ?? throw new InvalidOperationException("Agent token JWT unavailable on the verified request."); + var agentConfirmationKey = parsed.ConfirmationKey!; + var fedRequest = new AccessServerRequest + { + ResourceToken = resourceTokenJwt, + AgentToken = agentTokenJwt, + UpstreamToken = upstreamTokenJwt, + ExpectedAudience = resourceUrl, + ExpectedAgentId = agentId, + AgentKey = agentConfirmationKey, + RequestedScope = federatedScope, + OnInteractionRequired = (interaction, _) => + { + entry.InteractionUrl = interaction.Url; + entry.InteractionCode = interaction.Code; + entry.FirstAnswer.TrySetResult(); + return Task.CompletedTask; + }, + // The AS needs identity claims (§Claims Required) for its policy + // decision. The PS is the identity authority — answer via the + // same asserter, mapping its Assert into the directed claims push. + OnClaimsRequired = async (claimsRequirement, ct) => + { + var asserted = await asserter.AssertAsync(new IdentityAssertionRequest + { + ResourceUrl = resourceUrl, + Scope = federatedScope, + AgentId = agentId, + RequiredClaims = claimsRequirement.RequiredClaims, + }, ct); + return new ClaimsResponse + { + Subject = asserted.Subject ?? "pairwise-sub", + Claims = ProjectClaims(asserted, claimsRequirement.RequiredClaims), + }; + }, + }; + + _ = Task.Run(async () => + { + try + { + var token = await federation.FederateAsync(resourceAudience, fedRequest); + entry.AuthToken = token; + entry.Status = PersonPendingStatus.Allowed; + } + catch (AAuthInteractionDeniedException) + { + entry.Error = "denied"; + entry.ErrorStatus = StatusCodes.Status403Forbidden; + entry.Status = PersonPendingStatus.Denied; + } + catch (AAuthTokenExchangeException ex) + { + entry.Error = ex.ErrorCode; + entry.ErrorStatus = ex.StatusCode; + entry.Status = PersonPendingStatus.Denied; + } + catch (AAuthPaymentRequiredException ex) + { + entry.Error = "payment_required"; + entry.ErrorStatus = StatusCodes.Status402PaymentRequired; + entry.ErrorLocation = ex.Location; + entry.Status = PersonPendingStatus.Denied; + } + catch (Exception ex) + { + entry.Error = "federation_failed"; + entry.ErrorStatus = StatusCodes.Status502BadGateway; + logger.LogWarning(ex, "Four-party federation to {AccessServer} failed.", resourceAudience); + entry.Status = PersonPendingStatus.Denied; + } + finally + { + entry.FirstAnswer.TrySetResult(); + } + }); + + await entry.FirstAnswer.Task; + + if (entry.InteractionUrl is not null) + { + ctx.Response.Headers.Location = $"{options.PendingPathPrefix}/{entry.Id}"; + ctx.Response.Headers["Retry-After"] = "1"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = + Interaction.Format(entry.InteractionUrl, entry.InteractionCode!); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + + if (entry.Status == PersonPendingStatus.Allowed) + { + return Results.Ok(new { auth_token = entry.AuthToken }); + } + + if (!string.IsNullOrEmpty(entry.ErrorLocation)) + { + ctx.Response.Headers.Location = entry.ErrorLocation; + } + return Results.Json( + new { error = entry.Error ?? "denied" }, + statusCode: entry.ErrorStatus ?? StatusCodes.Status403Forbidden); + } + + IResult? MintFromAssertion( + IdentityAssertion asserted, string audience, string agentId, string scope, + IAAuthKey confirmationKey, JsonObject? upstreamAct, MissionClaim? mission) + { + if (asserted.Kind != IdentityAssertionKind.Assert) + { + return null; + } + return Results.Ok(new + { + auth_token = Mint( + audience, agentId, scope, confirmationKey, + asserted.Subject ?? "pairwise-sub", asserted.Tenant, asserted.Roles, + asserted.Groups, asserted.AdditionalClaims, upstreamAct, mission), + }); + } + } + + private static IResult Pending202( + HttpContext ctx, PersonPendingEntry entry, AAuthPersonServerOptions options, string interactionUrl) + { + ctx.Response.Headers.Location = $"{options.PendingPathPrefix}/{entry.Id}"; + ctx.Response.Headers["Retry-After"] = "0"; + ctx.Response.Headers["Cache-Control"] = "no-store"; + ctx.Response.Headers[AAuthRequirementHeader.Name] = Interaction.Format(interactionUrl, entry.Id); + return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted); + } + + private static async Task AppendMissionGrantAsync(WebApplication app, PersonPendingEntry entry, string detail) + { + var missionLog = app.Services.GetRequiredService(); + await AppendMissionTokenAsync(missionLog, entry.Mission!.S256, entry.ResourceUrl, entry.Scope, detail); + } + + private static Task AppendMissionTokenAsync( + IMissionLog missionLog, string s256, string resource, string scope, string detail) + => missionLog.AppendAsync(new MissionLogEntry(s256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow) + { + Resource = resource, + Scope = scope, + Granted = true, + Detail = detail, + }); + + // Project the asserter's claims (tenant/roles/groups/additional) into the + // §Claims Required push payload, limited to the names the AS requested. + private static IReadOnlyDictionary ProjectClaims( + IdentityAssertion asserted, IReadOnlyList requiredClaims) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var name in requiredClaims) + { + switch (name) + { + case "tenant" when asserted.Tenant is not null: + result["tenant"] = asserted.Tenant; + break; + case "roles" when asserted.Roles is not null: + result["roles"] = new JsonArray(System.Linq.Enumerable.ToArray( + System.Linq.Enumerable.Select(asserted.Roles, r => (JsonNode?)r))); + break; + case "groups" when asserted.Groups is not null: + result["groups"] = new JsonArray(System.Linq.Enumerable.ToArray( + System.Linq.Enumerable.Select(asserted.Groups, g => (JsonNode?)g))); + break; + default: + if (asserted.AdditionalClaims is not null + && asserted.AdditionalClaims.TryGetValue(name, out var value)) + { + result[name] = value?.DeepClone(); + } + break; + } + } + return result; + } + + // Peek the `aud` claim of a (possibly unverified) compact JWT without + // checking its signature — used only to ROUTE the request (three- vs + // four-party). Both branches fully verify the token afterwards. + private static string? PeekJwtAudience(string jwt) + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return null; + } + JsonObject? payload; + try + { + payload = JsonNode.Parse(Base64UrlDecode(parts[1])) as JsonObject; + } + catch (System.Text.Json.JsonException) + { + return null; + } + return payload?["aud"] switch + { + JsonValue v => v.GetValue(), + JsonArray { Count: > 0 } a => (string?)a[0], + _ => null, + }; + } + + private static string Base64UrlDecode(string segment) + { + var s = segment.Replace('-', '+').Replace('_', '/'); + s += (s.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty }; + return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(s)); + } +} diff --git a/src/AAuth/Person/IIdentityClaimsAsserter.cs b/src/AAuth/Person/IIdentityClaimsAsserter.cs new file mode 100644 index 0000000..8eb3182 --- /dev/null +++ b/src/AAuth/Person/IIdentityClaimsAsserter.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Tokens; + +namespace AAuth.Person; + +/// +/// Pluggable Person Server identity/consent seam — the PS counterpart to +/// AAuth.Access.IAccessPolicy. Given the verified token-request context +/// it returns an the +/// MapAAuthPersonServer host helper turns into the spec-mandated wire +/// response: mint the auth token (asserting a directed sub + identity +/// claims and confirming consent), 403 denied, or +/// 202 requirement=interaction while the user reviews. AAuth crypto +/// (resource-token verification, the auth-token mint, the §Auth Token Delivery +/// check, AS federation) stays in the host; the asserter only decides. +/// +/// +/// In a three-party (PS-asserted) exchange the asserter supplies the identity +/// and consent decision directly. In a four-party (federated) exchange the +/// same asserter answers the AS's §Claims Required push: the host maps an +/// into the directed sub + claims +/// pushed to the AS. The host packages the mission three-gate model around the +/// asserter (terminated rejection and prior-consent silent grant use the +/// IMissionStore/IMissionLog primitives); the asserter owns the +/// in-scope / prompt policy decision for a mission-bound request. +/// +public interface IIdentityClaimsAsserter +{ + /// Decide identity + consent for the request and return an assertion. + Task AssertAsync( + IdentityAssertionRequest request, CancellationToken cancellationToken = default); +} + +/// The verified context an decides on. +public sealed class IdentityAssertionRequest +{ + /// The resource URL the auth token will be audienced to (the resource token's iss). + public required string ResourceUrl { get; init; } + + /// The requested scope (from the resource token). + public required string Scope { get; init; } + + /// The verified agent identifier (the agent token's sub). + public required string AgentId { get; init; } + + /// + /// The claim names the recipient asked for. In a four-party exchange these + /// are the AS's §Claims Required names; in a three-party exchange this is + /// (the resource applies its own policy on whatever + /// the PS asserts). + /// + public IReadOnlyList? RequiredClaims { get; init; } + + /// + /// The mission context (if any) the resource token carried. When set, the + /// request is governed by the mission; the asserter decides whether the + /// (resource, scope) is within the mission's approved intent (silent + /// ) or needs the user + /// (). + /// + public MissionClaim? Mission { get; init; } + + /// The pending-entry id when the request resumes a parked consent. + public string? InteractionId { get; init; } +} + +/// The kinds of decision an can return. +public enum IdentityAssertionKind +{ + /// Assert identity + consent — mint the auth token (or push the claims). + Assert, + + /// Deny the request — 403 denied. + Deny, + + /// The user must review/consent first (§Interaction → 202). + NeedsConsent, +} + +/// +/// The outcome of an evaluation. Use the +/// static factory methods rather than the constructor so each decision kind +/// carries only the fields that apply to it. +/// +public sealed class IdentityAssertion +{ + private IdentityAssertion( + IdentityAssertionKind kind, + string? subject = null, + string? tenant = null, + IReadOnlyList? roles = null, + IReadOnlyList? groups = null, + IReadOnlyDictionary? additionalClaims = null, + string? reason = null) + { + Kind = kind; + Subject = subject; + Tenant = tenant; + Roles = roles; + Groups = groups; + AdditionalClaims = additionalClaims; + Reason = reason; + } + + /// The decision kind. + public IdentityAssertionKind Kind { get; } + + /// The directed (pairwise) user identifier — the auth token's sub. + public string? Subject { get; } + + /// The asserted tenant claim, if any. + public string? Tenant { get; } + + /// The asserted role claims, if any. + public IReadOnlyList? Roles { get; } + + /// The asserted group claims, if any. + public IReadOnlyList? Groups { get; } + + /// Any further asserted identity claims (e.g. email), if any. + public IReadOnlyDictionary? AdditionalClaims { get; } + + /// Human-readable denial reason (). + public string? Reason { get; } + + /// + /// Assert identity + consent. is the directed + /// sub; the remaining fields are optional asserted identity claims. + /// + public static IdentityAssertion Assert( + string subject, + string? tenant = null, + IReadOnlyList? roles = null, + IReadOnlyList? groups = null, + IReadOnlyDictionary? additionalClaims = null) + => new(IdentityAssertionKind.Assert, subject, tenant, roles, groups, additionalClaims); + + /// Deny the request with a reason. + public static IdentityAssertion Deny(string reason) + => new(IdentityAssertionKind.Deny, reason: reason); + + /// Require the user to review/consent before the request resolves. + public static IdentityAssertion NeedsConsent() + => new(IdentityAssertionKind.NeedsConsent); +} + +/// +/// The default : asserts a fixed directed +/// sub and no further claims, with no consent prompt. Suitable for a +/// non-interactive demo PS; a production PS swaps in an implementation that +/// derives the principal's directed identity and consent decision. +/// +public sealed class DefaultIdentityClaimsAsserter : IIdentityClaimsAsserter +{ + private readonly string _subject; + + /// Create the default asserter. + /// The directed sub to assert. Default pairwise-sub. + public DefaultIdentityClaimsAsserter(string subject = "pairwise-sub") + { + _subject = subject; + } + + /// + public Task AssertAsync( + IdentityAssertionRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(IdentityAssertion.Assert(_subject)); +} diff --git a/src/AAuth/Person/IPersonPendingStore.cs b/src/AAuth/Person/IPersonPendingStore.cs new file mode 100644 index 0000000..96e13a5 --- /dev/null +++ b/src/AAuth/Person/IPersonPendingStore.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AAuth.Crypto; +using AAuth.Tokens; + +namespace AAuth.Person; + +/// +/// Stores in-flight Person Server token decisions awaiting an interactive user +/// review/consent round-trip (§Interaction), and — in the four-party +/// (federated) flow — the background PS→AS federation result. The +/// MapAAuthPersonServer host parks the mint inputs here when the asserter +/// defers, and resumes (mint or deny) when the agent polls the pending URL. The +/// PS counterpart to AAuth.Access.IAccessPendingStore. +/// +public interface IPersonPendingStore +{ + /// Park a new pending decision and return the created entry. + PersonPendingEntry Add( + string resourceUrl, + string scope, + string agentId, + IAAuthKey? agentConfirmationKey, + JsonObject? upstreamAct = null, + MissionClaim? mission = null); + + /// Look up a pending entry by id, or . + PersonPendingEntry? Get(string id); + + /// + /// Mark the entry allowed with the asserted identity the next poll mints. + /// The host's interaction page calls this once the user has consented. + /// + void MarkAllowed( + string id, + string subject, + string? tenant = null, + IReadOnlyList? roles = null, + IReadOnlyList? groups = null, + IReadOnlyDictionary? additionalClaims = null); + + /// Mark the entry denied with a reason. + void MarkDenied(string id, string reason); +} + +/// The lifecycle state of a . +public enum PersonPendingStatus +{ + /// Awaiting the user review/consent (or background federation). + Pending, + + /// Approved — the next poll mints (or returns) the auth token. + Allowed, + + /// Denied — the next poll returns 403 denied. + Denied, +} + +/// A parked Person Server token decision. +public sealed class PersonPendingEntry +{ + /// Opaque pending id (path segment of the Location URL). + public required string Id { get; init; } + + /// The resource URL the auth token will be audienced to. + public required string ResourceUrl { get; init; } + + /// The requested scope. + public required string Scope { get; init; } + + /// The verified agent identifier. + public required string AgentId { get; init; } + + /// + /// The agent's confirmation key (cnf.jwk binding) — set for the + /// three-party path where the PS mints. for the + /// four-party path where the AS mints and the PS only relays. + /// + public IAAuthKey? AgentConfirmationKey { get; init; } + + /// Optional upstream act context for call chaining. + public JsonObject? UpstreamAct { get; init; } + + /// The mission context governing the request, if any. + public MissionClaim? Mission { get; init; } + + /// The entry's lifecycle state. + public PersonPendingStatus Status { get; set; } + + /// The directed sub the asserter supplied on approval. + public string? Subject { get; set; } + + /// The asserted tenant claim, if any. + public string? Tenant { get; set; } + + /// The asserted role claims, if any. + public IReadOnlyList? Roles { get; set; } + + /// The asserted group claims, if any. + public IReadOnlyList? Groups { get; set; } + + /// Any further asserted identity claims, if any. + public IReadOnlyDictionary? AdditionalClaims { get; set; } + + /// The denial reason when is Denied. + public string? DenyReason { get; set; } + + /// + /// The AS-issued auth token, set when the four-party federation completes + /// successfully. When present, the next poll returns it verbatim (the AS + /// minted it; the PS does not re-mint). + /// + public string? AuthToken { get; set; } + + /// The AS interaction URL to relay to the agent (four-party). + public string? InteractionUrl { get; set; } + + /// The AS interaction code to relay to the agent (four-party). + public string? InteractionCode { get; set; } + + /// An error code surfaced by a failed federation, if any. + public string? Error { get; set; } + + /// The HTTP status to surface for , if any. + public int? ErrorStatus { get; set; } + + /// A Location to surface alongside (e.g. payment), if any. + public string? ErrorLocation { get; set; } + + /// + /// Completes when the four-party federation produces its first answer + /// (an AS interaction to relay, or a terminal result). Runtime-only. + /// + public TaskCompletionSource FirstAnswer { get; } + = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// When the entry was parked. Drives in-memory TTL eviction. + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; +} + +/// +/// Process-wide in-memory . Suitable for a +/// single-instance demo/sample; a production PS would persist entries with a +/// TTL. Entries are evicted once they exceed (lazily, on each +/// /) so the dictionary does not grow without +/// bound. +/// +public sealed class InMemoryPersonPendingStore : IPersonPendingStore +{ + /// How long a parked entry is retained before it is evicted. + public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10); + + private readonly ConcurrentDictionary _entries = new(); + + /// + public PersonPendingEntry Add( + string resourceUrl, + string scope, + string agentId, + IAAuthKey? agentConfirmationKey, + JsonObject? upstreamAct = null, + MissionClaim? mission = null) + { + Sweep(); + var entry = new PersonPendingEntry + { + Id = Guid.NewGuid().ToString("N"), + ResourceUrl = resourceUrl, + Scope = scope, + AgentId = agentId, + AgentConfirmationKey = agentConfirmationKey, + UpstreamAct = upstreamAct, + Mission = mission, + Status = PersonPendingStatus.Pending, + }; + _entries[entry.Id] = entry; + return entry; + } + + /// + public PersonPendingEntry? Get(string id) + { + Sweep(); + return _entries.TryGetValue(id, out var entry) ? entry : null; + } + + /// + public void MarkAllowed( + string id, + string subject, + string? tenant = null, + IReadOnlyList? roles = null, + IReadOnlyList? groups = null, + IReadOnlyDictionary? additionalClaims = null) + { + if (_entries.TryGetValue(id, out var entry)) + { + entry.Subject = subject; + entry.Tenant = tenant; + entry.Roles = roles; + entry.Groups = groups; + entry.AdditionalClaims = additionalClaims; + entry.Status = PersonPendingStatus.Allowed; + } + } + + /// + public void MarkDenied(string id, string reason) + { + if (_entries.TryGetValue(id, out var entry)) + { + entry.Status = PersonPendingStatus.Denied; + entry.DenyReason = reason; + } + } + + /// Remove all entries (test helper). + public void Clear() => _entries.Clear(); + + /// Evict entries older than . + private void Sweep() + { + var cutoff = DateTimeOffset.UtcNow - Ttl; + foreach (var kv in _entries) + { + if (kv.Value.CreatedAt < cutoff) + { + _entries.TryRemove(kv.Key, out _); + } + } + } +} diff --git a/src/AAuth/Server/Governance/DelegateInteractionRelay.cs b/src/AAuth/Server/Governance/DelegateInteractionRelay.cs new file mode 100644 index 0000000..ea1c00c --- /dev/null +++ b/src/AAuth/Server/Governance/DelegateInteractionRelay.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent.Governance; + +namespace AAuth.Server.Governance; + +/// +/// An backed by a delegate, so a PS can supply a +/// user channel with a lambda instead of a full class (§Interaction Endpoint). The +/// delegate receives the parsed and returns the +/// outcome. +/// +public sealed class DelegateInteractionRelay : IInteractionRelay +{ + private readonly Func> _relay; + + /// Create a relay from an async delegate. + public DelegateInteractionRelay(Func> relay) + { + _relay = relay ?? throw new ArgumentNullException(nameof(relay)); + } + + /// + public Task RelayAsync(InteractionRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + return _relay(request, ct); + } +} diff --git a/src/AAuth/Server/Governance/IDeferredConsentStore.cs b/src/AAuth/Server/Governance/IDeferredConsentStore.cs index be994af..9325a98 100644 --- a/src/AAuth/Server/Governance/IDeferredConsentStore.cs +++ b/src/AAuth/Server/Governance/IDeferredConsentStore.cs @@ -22,6 +22,14 @@ public enum DeferredConsentKind /// until the user completes the interaction. /// Interaction, + + /// + /// A completion summary the user is reviewing (§Interaction Response: + /// "The PS returns a deferred response while the user reviews."). The agent + /// polls until the user accepts (the PS terminates the mission) or responds + /// with follow-up questions (the mission stays active). + /// + Completion, } /// diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs index 55e761a..9f9d504 100644 --- a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs +++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs @@ -104,7 +104,7 @@ public async Task Mission_DefaultApprover_ReturnsApprovedBlob() await host.StopAsync(); } - [Fact(DisplayName = "§Mission Creation — an agentless request is rejected (401 invalid_carrier_token)")] + [Fact(DisplayName = "§Mission Creation — an agentless request is rejected (403 invalid_carrier_token)")] public async Task Mission_NoAgentToken_Unauthorized() { var builder = WebApplication.CreateBuilder(); @@ -119,7 +119,7 @@ public async Task Mission_NoAgentToken_Unauthorized() var response = await client.PostAsync("https://localhost/mission", JsonContent(new JsonObject { ["description"] = "# Plan a trip" })); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); await app.StopAsync(); ((IDisposable)app).Dispose(); } @@ -388,6 +388,135 @@ public async Task Interaction_PendingRelay_NoStore_Returns200() await host.StopAsync(); } + [Fact(DisplayName = "§Interaction Response — a completion relay reviewing asynchronously parks 202, then terminates the mission on accept")] + public async Task Completion_PendingRelay_Parks202_ThenTerminates() + { + const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true })); + }); + var missionStore = host.Services.GetRequiredService(); + await missionStore.SaveAsync(new StoredMission(s256, Ps, Agent, new byte[] { 1, 2, 3 })); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "completion", + ["summary"] = "# Booked the refundable option", + ["mission"] = new JsonObject { ["approver"] = Ps, ["s256"] = s256 }, + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + // §Interaction Response: "The PS returns a deferred response while the user reviews." + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var location = response.Headers.Location!.ToString(); + Assert.Contains("/governance-pending/", location); + + // The mission stays active while the user reviews. + using var pendingPoll = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.Accepted, pendingPoll.StatusCode); + Assert.Equal(MissionState.Active, (await missionStore.GetAsync(s256))!.State); + + // The user accepts the summary; the poll terminates the mission. + var id = location[(location.LastIndexOf('/') + 1)..]; + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: true); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.OK, done.StatusCode); + var json = await ReadJson(done); + Assert.Equal("terminated", (string?)json?["mission_status"]); + Assert.Equal(MissionState.Terminated, (await missionStore.GetAsync(s256))!.State); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction Response — a reviewed completion the user does not accept keeps the mission active")] + public async Task Completion_PendingRelay_FollowUp_StaysActive() + { + const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + using var host = await BuildHostAsync(s => + { + s.AddAAuthDeferredConsent(); + s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true })); + }); + var missionStore = host.Services.GetRequiredService(); + await missionStore.SaveAsync(new StoredMission(s256, Ps, Agent, new byte[] { 1, 2, 3 })); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "completion", + ["summary"] = "# Draft itinerary", + ["mission"] = new JsonObject { ["approver"] = Ps, ["s256"] = s256 }, + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + var location = response.Headers.Location!.ToString(); + + var id = location[(location.LastIndexOf('/') + 1)..]; + var consent = host.Services.GetRequiredService(); + await consent.ResolveAsync(id, approved: false); + + using var done = await client.GetAsync("https://localhost" + location); + Assert.Equal(HttpStatusCode.OK, done.StatusCode); + var json = await ReadJson(done); + Assert.Equal("active", (string?)json?["mission_status"]); + Assert.Equal(MissionState.Active, (await missionStore.GetAsync(s256))!.State); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction Response — without the deferred store a completion resolves synchronously off the relay's Accepted result")] + public async Task Completion_NoStore_ResolvesSynchronously() + { + const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + using var host = await BuildHostAsync(s => + s.AddSingleton(new StubRelay(new InteractionRelayResult { Accepted = true }))); + var missionStore = host.Services.GetRequiredService(); + await missionStore.SaveAsync(new StoredMission(s256, Ps, Agent, new byte[] { 1, 2, 3 })); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "completion", + ["summary"] = "# Done", + ["mission"] = new JsonObject { ["approver"] = Ps, ["s256"] = s256 }, + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Headers.Location); + var json = await ReadJson(response); + Assert.Equal("terminated", (string?)json?["mission_status"]); + Assert.Equal(MissionState.Terminated, (await missionStore.GetAsync(s256))!.State); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction Endpoint — AddAAuthInteractionRelay wires a delegate relay used for a question")] + public async Task DelegateInteractionRelay_AnswersQuestion() + { + using var host = await BuildHostAsync(s => + s.AddAAuthInteractionRelay((req, ct) => + Task.FromResult(new InteractionRelayResult { Answer = "Yes, the refundable option." }))); + using var client = host.GetTestServer().CreateClient(); + + var body = new JsonObject + { + ["type"] = "question", + ["question"] = "# Which option?", + }; + var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJson(response); + Assert.Equal("Yes, the refundable option.", (string?)json?["answer"]); + + await host.StopAsync(); + } + private sealed class StubApprover(MissionApprovalDecision decision) : IMissionApprover { public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default) diff --git a/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs b/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs new file mode 100644 index 0000000..bda1437 --- /dev/null +++ b/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs @@ -0,0 +1,335 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Crypto; +using AAuth.Discovery; +using AAuth.HttpSig; +using AAuth.Person; +using AAuth.Server.Governance; +using AAuth.Tokens; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace AAuth.Conformance.Person; + +/// +/// Conformance for the Person Server mapper (MapAAuthPersonServer) — the +/// one-call PS issuer (AAuth protocol §Agent Token Request, §PS-asserted access, +/// §PS-AS Federation). The mapper verifies the resource token, delegates the +/// identity + consent decision to , mints +/// the auth token (three-party) or routes to an Access Server (four-party), and +/// packages the mission three-gate model over the mission primitives. +/// +public class PersonServerMapperTests +{ + private const string PsIssuer = "https://ps.test"; + private const string AsIssuer = "https://as.test"; + private const string ResourceUrl = "https://whoami.test"; + private const string AgentId = "aauth:demo@ap.example"; + private const string PsKid = "ps-1"; + private const string ResKid = "whoami-1"; + + private static readonly AAuthKey ResourceKey = AAuthKey.Generate(); + + // Build a PS host: real verification middleware + stub resource discovery + + // the supplied asserter (default asserts a fixed sub). + private static async Task BuildHostAsync(IIdentityClaimsAsserter? asserter = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var psKey = AAuthKey.Generate(); + builder.Services.AddSingleton(new AAuthVerifier { MaxAge = TimeSpan.FromSeconds(300) }); + builder.Services.AddSingleton(new TokenVerifier()); + builder.Services.AddSingleton(new MetadataClient(new HttpClient(new StubResourceHandler()))); + builder.Services.AddSingleton(new JwksClient(new HttpClient(new StubResourceHandler()))); + builder.Services.AddAAuthGovernance(); + builder.Services.AddSingleton(sp => new UpstreamTokenValidator( + sp.GetRequiredService(), sp.GetRequiredService())); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(asserter ?? new DefaultIdentityClaimsAsserter("user-42")); + builder.Services.AddRouting(); + + var app = builder.Build(); + app.MapAAuthPersonServer(new AAuthPersonServerOptions + { + Issuer = PsIssuer, + SigningKeys = new System.Collections.Generic.Dictionary { [PsKid] = psKey }, + TrustedAccessServers = new[] { AsIssuer }, + }); + await app.StartAsync(); + return app; + } + + private static HttpClient SignedAgentClient(IHost host, AAuthKey agentKey, string agentId) + { + var agentToken = new AgentTokenBuilder + { + Issuer = "https://ap.example", + Subject = agentId, + KeyId = "agent-1", + Key = agentKey, + PersonServer = PsIssuer, + }.Build(); + var signing = new AAuthSigningHandler(agentKey, () => agentToken) + { + InnerHandler = host.GetTestServer().CreateHandler(), + }; + return new HttpClient(signing) { BaseAddress = new Uri(PsIssuer) }; + } + + private static string ResourceToken( + AAuthKey agentKey, string agentId, string audience, string scope = "whoami", MissionClaim? mission = null) + => new ResourceTokenBuilder + { + Issuer = ResourceUrl, + Audience = audience, + Agent = agentId, + AgentJkt = agentKey.ComputeJwkThumbprint(), + Key = ResourceKey, + KeyId = ResKid, + Scope = scope, + Mission = mission, + }.Build(); + + private static JsonObject DecodePayload(string jwt) + { + var segments = jwt.Split('.'); + return (JsonObject)JsonNode.Parse( + Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(segments[1]))!; + } + + [Fact(DisplayName = "§PS-asserted access — three-party mint binds the agent key and asserts the directed sub")] + public async Task ThreeParty_MintsAuthToken_BoundToAgentKey() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(); + using var http = SignedAgentClient(host, agentKey, AgentId); + + using var response = await http.PostAsJsonAsync("/token", + new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, PsIssuer) }); + + Assert.True(response.IsSuccessStatusCode, + $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + var body = await response.Content.ReadFromJsonAsync(); + var payload = DecodePayload((string)body!["auth_token"]!); + Assert.Equal(PsIssuer, (string?)payload["iss"]); + Assert.Equal(ResourceUrl, (string?)payload["aud"]); + Assert.Equal(AgentId, (string?)payload["agent"]); + Assert.Equal("user-42", (string?)payload["sub"]); + Assert.Equal(AuthTokenBuilder.PersonDwk, (string?)payload["dwk"]); + var boundKey = AAuthKey.FromJwk((JsonObject)payload["cnf"]!["jwk"]!); + Assert.Equal(agentKey.ComputeJwkThumbprint(), boundKey.ComputeJwkThumbprint()); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Error Responses — an auth token presented as carrier is refused (403 invalid_carrier_token)")] + public async Task ThreeParty_RejectsAuthTokenAsCarrier() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(); + + // Sign with an auth token (wrong carrier type), not an agent token. + var authTokenAsCarrier = new AuthTokenBuilder + { + Issuer = PsIssuer, + Audience = ResourceUrl, + Agent = AgentId, + AgentConfirmationKey = agentKey, + Key = AAuthKey.Generate(), + KeyId = "x", + Subject = "pairwise", + Scope = "whoami", + }.Build(); + var signing = new AAuthSigningHandler(agentKey, () => authTokenAsCarrier) + { + InnerHandler = host.GetTestServer().CreateHandler(), + }; + using var http = new HttpClient(signing) { BaseAddress = new Uri(PsIssuer) }; + + using var response = await http.PostAsJsonAsync("/token", + new JsonObject { ["resource_token"] = "irrelevant" }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("invalid_carrier_token", (string?)body!["error"]); + + await host.StopAsync(); + } + + [Fact(DisplayName = "§Agent Token Request — a missing resource_token is a 400")] + public async Task ThreeParty_RejectsMissingResourceToken() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(); + using var http = SignedAgentClient(host, agentKey, AgentId); + + using var response = await http.PostAsJsonAsync("/token", new JsonObject()); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await host.StopAsync(); + } + + [Fact(DisplayName = "§PS-asserted access — a denying asserter yields 403 denied")] + public async Task ThreeParty_DenyingAsserter_Forbidden() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(new StubAsserter(IdentityAssertion.Deny("not allowed"))); + using var http = SignedAgentClient(host, agentKey, AgentId); + + using var response = await http.PostAsJsonAsync("/token", + new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, PsIssuer) }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("denied", (string?)body!["error"]); + await host.StopAsync(); + } + + [Fact(DisplayName = "§Interaction — NeedsConsent parks a 202 poll; the host verdict resolves the mint")] + public async Task ThreeParty_NeedsConsent_Parks202_ThenMints() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(new StubAsserter(IdentityAssertion.NeedsConsent())); + using var http = SignedAgentClient(host, agentKey, AgentId); + + using var post = await http.PostAsJsonAsync("/token", + new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, PsIssuer) }); + + Assert.Equal(HttpStatusCode.Accepted, post.StatusCode); + var location = post.Headers.Location!.OriginalString; + Assert.Contains("/pending/", location); + + // The host's interaction page resolves the verdict against the store. + var store = (InMemoryPersonPendingStore)host.Services.GetRequiredService(); + var id = location[(location.LastIndexOf('/') + 1)..]; + store.MarkAllowed(id, "user-99"); + + using var poll = await http.GetAsync(location); + Assert.Equal(HttpStatusCode.OK, poll.StatusCode); + var body = await poll.Content.ReadFromJsonAsync(); + var payload = DecodePayload((string)body!["auth_token"]!); + Assert.Equal("user-99", (string?)payload["sub"]); + await host.StopAsync(); + } + + [Fact(DisplayName = "§Mission Status Errors — a terminated mission is rejected (403 mission_terminated)")] + public async Task Mission_Terminated_Rejected() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(); + using var http = SignedAgentClient(host, agentKey, AgentId); + + const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + var missions = host.Services.GetRequiredService(); + await missions.SaveAsync(new StoredMission(s256, PsIssuer, AgentId, new byte[] { 1, 2, 3 })); + await missions.SetStateAsync(s256, MissionState.Terminated); + + using var response = await http.PostAsJsonAsync("/token", + new JsonObject + { + ["resource_token"] = ResourceToken( + agentKey, AgentId, PsIssuer, mission: new MissionClaim(PsIssuer, s256)), + }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("mission_terminated", (string?)body!["error"]); + await host.StopAsync(); + } + + [Fact(DisplayName = "§Agent Token Request — an in-scope mission mints silently and records the grant")] + public async Task Mission_InScope_Mints_AndLogsGrant() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(new StubAsserter(IdentityAssertion.Assert("user-42"))); + using var http = SignedAgentClient(host, agentKey, AgentId); + + const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + using var response = await http.PostAsJsonAsync("/token", + new JsonObject + { + ["resource_token"] = ResourceToken( + agentKey, AgentId, PsIssuer, mission: new MissionClaim(PsIssuer, s256)), + }); + + Assert.True(response.IsSuccessStatusCode, + $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + var payload = DecodePayload((string)(await response.Content.ReadFromJsonAsync())!["auth_token"]!); + Assert.Equal(PsIssuer, (string?)payload["iss"]); + Assert.NotNull(payload["mission"]); + + // The grant was recorded so a repeat request resolves via prior consent. + var log = host.Services.GetRequiredService(); + Assert.True(await log.HasPriorConsentAsync(s256, ResourceUrl, "whoami")); + await host.StopAsync(); + } + + [Fact(DisplayName = "§PS-AS Federation — a resource token audienced to an untrusted AS is refused")] + public async Task FourParty_UntrustedAccessServer_Refused() + { + var agentKey = AAuthKey.Generate(); + using var host = await BuildHostAsync(); + using var http = SignedAgentClient(host, agentKey, AgentId); + + using var response = await http.PostAsJsonAsync("/token", + new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, "https://untrusted-as.test") }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("untrusted_access_server", (string?)body!["error"]); + await host.StopAsync(); + } + + private sealed class StubAsserter : IIdentityClaimsAsserter + { + private readonly IdentityAssertion _assertion; + public StubAsserter(IdentityAssertion assertion) => _assertion = assertion; + public Task AssertAsync( + IdentityAssertionRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(_assertion); + } + + // Serves the resource's well-known metadata + JWKS so the SDK's + // VerifyResourceTokenAsync resolves the resource's signing key in-process. + private sealed class StubResourceHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + string json; + if (path == "/.well-known/aauth-resource.json") + { + json = new JsonObject + { + ["issuer"] = ResourceUrl, + ["jwks_uri"] = $"{ResourceUrl}/.well-known/jwks.json", + }.ToJsonString(); + } + else + { + var jwk = ResourceKey.ToPublicJwk(); + jwk["kid"] = ResKid; + jwk["use"] = "sig"; + jwk["alg"] = AAuthKey.Algorithm; + json = new JsonObject { ["keys"] = new JsonArray(jwk) }.ToJsonString(); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/tests/AAuth.Tests/Agent/InteractionChainingTests.cs b/tests/AAuth.Tests/Agent/InteractionChainingTests.cs index 83c1828..2e18e70 100644 --- a/tests/AAuth.Tests/Agent/InteractionChainingTests.cs +++ b/tests/AAuth.Tests/Agent/InteractionChainingTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AAuth.Agent; using AAuth.Discovery; +using AAuth.Errors; using AAuth.Headers; using Xunit; @@ -78,16 +79,18 @@ public async Task DirectInteractionCallback_StillPollsToTerminal() Assert.True(handler.PendingPolled); } - [Fact(DisplayName = "Chaining — no callback on a 202 still throws the existing HttpRequestException")] - public async Task NoCallback_StillThrowsExisting() + [Fact(DisplayName = "Chaining — no callback on a 202 throws a terminal user_unreachable token-exchange error")] + public async Task NoCallback_ThrowsUserUnreachable() { var handler = new DeferredExchangeHandler(); var metaClient = new MetadataClient(new HttpClient(handler)); var exchangeClient = new TokenExchangeClient(new HttpClient(handler), metaClient); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => exchangeClient.ExchangeAsync(PsUrl, "fake-resource-token")); + Assert.Equal("user_unreachable", ex.ErrorCode); + Assert.True(ex.IsTerminal); Assert.False(handler.PendingPolled); } diff --git a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs index c9bf122..9ed9d9a 100644 --- a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs +++ b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs @@ -181,7 +181,7 @@ public async Task Token_RejectsAuthTokenAsCarrier() var response = await http.PostAsJsonAsync("/token", new JsonObject { ["resource_token"] = "irrelevant" }); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal("invalid_carrier_token", (string?)body!["error"]); } From ad3e9ae64de654fce544e2cf66b68239974ad445 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 16:52:02 +0000 Subject: [PATCH 20/24] docs(missions): document one-call Person Server API and finalize plan - token-issuance, configuration, dependency-injection: MapAAuthPersonServer + AAuthPersonServerOptions, IIdentityClaimsAsserter seam, interaction relay registration, mission three-gate packaging - error-handling: user_unreachable / no-interaction-capability section - mission-governance, mission-governed-access, ps-asserted-access, federated-access: one-call helper cross-links and deferred-completion note - MockPersonServer README: SDK one-call-helper note - plan: Phase 12 DoD ticks, DEV dispositions, research notes --- .../implementation-plan.md | 248 +++++++++++++++++- .../issues-and-deviations.md | 20 +- .../research.md | 146 +++++++++++ docs/advanced/error-handling.md | 16 ++ docs/reference/configuration.md | 17 ++ docs/reference/dependency-injection.md | 35 +++ docs/server/mission-governance.md | 20 ++ docs/server/token-issuance.md | 102 +++++++ docs/workflows/federated-access.md | 7 +- docs/workflows/mission-governed-access.md | 6 + docs/workflows/ps-asserted-access.md | 9 + samples/MockPersonServer/README.md | 8 + 12 files changed, 625 insertions(+), 9 deletions(-) diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md index b91d85f..a67891d 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -590,12 +590,254 @@ truth `src/AAuth/`. --- +## Phase 12 — Post-rename SDK hardening: PS role + four spec-shape fixes + +**Goal:** Land the five-item improvement backlog from the 2026-06-07 deep review +([research.md](research.md) Part H): promote the **Person Server** into a first-class +one-call SDK role and tighten four spec-shape gaps in the governance/interaction +surface. Every item is grounded in a cited spec section and the current SDK state. +All five are in scope; one sub-task (the F5 PS emit) is intentionally gated on +draft-02 and split from its unblocked agent-side half. + +**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (§PS-asserted access / +§Incremental adoption L162, §Auth Token Delivery, §Interaction Response L1212, +§Error Responses L1998 + L2108, §Interaction Endpoint); `aauth-spec/upcoming-changes-02.md` +§2 (F5). + +> **Correction folded in.** The Access Server is **already** a first-class SDK role +> (`MapAAuthAccessServer` in `src/AAuth/Access/AAuthAccessServerEndpoints.cs`); there +> is **no "Mission Manager" party** in the spec or code. The genuine additive gap is +> the **Person Server**, which is the only server role without a one-call mapper. + +### Implementation Decisions + +- **W1 seam shape.** Add `IIdentityClaimsAsserter` mirroring `IAccessPolicy` + (directed `sub` + asserted claims + silent/consent/deny), plus a PS-side + pending/consent store mirroring `IAccessPendingStore`. The SDK keeps all crypto + (resource-token verification, AS federation via `AccessServerClient`, the §Auth + Token Delivery 7-step check, and the auth-token mint via `AuthTokenBuilder`). +- **W1 scope guard (extends DEV-4; revised 2026-06-07 per user direction).** + `MapAAuthPersonServer` packages **both** the three-party collapsed mint and the + four-party federation branch (keyed off the resource-token `aud`), **and** the + mission three-gate *token-issuance mechanics*: gate-1 terminated rejection + (`IMissionStore`), gate-2a/2b silent grant (in-approved-intent / prior-consent via + the asserter + `IMissionLog`), and gate-3 park-and-prompt (`202 requirement=interaction` + + a PS pending entry). The mission scope/consent *policy* decision is the + `IIdentityClaimsAsserter`'s job; the SDK keeps the `IMissionStore`/`IMissionLog` + mechanics and the mission-bound mint. **Still host-mapped (not in the SDK):** the + interactive consent / clarification UI page itself (the `MissionConsentScript` + scripted chat is test scaffolding) and the pending-verdict resolution — exactly how + `MapAAuthAccessServer` delegates its `InteractionLoginPath` page to the host. + `MockPersonServer`'s existing hand-wired interactive path stays as-is (DC6, no + regressions); the mapper is the additive one-call alternative. +- **W2 split.** Ship the **agent-side** typed-exception classification now (replace + the generic `HttpRequestException` in `DeferredExchange` with a terminal typed + exception). The **PS emit** of `400 user_unreachable` stays gated on draft-02 + (emitting today diverges from the authoritative L1213 `interaction_required` + wording) — DEV-14. +- **W3.** Reuse the DEV-9 park-and-poll machinery for the `completion` arm; keep the + synchronous 200 fallback when no `IDeferredConsentStore` is registered. +- **W4 decision = Option B.** Return **403** `{error:"invalid_carrier_token"}` for + the mission carrier-type mismatch (authz refusal, not a 401 signature failure), per + the H.4 analysis. Update the two pinning tests (`GovernanceDeferredConsentMapperTests`, + `MockPersonServerTests`) to match — DEV-13. +- **W5.** Add a `DelegateInteractionRelay` (lambda relay) alongside the no-op + `DefaultInteractionRelay`; pure ergonomics, no spec change — DEV-3. + +### Work items + +- **W1 — `MapAAuthPersonServer(...)` (additive, largest).** New mapper + options + + `IIdentityClaimsAsserter` seam + PS pending store, wrapping the existing + `AuthTokenBuilder` / `AuthTokenResponseValidator` / `AccessServerClient` / + `TokenVerifier`. Mirrors `MapAAuthAccessServer`. Closes the PS role-symmetry gap; + unblocks external adopters running a real PS in one call. +- **W2 — `user_unreachable` agent classification (DEV-14, partial).** Throw a typed + terminal exception (not `HttpRequestException`) from the no-callback deferred path + in `src/AAuth/Agent/DeferredExchange.cs`. PS emit deferred to draft-02. +- **W3 — deferred `completion` review (DEV-10).** Honor `InteractionRelayResult.Pending` + in the `Completion` arm of `HandleInteractionAsync`; park + 202 + poll when a store + exists, synchronous 200 otherwise. +- **W4 — mission carrier-type 401→403 (DEV-13).** Change `HandleMissionAsync`'s + carrier guard to 403; update the two pinning tests. +- **W5 — `DelegateInteractionRelay` (DEV-3).** Lambda-friendly relay so a PS supplies + a user channel without a full class. + +- **W6 — docs / samples / snippets sync (grounded against the SDK).** Every W1–W5 + change that is observable to an adopter is reflected in the docs, sample READMEs, + and GuidedTour/SampleApp narration, then a docs-vs-SDK grounding pass (mirroring + Phase 11 / DEV-11) confirms no snippet drift. Impacted surfaces (from a workspace + scan — confirm and extend during the phase): + - **W1 (new public API):** add a PS token-issuance page covering `MapAAuthPersonServer` + + `AAuthPersonServerOptions` + `IIdentityClaimsAsserter` — [docs/server/token-issuance.md](../../../docs/server/token-issuance.md), + cross-linked from [docs/workflows/ps-asserted-access.md](../../../docs/workflows/ps-asserted-access.md) + and [docs/workflows/federated-access.md](../../../docs/workflows/federated-access.md) + (it sits beside `MapAAuthAccessServer` at L117/L135); register the new seam in + [docs/reference/dependency-injection.md](../../../docs/reference/dependency-injection.md) + and [docs/reference/configuration.md](../../../docs/reference/configuration.md). + Optionally migrate `MockPersonServer`'s non-interactive path + README to the mapper + (interactive consent page stays hand-wired per DEV-4). + - **W2:** `TokenErrorCode.UserUnreachable` / terminal-vs-non-terminal classification — + [docs/advanced/error-handling.md](../../../docs/advanced/error-handling.md) (still + flagged forward-looking until draft-02, DEV-14). + - **W3:** completion now returns a deferred `202`/poll when the relay is pending — + [docs/server/mission-governance.md](../../../docs/server/mission-governance.md) + (`InteractionRelayResult` table L247) and [docs/workflows/mission-governed-access.md](../../../docs/workflows/mission-governed-access.md) + (the propose-completion step L131/L135). + - **W4:** mission carrier-type mismatch now `403` (not `401`) — any error-shape table + in [docs/server/mission-governance.md](../../../docs/server/mission-governance.md) + / [docs/server/authn-authz.md](../../../docs/server/authn-authz.md). + - **W5:** `DelegateInteractionRelay` as the lambda alternative to a full + `IInteractionRelay` class — [docs/server/mission-governance.md](../../../docs/server/mission-governance.md) + (L36/L46/L251) and [docs/reference/dependency-injection.md](../../../docs/reference/dependency-injection.md) + (L412/L420). + +### Spec validation (verbatim quotes) + +Each work item is validated against the authoritative spec text below +(`aauth-spec/draft-hardt-oauth-aauth-protocol.md` unless noted). Quotes are verbatim; +line numbers are current as of 2026-06-07. + +**W1 — `MapAAuthPersonServer`.** The PS role this mapper packages is exactly the +PS-asserted (three-party) and federated (four-party) issuer the spec defines. + +- §Overview L162: *"Issuing resource tokens to the agent's person server enables + PS-asserted access (three-party): the PS asserts identity claims about the user + (`sub`, optionally `email`, `tenant`, `groups`, `roles`) and confirms user consent + for the scope the resource requested; the resource applies its own policy on the + resulting claims."* → drives the `IIdentityClaimsAsserter` seam (directed `sub` + + optional claims + consent decision). +- §PS-AS Federation L1466: *"The PS is the only entity that calls AS token endpoints… + If `aud` matches the PS's own identifier, the PS issues an auth token asserting + identity and consent for the requested scope (three-party). If `aud` identifies a + different server (an AS)… the PS… calls the AS's `token_endpoint` (four-party)."* + → the mapper's two branches (collapsed mint via `AuthTokenBuilder` vs. federation + via `AccessServerClient`) are spec-mandated, keyed off the resource token `aud`. +- §Auth Token Delivery L1439 (the 7-step check the SDK keeps, not the PS): *"When the + AS issues an auth token (`200` response), the PS MUST verify the auth token before + returning it to the agent: 1. Verify the auth token JWT signature… 2. Verify `iss`… + 3. Verify `aud`… 4. Verify `agent`… 5. Verify `cnf.jwk`… 6. Verify `act`… 7. Verify + `scope` is consistent with what was requested — not broader than the scope in the + resource token."* → `AuthTokenResponseValidator.ValidateAsync` already implements + all seven; the mapper wires it into the federation branch. +- §Claims Required L1450: *"A server MUST use `requirement=claims` with a `202 Accepted` + response when it needs identity claims… The recipient MUST provide the requested + claims (including a directed user identifier as `sub`)…"* → the AS-side `OnClaimsRequired` + callback the PS mapper surfaces. **Verdict: COMPLIANT — the mapper packages existing + spec-conformant primitives; no new wire behavior.** +- §Agent Token Request L812 (mission gating, folded into the mapper per the revised + scope guard): *"the PS evaluates the request against mission scope, handles user + consent if needed, and uses the same requirement response patterns."* and §Resource + Tokens L784: *"The PS SHOULD remember prior consent decisions within a mission so the + user is not re-prompted when the agent resubmits a request for the same resource and + scope."* → the mapper's gate-2a (in-approved-intent) and gate-2b (prior consent via + `IMissionLog`) silent grants, gate-1 terminated rejection, and gate-3 park-and-prompt + (`202 requirement=interaction`) are spec-mandated; the interactive consent page that + resolves gate-3 stays host-mapped. **Verdict: COMPLIANT — the mapper packages the + three-gate model over existing `IMissionStore`/`IMissionLog` primitives.** + +**W2 — `user_unreachable` (agent classification now; PS emit gated).** This code is +**not yet** in the authoritative draft. + +- Authoritative §Interaction Response L1213 (today): *"If the PS cannot reach the user + and the agent does not have the `interaction` capability, the PS returns + `interaction_required`."* — so emitting `user_unreachable` now would **contradict** + the authoritative text. +- `aauth-spec/upcoming-changes-02.md` §2: *"Add `user_unreachable` as a distinct + terminal error… `user_unreachable` | 400 | Terminal | PS has no channel to the user + AND the agent didn't declare `interaction` capability."* and *"Error classification + (Gap E) should treat `user_unreachable` as a terminal, non-retryable error distinct + from `interaction_required`."* **Verdict: agent-side terminal classification is + COMPLIANT with the agreed draft-02 direction and changes no wire output; the PS + emit stays DEFERRED until draft-02 lands (DEV-14) to avoid contradicting L1213.** + +**W3 — deferred `completion` review.** + +- §Interaction Response L1212: *"For `completion` type, the PS presents the summary to + the user. The user either accepts — the PS terminates the mission and returns + `200 OK` — or responds with follow-up questions via clarification, keeping the + mission active. **The PS returns a deferred response while the user reviews.**"* +- §Interaction Response L1199 (the parallel the interaction arm already follows): *"For + `interaction` and `payment` types, the PS relays the interaction to the user and + returns a deferred response. The agent polls until the user completes the interaction."* + **Verdict: today's synchronous `Completion` arm is spec-tolerable but not spec-shaped; + honoring `Pending` (park + 202 + poll) makes it match the highlighted sentence. The + 202/poll mechanics reuse §Deferred Responses, already implemented for DEV-9.** + +**W4 — mission carrier-type guard (401→403).** + +- §Error Responses / Authentication Errors L1998: *"A `401` response from any AAuth + endpoint uses the `Signature-Error` header."* +- §Verification (Server) L2108: *"When a server receives a signed request, it MUST + perform the following steps. Any failure MUST result in a `401` response with the + appropriate `Signature-Error` header."* — the carrier-type check is **not** one of + these signature-verification steps (the signature already verified), so a bare + `401 {error:"invalid_carrier_token"}` JSON response sits outside the spec's 401 + contract. **Verdict: returning `403` (an authorization refusal, not a signature + failure) is the spec-correct shape — Option B — keeping every actual `401` bound to + the `Signature-Error` header per L1998/L2108.** + +**W5 — `DelegateInteractionRelay`.** + +- §Interaction Endpoint L1131: *"The interaction endpoint enables the agent to reach + the user through the PS… The agent uses this endpoint to forward interaction + requirements from resources that it cannot handle directly, to ask the user + questions, to relay payment approvals, or to propose mission completion."* — the + spec defines the endpoint behavior; **how** the PS reaches the user is an + implementation concern. **Verdict: COMPLIANT — adding a lambda-based relay alongside + the no-op default is a pure ergonomic SDK seam with no protocol effect.** + +### Definition of Done + +- [x] **W1:** `MapAAuthPersonServer` + `AAuthPersonServerOptions` + `IIdentityClaimsAsserter` + + PS pending store land; covered by conformance/integration tests; the three-party + collapsed mint and four-party federation branches both exercised. _(New SDK files + `src/AAuth/Person/{IIdentityClaimsAsserter,IPersonPendingStore,AAuthPersonServerEndpoints}.cs`; + 8 conformance tests in `tests/AAuth.Conformance/Person/PersonServerMapperTests.cs` — + three-party silent mint + agent-key binding, carrier-type 403, missing-resource-token 400, + deny→403, NeedsConsent→202→poll→mint, mission terminated→403, mission in-scope mint+grant-logged, + and the four-party untrusted-AS routing guard. Mapper packages both branches + the mission + three-gate token-issuance mechanics per the 2026-06-07 user-directed scope revision.)_ +- [x] **W1:** `MockPersonServer` interactive flows remain hand-wired and e2e-green + (no DEV-4 regression). _(MockPersonServer endpoints untouched by W1; the README now points + to the SDK one-call helper as the non-interactive alternative while the sample keeps its + interactive consent/mission screens hand-wired. MockPersonServer integration tests green in + the 387 unit baseline.)_ +- [x] **W2:** agent no-callback deferred path throws a typed terminal exception; PS + emit remains gated on draft-02 (DEV-14 note updated, not closed). _(DeferredExchange throws + `AAuthTokenExchangeException(UserUnreachable, statusCode:400, isTerminal:true)`; documented in + `docs/advanced/error-handling.md` with the forward-looking draft-02 PS-emit note.)_ +- [x] **W3:** `Completion` arm defers via the store (202 + poll) and degrades to + synchronous 200; new mapper test covers both; DEV-10 status flipped to fixed. _(GovernanceDeferredConsentMapperTests + 16/16; documented in `docs/workflows/mission-governed-access.md` propose-completion step.)_ +- [x] **W4:** mission carrier-type mismatch returns 403; the two pinning tests + updated; DEV-13 status flipped to fixed. _(Documented in `docs/server/mission-governance.md` + carrier-type guard note; MockPersonServer 4× 401→403; MockPersonServerTests 7/7.)_ +- [x] **W5:** `DelegateInteractionRelay` added with a test; DEV-3 status flipped to fixed. + _(`AddAAuthInteractionRelay(...)` documented in `docs/server/mission-governance.md` + + `docs/reference/dependency-injection.md`.)_ +- [x] **W6:** docs, sample READMEs, and GuidedTour/SampleApp narration updated for every + observable W1–W5 change; a docs-vs-SDK grounding pass (Phase 11 / DEV-11 style) + confirms every snippet compiles against `src/AAuth/` with no drift. _(token-issuance.md + one-call PS section + AAuthPersonServerOptions/IIdentityClaimsAsserter tables; configuration.md + + dependency-injection.md seam registration; ps-asserted-access.md + federated-access.md cross-links; + MockPersonServer README note. Every new snippet hand-verified against the SDK surface read this + phase — no automated snippet harness exists; GuidedTour `CodeSnippets.cs` unaffected.)_ +- [x] `dotnet build AAuth.slnx` 0/0; unit + conformance + relevant e2e green. _(Solution 0/0; + unit 387, conformance 480 — both green. e2e not re-run: no e2e-observable behavior changed + (W4 status codes have integration coverage; W1 is additive SDK surface).)_ +- [x] `issues-and-deviations.md` updated (DEV-3/10/13/14 dispositions; any new W1 DEVs); + research Part H cross-checked. _(DEV-3→fixed (W5), DEV-10→fixed (W3), DEV-13→fixed (W4), + DEV-14 stays forward-looking with the W2 agent-side note; new DEV-15 records the + user-directed W1 scope expansion.)_ + +--- + ## Out of Scope | Item | Reason | |------|--------| -| R3 (Rich Resource Requests) — models, RFC 8785 hasher, `r3_*` token claims, AS/MM fetch, resource enforcement | Split into its own initiative — `.agent/plans/2026-06-06-r3-rich-resource-requests/` | -| Implementing AS or MM as production SDK roles | Out of scope per mission research; mock servers only | +| R3 (Rich Resource Requests) — models, RFC 8785 hasher, `r3_*` token claims, AS fetch, resource enforcement | Split into its own initiative — `.agent/plans/2026-06-06-r3-rich-resource-requests/` | +| Mission Manager (MM) as a production SDK role | No MM party exists in the AAuth spec (parties are Agent / PS / Resource / AS). The AS already ships as a first-class role (`MapAAuthAccessServer`); the **PS** first-class mapper (`MapAAuthPersonServer`) moved **into scope** — Phase 12 W1. | | Mission lifecycle beyond active/terminated (suspend/resume/revoke) | Deferred to companion spec (§Mission Management) | -| `user_unreachable` (F5) and `prompt` finalization (F6) | Pending draft-02 publication | +| `user_unreachable` PS **emit** (F5) and `prompt` finalization (F6) | Pending draft-02 publication. Phase 12 W2 lands the unblocked **agent-side** typed-exception classification now; the PS emit stays gated on draft-02 (DEV-14). | | Payment settlement protocols (x402/MPP) | External; SDK only surfaces 402 + details | diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md index 25b34aa..2eb790a 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md @@ -43,21 +43,33 @@ These judgment calls were made during Phase 1. **All confirmed by the user on |----|-------|------|---------|--------|--------| | DEV-1 | 1 | Resource mapper | `MapAAuthGovernance` resolved `PermissionOutcome.Prompt` as a denial — no built-in deferred (202) user-consent channel. | §Permission Endpoint (deferred consent) | resolved (Phase 2, D3 — `IDeferredConsentStore` seam + `AddAAuthDeferredConsent()`; mapper parks `Prompt` and answers 202 + poll route. Store is opt-in so the existing `Prompt`→denied default is preserved. Interactive browser page stays a sample concern.) | | DEV-2 | 1 | Resource mapper | Mission-creation endpoint not mapped by `MapAAuthGovernance`; approval-blob building + proposal approval stayed PS-side (`MissionApproval` was sample-local). | §Mission Creation, §Mission Approval | resolved (Phase 2, D3 — `MissionApprovalBuilder` + `IMissionApprover`/`DefaultMissionApprover` promoted into the SDK; `MapAAuthGovernance` maps the mission endpoint, persists the `StoredMission`, and emits the `AAuth-Mission` header. MockPersonServer now uses `MissionApprovalBuilder`.) | -| DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. | §Interaction Endpoint | intentional | +| DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. **Phase 12 (W5):** the seam now has an ergonomic lambda channel — `DelegateInteractionRelay` + `AddAAuthInteractionRelay((request, ct) => …)` lets a PS supply its user channel as a one-liner (replacing the no-op default via `RemoveAll`). The no-op default stays for an unconfigured PS. | §Interaction Endpoint | fixed (Phase 12; build 0/0, conformance 480) | | DEV-4 | 4 | MockPersonServer | Phase 4 listed "swap MockPersonServer's hand-wired `/mission` `/permission` `/audit` `/mission-interaction` + pending routes for `MapAAuthGovernance()`." Kept the hand-wired endpoints instead. They are bound to the deterministic `MissionConsentScript` (`/admin/*` scripting the e2e suite drives), the `MissionPolicyStore` that decides silent-vs-prompt **token exchange** at `/token`, and the interactive browser `/interaction` consent page. `MapAAuthGovernance()` targets the *non-interactive default* PS; reproducing this rich behavior through `IMissionApprover` + a custom `IDeferredConsentStore` is a large rewrite of a spec-conformant, e2e-green file for **zero spec/behavior gain**, against DC6 (no regressions). Consistent with **DEV-1**'s decision that the interactive browser page "stays a sample concern." The agent-facing call-sites (MissionAgent, Mission.razor, GuidedTour) ARE migrated to the new session API; server-side request parsing still uses `GovernanceEndpoints` + `MissionApprovalBuilder`. | §Mission Creation, §Permission Endpoint, §Audit Endpoint (deferred consent) | intentional | | DEV-5 | 4 | WhoAmI | Phase 4 listed "WhoAmI — resource governance builder for mission-aware challenge." No such builder exists or was introduced (Phase 3 built the **agent** session surface, not a resource-side one). `ChallengeOptions { MissionAware = true }` IS the canonical, spec-correct resource-side seam (§Terminology: a *mission-aware resource* includes the mission object from the `AAuth-Mission` header in the resource tokens it issues), already used by `WhoAmI`. No agent-style manual `MissionClaim` threading exists on the resource side. No change required. | §Mission Context at Resources, §Terminology (mission-aware resource) | intentional | | DEV-6 | 5 | SDK deferred exchange | Building the Phase 5 combined page surfaced two real SDK bugs in the clarification→interaction escalation path (`DeferredExchange.cs`). **Bug 1:** after a clarification round, the poller did not stop on a subsequent interaction `202` (the `stopOnInteraction` flag was not threaded through `PollAsync`/`ComposePollerOptions`), so the SDK kept polling instead of surfacing the user-approval gate. **Bug 2:** when a polled interaction `202` omitted the `Location` header, `ResolveLocation` returned null instead of falling back to the last pending URL, dropping the interaction URL the UI needs. Both fixed in the SDK and covered by `ChallengeClarificationSeamTests` (4/4). | §Clarification Chat, §Interaction Endpoint | fixed (Phase 5; conformance 4/4, build 0/0) | | DEV-7 | 5 | SampleApp Blazor page | `MissionCallChain.razor` originally spawned a per-second `PeriodicTimer`/`Task.Run` poll-counter that called `InvokeAsync(StateHasChanged)` while parked on a prompt. Because the approval popup leaves the main tab backgrounded, Chromium throttles it and stops ACKing SignalR render batches; the timer filled the circuit's unacked-batch buffer (default max 10) and **paused all rendering ~120 s** until it drained — an e2e timeout. Root cause is a Blazor Server anti-pattern (background-timer `StateHasChanged` on a backgroundable tab), not the protocol. **Fix:** removed the cosmetic timer entirely; the polling banner is surfaced by a single `StateHasChanged` with a static spinner. (`Mission.razor` keeps its `ct`-bound timer and passes; left untouched to avoid regression risk.) | (sample/UI only) | fixed (Phase 5) | | DEV-8 | 5 | e2e spec timing | `mission-call-chain.spec.ts`'s `approvePrompt` asserted an **exact** transient `toHaveCount(2)` after the step-2 approval. Unlike `mission.spec.ts` — where every gate parks on the next prompt so the count settles — step 3 here is **silent and final**: it advances with no gate and appends its card immediately, and Blazor **coalesces** the step-2 and step-3 `StateHasChanged` into one render batch (the DOM jumps 1→3, never showing 2). The exact count was therefore racy by construction; the page behaviour is correct (forcing artificial render flushes into product code to satisfy a test would be the anti-pattern). **Fix:** the helper now waits for the just-approved step's card via `expect(stepCard(page, expectedCards)).toBeVisible()` (i.e. ≥ expectedCards), matching the helper's own documented intent ("reach expectedCards"). The strict final `toHaveCount(3)` and all per-card content assertions are unchanged; `mission.spec.ts` is untouched. | (e2e test only) | fixed (Phase 5; two consecutive clean full-suite runs) | | DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"For `interaction` and `payment` types, the PS relays the interaction to the user and **returns a deferred response**… The agent polls until the user completes the interaction."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) | -| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional | +| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. **Phase 12 (W3):** the `completion` branch now honours `InteractionRelayResult.Pending` — when the relay defers **and** an `IDeferredConsentStore` is registered, the mapper parks a new `DeferredConsentKind.Completion` entry and answers `202` + poll `Location` (the user reviews the summary asynchronously), resolving to `200` on accept; without a store it degrades to the synchronous `Accepted` model. Symmetric with the DEV-9 interaction/payment fix. Covered by `GovernanceDeferredConsentMapperTests` (16/16). | §Interaction Response | fixed (Phase 12; conformance 480, build 0/0) | | DEV-11 | 11 | Docs & code snippets | The Phase 11 docs-vs-SDK grounding review (subagent) found doc snippets that would not compile against the current SDK: (a) `mission-governance-clients.md`, `mission-governed-access.md` passed **bare strings** to `RequestPermissionAsync`/`RecordAuditAsync`, which take a `MissionAction` (no implicit `string` conversion exists) — CS1503; (b) `server/mission-governance.md`'s `MyPermissionDecider` compared `t.Name == context.Request.Action` (string vs `MissionAction`) — CS0019; (c) `dependency-injection.md` + `configuration.md` documented `AAuthAgentOptions` members that do not exist (`UseHwk()`, `InteractionHandling`, `InteractionHandlingOptions`, `BaseAddress`, `ChallengeHandling*`, `RefreshThreshold`, `Capabilities`, `InnerHandler`, `CallChainProvider`, `SignatureKeyProvider`) and omitted the real ones (`OnResourceInteraction`, `OnApprovalPending`, `PollingTimeout`); (d) `error-handling.md`'s `TokenErrorCode` listing omitted `MissionTerminated`; (e) `server/mission-governance.md` comment said the interaction route is `/interaction` (actual default `/mission-interaction`); (f) `dependency-injection.md` claimed the PS-side seams are "always supplied by the PS" whereas `AddAAuthGovernance` registers no-op defaults via `TryAdd`. **Fix:** all corrected against `src/AAuth/` (the source of truth — samples already used `new MissionAction(...)`/`tool.ToAction()` and build 0/0). | (docs only — grounded against SDK) | fixed (Phase 11) | | DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code was emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. **Fix (user-approved full rename):** every site was renamed to `denied` with **no** backward-compat alias, since every emitter and classifier in this repo is ours and now round-trips `denied` end-to-end, keeping the system internally consistent and 100% spec-aligned. Scope covered: the SDK PS governance emits (`AAuthGovernanceApplicationBuilderExtensions`), the four-party AS path (`AAuthAccessServerEndpoints` 3 emits, `AccessServerClient.IsDeniedAsync` classifier — confirmed in-scope as those `403`/`AccessDecisionKind.Deny` responses are AAuth polling denials), the agent classifiers (`TokenExchangeClient.IsDeniedAsync`, `DeferredExchange` comments), interface/exception doc comments (`IAccessPolicy`, `IAccessPendingStore`, `AAuthInteractionExceptions`), all sample mocks (`MockPersonServer`, `Orchestrator`, `MockAccessServer`, `MissionAgent`), the SampleApp `Mission.razor` payload+narration, `GuidedTour` narration + classifier, all conformance/integration test asserts (incl. method `Pending_Returns403Denied_AfterDeny` and the Keycloak test emit), the Playwright specs, and `docs/advanced/interaction-chaining.md`. Spec is unaffected (`denied` is already the only denial code there). | §Polling Error Codes (L2023) | fixed (build 0/0) | -| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Left as-is (LOW); changing the status/shape would churn our own just-written conformance tests for no protocol-observable gain. | §Error Responses | intentional | -| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking) | +| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). **Phase 12 (W4):** resolved by moving the guard to `403 {error:"invalid_carrier_token"}` — a valid-signature authorization failure, not a `401` authentication failure, so the spec's `401`/`Signature-Error` rule no longer applies. The two pinning tests were updated and `MockPersonServer` flipped its four `invalid_carrier_token` guards 401→403 to match. | §Error Responses | fixed (Phase 12; unit 387, conformance 480) | +| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. **Phase 12 (W2):** the agent-side classification now ships — `DeferredExchange`'s no-callback deferred path throws a **terminal** `AAuthTokenExchangeException(user_unreachable, statusCode:400, isTerminal:true)` instead of hanging, documented in `error-handling.md`. The **PS wire-emit** of `user_unreachable` stays gated on draft-02 (this entry remains open until then). | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking; W2 agent-side landed Phase 12) | +| DEV-15 | 12 | PS one-call mapper scope | Phase 12 W1 was originally scoped (in the plan's Implementation Decisions) to package only the **three-party PS-asserted** mint behind `MapAAuthPersonServer`, deferring four-party federation and the mission three-gate mechanics to keep the first cut small. **User-directed expansion (2026-06-07):** include **both** the three-party mint **and** four-party federation branches now, and fold the mission three-gate token-issuance mechanics (gate-1 terminated rejection, gate-2 prior-consent silent grant, gate-3 asserter prompt) into the mapper — while the interactive consent/clarification UI stays host-mapped (the asserter's `NeedsConsent` emits `202 requirement=interaction` and delegates the page to the host, consistent with DEV-1/DEV-4). Recorded in `implementation-plan.md` (W1 scope-guard revision + spec anchor). Spec basis: §Overview L162 (PS-asserted claims+consent), §PS-AS Federation L1466 (aud-keyed two-branch routing), §Agent Token Request / §Resource Tokens (prior-consent silent issuance). Covered by `PersonServerMapperTests` (8/8). | §Overview, §PS-AS Federation, §Agent Token Request | fixed (Phase 12; user-directed, spec-grounded; conformance 480, build 0/0) | ## Notes - Known spec-alignment findings from research (F1–F6) are tracked in [research.md](research.md) Part F; only deviations discovered **during implementation** are logged here. +- **Scheduled for Phase 12 (2026-06-07 deep review, [research.md](research.md) Part H):** + DEV-3 (W5 `DelegateInteractionRelay`), DEV-10 (W3 deferred completion), DEV-13 + (W4 carrier-type 401→403), and DEV-14 (W2 agent-side typed exception now; PS emit + still gated on draft-02). A new additive item — `MapAAuthPersonServer` (W1) — is + added as the PS first-class role. Statuses flip to `fixed` as each item lands. +- **Phase 12 dispositions (landed):** DEV-3 → fixed (W5), DEV-10 → fixed (W3), + DEV-13 → fixed (W4) — these three flipped from `intentional` once the Part H + review reclassified them as worth doing. DEV-14 stays forward-looking (W2 added + the agent-side classification; PS wire-emit awaits draft-02). DEV-15 records the + user-directed W1 scope expansion (both branches + mission three-gate in the + mapper). All Phase 12 work: solution build 0/0; unit 387; conformance 480. diff --git a/.agent/plans/2026-06-06-mission-api-refactor/research.md b/.agent/plans/2026-06-06-mission-api-refactor/research.md index fbb4a60..2f9d056 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/research.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/research.md @@ -483,3 +483,149 @@ edit time. > A secondary observation — completion review resolves synchronously rather than > deferring (§Interaction Response L1212) — is logged as **DEV-10** (intentional: > the completion relay contract is synchronous, no dropped `Pending` signal). + +--- + +## Part H — Post-rename improvement backlog (deep review, 2026-06-07) + +After the DEV-12 `access_denied`→`denied` rename landed, the user asked for a deep +review of the remaining SDK-improvement / spec-shape items, each grounded against +the AAuth spec and the SDK source. Five items were confirmed for delivery (see +Phase 12 in [implementation-plan.md](implementation-plan.md)). This section is the +research backing — current state and the spec gap per item; no task lists. + +> **Correction to an earlier confabulation.** An interim chat note claimed the SDK +> had "no production AS / Mission-Manager roles." That is wrong on both counts and +> is corrected here: +> - The **Access Server is already a first-class SDK role** — `MapAAuthAccessServer` +> ([../../../src/AAuth/Access/AAuthAccessServerEndpoints.cs](../../../src/AAuth/Access/AAuthAccessServerEndpoints.cs)) +> with `AAuthAccessServerOptions`, an `IAccessPolicy` decision seam, and an +> `IAccessPendingStore` deferral seam. +> - There is **no "Mission Manager" party** anywhere in the spec or code. The four +> AAuth parties are Agent, Person Server (PS), Resource Server, and Access Server +> (§Terminology L105 / §Roles L142). "Missions" are an orthogonal governance +> feature, not a party. +> The real additive gap is the **Person Server**, which has no one-call mapper. + +### H.1 — `MapAAuthPersonServer` (additive; largest item) + +**Spec.** §Incremental adoption (L162) makes the PS the issuer of the three-party +PS-asserted auth token and the PS half of four-party federation. §Auth Token +Delivery defines the 7-step verification a PS runs on an AS-issued token before +returning it. + +**Current SDK.** Every primitive exists; there is **no mapper**: + +- `AuthTokenBuilder` ([../../../src/AAuth/Tokens/AuthTokenBuilder.cs](../../../src/AAuth/Tokens/AuthTokenBuilder.cs)) — mints the auth token (collapsed PS+AS case). +- `AuthTokenResponseValidator.ValidateAsync` ([../../../src/AAuth/Tokens/AuthTokenResponseValidator.cs](../../../src/AAuth/Tokens/AuthTokenResponseValidator.cs)) — the §Auth Token Delivery 7-step check. +- `AccessServerClient` ([../../../src/AAuth/Access/AccessServerClient.cs](../../../src/AAuth/Access/AccessServerClient.cs)) — the PS→AS federation leg (push / poll / claims). +- `TokenVerifier.VerifyResourceTokenAsync` — resource-token verification. + +**Gap.** `MockPersonServer`'s `/token` ([../../../samples/MockPersonServer/Program.cs](../../../samples/MockPersonServer/Program.cs)) +hand-assembles ~350 lines of orchestration: resource-token verification → the +four-party federation branch (`aud` ≠ PS → forward to a trusted AS via +`AccessServerClient` with `OnInteractionRequired` / `OnClaimsRequired`) → the +three-party collapsed mint → mission gate → consent gate. The AS has a one-call +`MapAAuthAccessServer` with an `IAccessPolicy` seam; the PS is the **only** server +role without the equivalent. Proposed seam: `IIdentityClaimsAsserter` (mirrors +`IAccessPolicy`) returning the directed `sub` + asserted claims and a +silent/consent/deny decision, plus a PS-side pending/consent store mirroring +`IAccessPendingStore`. The SDK keeps all crypto (verification, federation, mint, +§Auth Token Delivery); the PS only decides identity + consent. **Effort: large.** +The interactive `MockPersonServer` consent page + `MissionConsentScript` stay +hand-wired (same rationale as DEV-4 / DEV-1: interactive browser flows are a sample +concern); the mapper targets the non-interactive default PS. + +### H.2 — `user_unreachable` emit path (F5 / DEV-14) + +**Spec.** The authoritative draft (L1213) still says the PS returns +`interaction_required` when it cannot reach the user and the agent lacks the +`interaction` capability. `upcoming-changes-02.md` §2 (L32–47) splits this into two +codes: `interaction_required` (202, non-terminal, carries a URL) vs +`user_unreachable` (400, terminal hard-stop). + +**Current SDK — agent side is already forward-compatible.** + +- `TokenErrorCode.UserUnreachable` exists with both-direction wire mapping + ([../../../src/AAuth/Errors/TokenError.cs](../../../src/AAuth/Errors/TokenError.cs)). +- `AAuthTokenExchangeException.IsTerminalCode` ([../../../src/AAuth/Errors/AAuthTokenExchangeException.cs](../../../src/AAuth/Errors/AAuthTokenExchangeException.cs)) + already treats everything except `server_error` as terminal, so `user_unreachable` + surfaces as a terminal typed exception when a PS emits it. + +**Gap.** No PS emits `user_unreachable`, and the agent's no-callback path throws a +**generic** `HttpRequestException` instead of a typed terminal exception — +`DeferredExchange.PollAsync` ([../../../src/AAuth/Agent/DeferredExchange.cs](../../../src/AAuth/Agent/DeferredExchange.cs), +the `RequireInteractionCallback && OnInteractionRequired is null` branch). The +agent-side typed-exception classification is **unblocked** and can land now; the PS +**emit** (return `400 user_unreachable` when no channel and no `interaction` +capability) stays gated on draft-02, since emitting it today would diverge from the +authoritative L1213 wording. + +### H.3 — deferred `completion` review (DEV-10) + +**Spec.** §Interaction Response (L1212): *"For `completion` type, the PS presents +the summary to the user. The user either accepts … or responds with follow-up +questions via clarification … The PS returns a deferred response while the user +reviews."* + +**Current SDK.** `HandleInteractionAsync` ([../../../src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs](../../../src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs), +the `Completion` arm) resolves synchronously off `result.Accepted` and returns +`mission_status: terminated|active` immediately. The `interaction`/`payment` arm +right below it already honors `result.Pending` → parks on `IDeferredConsentStore` → +202 + poll (the DEV-9 fix); completion simply never consults `Pending`. + +**Gap.** `InteractionRelayResult` ([../../../src/AAuth/Server/Governance/IInteractionRelay.cs](../../../src/AAuth/Server/Governance/IInteractionRelay.cs)) +already carries `Pending`, and `DeferredConsentKind` already exists. The completion +arm can reuse the exact park-and-poll machinery the interaction arm uses; only the +`switch` arm is synchronous. Add a `Pending` check (new +`DeferredConsentKind.Completion` or reuse `Interaction`), park + 202 + poll when a +store is registered, and degrade to today's synchronous 200/`active` when no store +(consistent with the permission/interaction fallback). **Effort: small-medium;** +relay contract unchanged (sync relays keep using `Accepted`). + +### H.4 — `Signature-Error` on the mission 401 (DEV-13) + +**Spec.** §Error Responses (L1998): *"A `401` response from any AAuth endpoint uses +the `Signature-Error` header."* L2108 scopes the 401 to a **signature-verification** +failure. + +**Current SDK.** `HandleMissionAsync`'s carrier-type guard returns +`401 {error:"invalid_carrier_token"}` as JSON with **no** `Signature-Error` header +when a non-agent token is presented — the signature already verified; this is a +semantic token-type rejection. The `SignatureError` helper + `SignatureErrorCode` +enum exist ([../../../src/AAuth/Errors/SignatureError.cs](../../../src/AAuth/Errors/SignatureError.cs)) +but `invalid_carrier_token` is not a signature-error code. + +**Gap / options.** Either (A) keep 401 and add `Signature-Error: invalid_jwt` +(satisfies L1998 literally, but the JWT was valid — just the wrong type), or +(B) return **403** with the JSON `invalid_carrier_token` body (a 403 authorization +refusal is not bound by the 401/`Signature-Error` rule, and a token-type mismatch is +genuinely an authz decision). **Lean: Option B** — the more defensible reading, +avoids overloading `invalid_jwt`. Trivial code; pinned by two existing tests +(`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Lowest protocol +value of the five. + +### H.5 — ergonomic `IInteractionRelay` default (DEV-3) + +**Spec.** §Interaction Endpoint expects the PS to relay to a user channel; a no-op +default is spec-legal (it just cannot reach anyone). + +**Current SDK.** `DefaultInteractionRelay` ([../../../src/AAuth/Server/Governance/DefaultInteractionRelay.cs](../../../src/AAuth/Server/Governance/DefaultInteractionRelay.cs)) +returns empty answer / `Accepted=false` / `Pending=false`. Correct and documented; +a real PS overrides it. + +**Gap (ergonomics, not compliance).** Overriding requires a whole `IInteractionRelay` +class — there is no lightweight delegate option, unlike the agent side which uses +`Func<>` callbacks throughout. Add a `DelegateInteractionRelay` (or an +`AddAAuthGovernance(relay: async req => …)` overload) so a PS can supply a lambda. +No spec change. **Effort: tiny; pure polish.** + +### Disposition summary + +| Item | Type | Spec anchor | Blocked? | Effort | +|------|------|-------------|----------|--------| +| H.1 `MapAAuthPersonServer` | additive role | §PS-asserted access, §Auth Token Delivery | no | large | +| H.2 `user_unreachable` emit | correctness | upcoming-changes-02 §2 | emit gated on draft-02; agent classify unblocked | small | +| H.3 deferred completion | correctness | §Interaction Response L1212 | no | small-medium | +| H.4 `Signature-Error` 401 | correctness (cosmetic) | §Error Responses L1998 | no | trivial | +| H.5 relay delegate default | ergonomics | §Interaction Endpoint | no | tiny | diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md index b538dc3..ffd34e8 100644 --- a/docs/advanced/error-handling.md +++ b/docs/advanced/error-handling.md @@ -150,6 +150,22 @@ if (!response.IsSuccessStatusCode) } ``` +### No interaction capability (`user_unreachable`) + +Deferred consent assumes the agent can reach the user. When the exchange resolves +to a `202` deferred requirement but the agent declared **no** interaction +capability (no `OnInteractionRequired` callback was supplied), there is no channel +to drive the consent to a verdict, so the SDK does not hang or poll forever — it +raises a **terminal** `AAuthTokenExchangeException` with +`ErrorCode = "user_unreachable"`, `StatusCode = 400`, and `IsTerminal = true`. +Treat it as a configuration signal: supply an interaction callback (interactive +agent) or accept that the request cannot complete unattended. + +> **Forward-looking (draft-02).** The PS *emitting* `user_unreachable` on the wire +> is a draft-02 addition; today the SDK classifies the unreachable-user case +> agent-side. The `TokenErrorCode.UserUnreachable` code is already modelled so +> adopters can pattern-match on it now. + ## Polling Errors (Deferred Consent) When polling a pending URL during deferred consent. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1725395..eb642a8 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -37,6 +37,23 @@ All configurable options across the AAuth .NET SDK, grouped by component. | `AuthorizationEndpoint` | `string?` | `null` | AS authorization URL | | `RevocationEndpoint` | `string?` | `null` | Revocation endpoint URL | +### AAuthPersonServerOptions (via MapAAuthPersonServer) + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Issuer` | `string` | — (required) | HTTPS URL of this PS (`iss` of minted auth tokens) | +| `SigningKeys` | `IReadOnlyDictionary` | — (required) | Key-id → signing key map (published at the PS JWKS) | +| `TokenPath` | `string` | `/token` | Token endpoint path | +| `PendingPathPrefix` | `string` | `/pending` | Deferred-consent poll path prefix | +| `DefaultScope` | `string` | `whoami` | Scope assumed when the resource token omits one | +| `InteractionPath` | `string` | `/interaction` | Path the host maps for the consent page | +| `TrustedAccessServers` | `IReadOnlyCollection?` | `null` | AS URLs the PS will federate to; `null`/empty ⇒ three-party only | + +The helper resolves `IIdentityClaimsAsserter` and `IPersonPendingStore` from DI +(and the `IMissionStore` / `IMissionLog` mission primitives when a request carries +a `mission` claim). See +[Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver). + ## Token Builders ### ResourceTokenBuilder diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index ec9f104..cf91cfa 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -420,5 +420,40 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` +The user channel can also be supplied as a lambda instead of a full class, via +`AddAAuthInteractionRelay(...)` (backed by `DelegateInteractionRelay`). It removes +any previously registered relay (including the no-op default) and registers the +delegate-backed one: + +```csharp +builder.Services.AddAAuthInteractionRelay((request, ct) => + Task.FromResult(new InteractionRelayResult { Accepted = true })); +``` + See [Mission Governance (Server)](../server/mission-governance.md) for the seams and the decision model. + +### Person Server side: the token-issuance seams + +The one-call PS issuer `MapAAuthPersonServer` resolves two seams from DI — the +identity/consent decision (`IIdentityClaimsAsserter`) and the deferred-consent +park store (`IPersonPendingStore`): + +```csharp +builder.Services.AddSingleton( + new DefaultIdentityClaimsAsserter("user-42")); // swap in a real asserter +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.MapAAuthPersonServer(new AAuthPersonServerOptions +{ + Issuer = psIssuer, + SigningKeys = new Dictionary { [PsKid] = psKey }, + TrustedAccessServers = trustedAccessServers, // omit ⇒ three-party only +}); +``` + +When the resource token carries a `mission` claim, the helper also resolves the +`IMissionStore` / `IMissionLog` primitives registered by `AddAAuthGovernance()`. +See [Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver). + diff --git a/docs/server/mission-governance.md b/docs/server/mission-governance.md index 0ab4a9a..b7970ce 100644 --- a/docs/server/mission-governance.md +++ b/docs/server/mission-governance.md @@ -36,6 +36,20 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` +For a lightweight user channel you can supply the relay as a lambda instead of a +full `IInteractionRelay` class, via `AddAAuthInteractionRelay(...)` (backed by +`DelegateInteractionRelay`). It replaces any relay registered earlier, including +the no-op default: + +```csharp +builder.Services.AddAAuthInteractionRelay(async (request, ct) => +{ + // request.Type is question | completion | interaction | payment + var accepted = await myUserChannel.AskAsync(request, ct); + return new InteractionRelayResult { Accepted = accepted }; +}); +``` + | Seam | Default (`AddAAuthGovernance`) | Who owns it | |------|--------------------------------|-------------| | `IMissionStore` | `InMemoryMissionStore` | SDK default; swap for durable storage | @@ -83,6 +97,12 @@ the proposal to `IMissionApprover`, persists the resulting `StoredMission`, and emits the `AAuth-Mission` response header. Reach for the manual mapping below only when an endpoint needs behavior the seams do not express. +> **Carrier-type guard.** The governed endpoints require the request to carry the +> expected token type. When the wrong carrier is presented (e.g. an auth token +> where the mission flow expects an agent token), the mapper refuses with `403` +> `invalid_carrier_token` — an authorization failure on a valid signature, not a +> `401` authentication failure. + ## Parsing requests by hand When a PS maps its own endpoints, `GovernanceEndpoints` maps request bodies to the diff --git a/docs/server/token-issuance.md b/docs/server/token-issuance.md index 1d00cc2..167b871 100644 --- a/docs/server/token-issuance.md +++ b/docs/server/token-issuance.md @@ -203,6 +203,108 @@ resource token the recipient MAY constrain `mission.approver` via For the full PS-side evaluation of mission context, see [Mission Governance (Server)](mission-governance.md). +## One-Call Person Server (`MapAAuthPersonServer`) + +The builders above are the primitives. The whole Person Server token-endpoint +pipeline also ships as a single host helper, `MapAAuthPersonServer` — the PS +counterpart to [`MapAAuthAccessServer`](../workflows/federated-access.md#access-server-side-code). +One call publishes the `/.well-known/aauth-person.json` metadata + JWKS, verifies +the RFC 9421 request signature, verifies the presented `resource_token`, and then +routes on the resource token's `aud` (§PS-AS Federation): + +- **`aud` = this PS** → three-party (PS-asserted): mint the auth token directly + (`dwk=aauth-person.json`, `iss`=PS). +- **`aud` = a trusted Access Server** → four-party (federated): forward a signed + PS→AS request via `AccessServerClient` and return the AS-issued auth token after + the §Auth Token Delivery check. + +The host owns all AAuth crypto; the identity and consent decision is delegated to +a pluggable `IIdentityClaimsAsserter`. + +```csharp +using AAuth.Person; + +// The identity/consent seam (the PS counterpart to IAccessPolicy) and the store +// that parks deferred consent decisions. +builder.Services.AddSingleton(new DefaultIdentityClaimsAsserter("user-42")); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// One call maps /.well-known + JWKS, request-signature verification, +// POST /token, and GET /pending/{id}. +app.MapAAuthPersonServer(new AAuthPersonServerOptions +{ + Issuer = psIssuer, + SigningKeys = new Dictionary { [PsKid] = psKey }, + DefaultScope = "whoami", + TrustedAccessServers = trustedAccessServers, // omit ⇒ three-party only +}); +``` + +### AAuthPersonServerOptions Properties + +| Property | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `Issuer` | Yes | — | HTTPS URL of this PS (`iss` of minted auth tokens) | +| `SigningKeys` | Yes | — | `kid → AAuthKey` map published at the PS JWKS | +| `TokenPath` | No | `/token` | The token endpoint path | +| `PendingPathPrefix` | No | `/pending` | The deferred-consent poll path prefix | +| `DefaultScope` | No | `whoami` | Scope assumed when the resource token omits one | +| `InteractionPath` | No | `/interaction` | Path the host maps for the consent page | +| `TrustedAccessServers` | No | `null` | Access Server URLs the PS will federate to; `null`/empty ⇒ three-party only | + +### The `IIdentityClaimsAsserter` seam + +The asserter is the only PS-specific decision the helper cannot make for you — +it returns the directed `sub` (plus optional `tenant` / `roles` / `groups` / +additional claims) and the consent verdict. It mirrors `IAccessPolicy` on the AS +side: + +```csharp +public interface IIdentityClaimsAsserter +{ + Task AssertAsync( + IdentityAssertionRequest request, CancellationToken cancellationToken = default); +} +``` + +The host maps the returned `IdentityAssertion` to the spec wire response: + +| `IdentityAssertion` | Wire response | +| --- | --- | +| `IdentityAssertion.Assert(sub, …)` | mint the auth token (three-party) / push the claims (four-party) | +| `IdentityAssertion.Deny(reason)` | `403 denied` | +| `IdentityAssertion.NeedsConsent()` | `202` + `AAuth-Requirement: requirement=interaction` + `Location` (poll `GET /pending/{id}`) | + +When the asserter returns `NeedsConsent()`, the helper parks the request and +returns the `202`; the host's own interaction page (mapped at `InteractionPath`) +collects the user's decision and resolves the parked entry via +`IPersonPendingStore.MarkAllowed(...)` / `MarkDenied(...)`, after which the +polling agent receives the minted token (or `403`). The consent UI stays a host +concern — the SDK only owns the protocol mechanics. + +The shipped [`DefaultIdentityClaimsAsserter`](../../samples/MockPersonServer/) +asserts a fixed directed `sub` with no prompt (a non-interactive demo PS); a +production PS swaps in an implementation that derives the principal's directed +identity and consent decision. + +### Mission three-gate packaging + +When the resource token carries a `mission` claim, `MapAAuthPersonServer` packages +the mission three-gate token-issuance mechanics around the asserter, using the +`IMissionStore` / `IMissionLog` primitives registered by +[`AddAAuthGovernance()`](mission-governance.md): + +1. **Terminated mission** → `403 mission_terminated` (the asserter is never consulted). +2. **Prior consent on record** for the `(resource, scope)` → silent mint, no prompt. +3. **Otherwise** → the asserter decides (`Assert` mints + records the grant; + `NeedsConsent` parks the `202`). + +The interactive consent/clarification screen remains host-mapped; the helper only +owns the terminated-rejection and prior-consent-silent-grant mechanics. See +[Mission Governance (Server)](mission-governance.md) for the full model. + ## Further Reading - [Verification Middleware](verification-middleware.md) — signature verification before token logic diff --git a/docs/workflows/federated-access.md b/docs/workflows/federated-access.md index a67d24a..cbc0358 100644 --- a/docs/workflows/federated-access.md +++ b/docs/workflows/federated-access.md @@ -111,6 +111,11 @@ var authToken = await accessServerClient.FederateAsync(aud, new AccessServerRequ return Results.Json(new { auth_token = authToken }); ``` +> Both branches above — the three-party mint and the four-party federation — are +> packaged in the one-call host helper `MapAAuthPersonServer` (set +> `TrustedAccessServers` to enable the federation branch). See +> [Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver). + ## Access-Server-Side Code The Access Server is the fourth party. The whole token-endpoint pipeline ships @@ -145,8 +150,6 @@ The helper resolves `IAccessPolicy` and `IAccessPendingStore` from DI. The policy returns one of `Allow` / `Deny` / `NeedsInteraction` / `NeedsClaims` / `NeedsPayment`; the helper maps those to a minted auth token, `403`, or a `202` that parks the decision and advertises the requirement to the PS. - - ### The `dwk=aauth-access.json` tell The auth token's `dwk` (discovery well-known) claim points at diff --git a/docs/workflows/mission-governed-access.md b/docs/workflows/mission-governed-access.md index 3ec1111..236dc09 100644 --- a/docs/workflows/mission-governed-access.md +++ b/docs/workflows/mission-governed-access.md @@ -136,6 +136,12 @@ bool terminated = await session.ProposeCompletionAsync( "Reconciled 24 receipts (2 duplicates removed) and emailed the summary."); ``` +If the PS's interaction relay cannot reach the user synchronously, it returns +`InteractionRelayResult { Pending = true }`; the governance mapper then answers the +completion proposal with a deferred `202` + poll `Location` (§Deferred Consent), and +the agent's `InteractionClient` polls until the user accepts or declines — the same +park-and-poll mechanics used for deferred permission consent. + After termination, any further governed request returns `403 mission_terminated`, surfaced to the agent as `AAuthMissionTerminatedException` (see [Error Handling](../advanced/error-handling.md#mission-termination)). diff --git a/docs/workflows/ps-asserted-access.md b/docs/workflows/ps-asserted-access.md index 0d9dbea..49f6cb1 100644 --- a/docs/workflows/ps-asserted-access.md +++ b/docs/workflows/ps-asserted-access.md @@ -151,6 +151,15 @@ See [Dependency Injection](../reference/dependency-injection.md) for full option - **Autonomous**: PS has standing consent → returns auth token immediately (step 3→4) - **Deferred**: PS requires user approval → returns 202 + pending URL → agent polls (see [Deferred Consent](deferred-consent.md)) +## Person-Server-Side + +The PS half of this flow (steps 3–4) ships as the one-call host helper +`MapAAuthPersonServer`: it publishes the PS metadata + JWKS, verifies the request +signature and the presented `resource_token`, delegates the identity + consent +decision to a pluggable `IIdentityClaimsAsserter`, and mints the auth token (or +parks a `202` deferred consent). See +[Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver). + ## Error Scenarios | Status | Header/Token | Cause | diff --git a/samples/MockPersonServer/README.md b/samples/MockPersonServer/README.md index b779ac3..44745c2 100644 --- a/samples/MockPersonServer/README.md +++ b/samples/MockPersonServer/README.md @@ -51,6 +51,14 @@ The PS only federates to Access Servers listed in `MockPersonServer:TrustedAccessServers`; any other `aud` is rejected with `untrusted_access_server` (403). +> **SDK one-call alternative.** Both branches above — the three-party collapsed +> mint and the four-party federation routing — are packaged by the SDK host helper +> [`MapAAuthPersonServer`](../../docs/server/token-issuance.md#one-call-person-server-mapaauthpersonserver), +> with the identity/consent decision delegated to an `IIdentityClaimsAsserter`. +> This sample keeps the endpoints hand-wired so it can render its own interactive +> consent / mission screens; a non-interactive PS can adopt the one-call helper +> directly. + ## Agent governance (missions) Beyond minting tokens, this PS doubles as the **contextual policy point** for From ef7f5f89ee5be622984b8a031525f296a48dbf1e Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 18:03:24 +0000 Subject: [PATCH 21/24] feat(samples): add combined Mission Call Chain flow to GuidedTour Adds a TourMode.MissionCallChain flow that demos one mission governing both a clarified elevated-scope grant (Clarification Chat) and a silent mission-forwarded call chain through the Orchestrator to WhoAmI (Call Chaining + Mission Log). Mirrors SampleApp's MissionCallChain flow. - Engine: 4 new step methods (clarification exchange/answer, forwarded chain, mission log) + 14-step plan dispatch in TourSession. - UI: 8th flow option, lanes, and description in Tour.razor. - Snippets: 4 illustrative SDK snippets in CodeSnippets. - e2e: mission-call-chain.spec.ts (happy + deny paths); picker.spec updated to expect 8 flows. --- samples/GuidedTour/CodeSnippets.cs | 56 +++ .../GuidedTour/Components/Pages/Tour.razor | 28 +- samples/GuidedTour/README.md | 9 +- samples/GuidedTour/TourOptions.cs | 1 + samples/GuidedTour/TourSession.cs | 461 +++++++++++++++++- .../mission-call-chain.spec.ts | 151 ++++++ .../playwright-tests/picker.spec.ts | 7 +- tests/e2e/helpers/tour.ts | 5 + 8 files changed, 707 insertions(+), 11 deletions(-) create mode 100644 samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index bfbf73f..7a4097f 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -361,4 +361,60 @@ internal static class CodeSnippets // gate 5 delete_inbox action . PROMPT (out of scope) // The PS is the policy-enforcement point; the resource stays oblivious. """; + + // ── Combined mission + call chain (§Clarification Chat, §Call Chaining) ── + + public const string MissionChainClarify = """ + // Requesting whoami:elevated_scope is OUT of the mission's intent, so the + // PS opens a clarification chat BEFORE asking the user to decide. + var session = governance.MissionSessionFor(mission); + var authToken = await session.ExchangeAsync("https://ps.example", resourceToken, + new TokenExchangeRequest + { + // The SDK surfaces the PS's question and lets the agent answer. + OnClarificationRequired = (q, _) => + Task.FromResult(ClarificationResponse.Respond( + "This mission needs the full account history to triage the inbox.")), + OnInteractionRequired = SurfaceToUser, + }); + // Raw HTTP: POST /token → 202 + AAuth-Requirement: requirement=clarification + // + { clarification: "Why does this mission need…?" } + """; + + public const string MissionChainAnswer = """ + // Answer the PS's question on the mission-pending URL. The PS records the + // exchange in the mission log and readies the user's decision. + using var req = new HttpRequestMessage(HttpMethod.Post, missionPendingUrl); + req.Content = JsonContent.Create(new + { + clarification_response = + "This mission needs the full account history to triage the inbox.", + }); + var resp = await signedClient.SendAsync(req); // → 204 No Content + // Now the agent surfaces {ps}/interaction?code={pendingId} for the user. + """; + + public const string MissionChainForward = """ + // The SAME mission now governs a multi-agent CALL CHAIN. WithMission binds + // the AAuth-Mission header; WithChallengeHandling threads the silent + // in-scope exchange; the Orchestrator forwards the mission downstream. + using var client = new AAuthClientBuilder(key) + .As("https://ps.example", agentId).WithKid(kid) + .WithPersonServer("https://ps.example") + .WithMission(mission) + .WithChallengeHandling() // (Orchestrator, orchestrate) is in scope + .Build(); + var resp = await client.GetAsync("https://orchestrator.example/mission"); + // 200: { chain, upstream, orchestrator, downstream } — downstream is + // WhoAmI's mission-bound /jwt/mission result. NO prompt: every hop in scope. + """; + + public const string MissionChainLog = """ + // DEMO-ONLY: read the mission's auditable trail by its s256 (§Mission Log). + var resp = await client.GetAsync($"https://ps.example/admin/mission-log/{s256}"); + var log = await resp.Content.ReadFromJsonAsync(); + foreach (var e in log.Entries) + Console.WriteLine($"{e.Kind} {e.Resource} {e.Scope} granted={e.Granted}"); + // The 'clarification' entry records the question + the agent's answer. + """; } diff --git a/samples/GuidedTour/Components/Pages/Tour.razor b/samples/GuidedTour/Components/Pages/Tour.razor index 2f514cd..3dc9ab6 100644 --- a/samples/GuidedTour/Components/Pages/Tour.razor +++ b/samples/GuidedTour/Components/Pages/Tour.razor @@ -41,6 +41,7 @@ + @if (Session.Mode == TourMode.Identity) @@ -166,6 +167,20 @@ creating the mission, the out-of-mission scope, and the out-of-scope action — everything in between flows without friction. break; + case TourMode.MissionCallChain: + + Signing mode: Agent Token (sig=jwt) — one durable + mission governs two very different kinds of access. An + out-of-mission elevated scope (whoami:elevated_scope) first triggers a + clarification chat — the PS asks why, the agent answers — and only + then prompts the user to approve. After that, a mission-forwarded call chain + (Agent → Orchestrator → WhoAmI) flows silently: because both hops + (orchestrate, whoami) are in the mission's scope, the Orchestrator + forwards the AAuth-Mission header downstream and no prompt is needed. + Two human approvals (mission creation, the clarified elevated scope) frame an + otherwise-silent multi-agent chain, and the PS's mission log records it all. + + break; }

@@ -200,8 +215,8 @@ Lanes="@ActiveLanes" IsPolling="@Session.IsPolling" PollCount="@Session.PollCount" - LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)" - LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? Session.PollStepNumber : 0)" + LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode || Session.IsMissionCallChainMode) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)" + LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode || Session.IsMissionCallChainMode) ? Session.PollStepNumber : 0)" CompletedLoopLabel="@CompletedLoopLabel()" LoopCompletedKind="@CompletedLoopKind()" /> @@ -259,9 +274,18 @@ new("Person Server", "ps", Actor.PersonServer), }; + private static readonly SequenceDiagram.LaneDefinition[] MissionCallChainLanes = + { + new("Agent", "agent", Actor.Agent), + new("Resource", "resource", Actor.Resource), + new("Orchestrator", "resource2", Actor.Orchestrator), + new("Person Server", "ps", Actor.PersonServer), + }; + private SequenceDiagram.LaneDefinition[]? ActiveLanes => Session.IsBootstrapMode ? BootstrapLanes : Session.IsIdentityMode ? IdentityLanes : + Session.IsMissionCallChainMode ? MissionCallChainLanes : Session.IsCallChainMode ? CallChainLanes : Session.IsFederatedMode ? FederatedLanes : Session.IsMissionMode ? MissionLanes : null; diff --git a/samples/GuidedTour/README.md b/samples/GuidedTour/README.md index 8abec96..637c7b1 100644 --- a/samples/GuidedTour/README.md +++ b/samples/GuidedTour/README.md @@ -13,7 +13,7 @@ hop. A swim-lane sequence diagram across up to four actors — **Agent**, **Orchestrator**, **Resource**, **Person Server** — with a payload inspector on the right that decodes each JWT and shows the canonical -RFC 9421 signature base for every signed request. Seven flows are +RFC 9421 signature base for every signed request. Eight flows are available, switchable at runtime from the topbar **Mode** picker: * **Bootstrap** (2–3 steps) — generate the agent's signing key and build @@ -44,6 +44,13 @@ available, switchable at runtime from the topbar **Mode** picker: contextual policy point. A mission-aware Resource copies the `AAuth-Mission` claim into its resource token. Requires a Person Server URL; drive the same flow from the CLI with `make demo-mission`. +* **Mission + Call Chain** (14 steps; two prompts) — one durable mission + governs two very different kinds of access. An out-of-mission elevated + scope first triggers a **clarification chat** (the PS asks *why*, the + agent answers) before the user approves it; then a **mission-forwarded + call chain** (Agent → Orchestrator → WhoAmI) flows **silently** because + both hops are in the mission's scope. The PS's mission log records the + whole trail. Requires a Person Server and an Orchestrator URL. When `PersonServerUrl` is empty in `appsettings.json`, the picker locks to Identity-based (the three-party options are disabled). You can also set diff --git a/samples/GuidedTour/TourOptions.cs b/samples/GuidedTour/TourOptions.cs index 207a8dd..69ee843 100644 --- a/samples/GuidedTour/TourOptions.cs +++ b/samples/GuidedTour/TourOptions.cs @@ -20,6 +20,7 @@ public enum TourMode CallChain, Federated, Mission, + MissionCallChain, } /// diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index b737e07..c947fb0 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -61,6 +61,16 @@ public sealed class TourSession : IAsyncDisposable private string? _missionEndpoint; private string? _permissionEndpoint; + // Combined mission + call-chain flow state (§Missions, §Clarification Chat, + // §Call Chaining). The clarification round on the elevated-scope token gate + // captures the PS's question + the agent's answer + the mission-pending id + // the user-approval and poll steps drive; the forwarded-chain step captures + // the combined Orchestrator → WhoAmI mission-governed result. + private string? _missionPendingId; + private string? _clarificationQuestion; + private string? _clarificationAnswer; + private string? _missionChainResponseBody; + // Background polling state (deferred mode, poll step). Mutated from // the polling task; the UI listens to StateChanged and re-renders. private CancellationTokenSource? _pollingCts; @@ -156,6 +166,15 @@ public SigningMode SigningMode /// private string MissionElevatedResourceUrl => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt/mission/elevated"; + /// + /// The Orchestrator's mission-governed chain endpoint (§Call Chaining, + /// §Mission Context at Resources). The agent advertises its mission here; + /// the Orchestrator copies it into its resource_token and — once the + /// in-scope token is minted — forwards the AAuth-Mission header downstream + /// to WhoAmI's mission-aware path, so one mission governs the whole chain. + /// + private string MissionChainTargetUrl => $"{_options.OrchestratorUrl!.TrimEnd('/')}/mission"; + /// /// True when the current flow is the identity-based path. Forced on /// when no PS URL is configured, regardless of . @@ -182,6 +201,15 @@ public SigningMode SigningMode /// True when the current flow is the mission-governed (PS-as-policy) path. public bool IsMissionMode => HasPersonServer && _mode == TourMode.Mission; + /// + /// True when the current flow is the combined mission + call-chain path + /// (§Missions, §Call Chaining): a durable mission governs an elevated-scope + /// clarification round and then a mission-forwarded Agent → Orchestrator → + /// WhoAmI chain. Requires both a Person Server and an Orchestrator. + /// + public bool IsMissionCallChainMode => + HasPersonServer && _mode == TourMode.MissionCallChain && HasOrchestrator; + /// /// True when the call-chain flow has entered its multi-hop consent path: /// the agent's exchange 202'd (no standing consent), so the flow surfaces @@ -206,6 +234,7 @@ public int TotalSteps { if (IsBootstrapMode) return HasAgentProvider ? 3 : 2; if (IsIdentityMode) return 2; + if (IsMissionCallChainMode) return 14; if (IsMissionMode) return 20; if (IsCallChainMode) return _callChainPending ? 13 : 7; if (IsFederatedMode) return _federatedPending ? 10 : 7; @@ -225,6 +254,7 @@ public IReadOnlyList Plan { if (IsBootstrapMode) return HasAgentProvider ? ApBootstrapPlan : LocalBootstrapPlan; if (IsIdentityMode) return IdentityPlan; + if (IsMissionCallChainMode) return MissionCallChainPlan; if (IsMissionMode) return MissionPlan; if (IsCallChainMode) return _callChainPending ? CallChainConsentPlan : CallChainPlan; if (IsFederatedMode) return _federatedPending ? FederatedConsentPlan : FederatedPlan; @@ -339,6 +369,32 @@ public IReadOnlyList Plan new(20, "Inspect mission result", "Review the full governed flow: one mission, one silent token, one prompted scope, one local tool, one prompted action.", Actor.Agent, Actor.Agent), }; + // The combined mission + call-chain flow (§Missions, §Clarification Chat, + // §Call Chaining). One durable mission governs two distinct kinds of access: + // an out-of-mission ELEVATED scope that triggers a clarification round before + // the user approves (cycle 2), and a mission-FORWARDED call chain that flows + // silently through the Orchestrator to WhoAmI because both hops are in scope. + // Mirrors the SampleApp MissionCallChain page as a step-by-step raw-HTTP + // walkthrough: two prompts (mission creation, elevated scope) frame an + // otherwise-silent multi-agent chain, and the PS's mission log records it all. + private static readonly TourPlanStep[] MissionCallChainPlan = + { + new(1, "Discover Person Server metadata", "Unsigned GET /.well-known/aauth-person.json for mission_endpoint + token_endpoint.", Actor.Agent, Actor.PersonServer), + new(2, "Propose mission → 202 (PROMPT)", "Signed POST /mission {description, tools}; the PS parks the proposal and returns 202 + interaction URL + single-use code.", Actor.Agent, Actor.PersonServer), + new(3, "Direct user to mission approval", "Agent surfaces the {url}?code={code} link for the user to approve the durable mission.", Actor.Agent, Actor.Agent), + new(4, "User approves the mission at the PS", "User opens the PS consent page and approves the mission; the PS records the approved mission + tools.", Actor.PersonServer, Actor.PersonServer), + new(5, "Poll → 200 mission approval blob", "Signed GETs to the mission-pending URL until the PS returns the verbatim approval blob + AAuth-Mission header (s256).", Actor.Agent, Actor.PersonServer), + new(6, "Signed GET /jwt/mission/elevated → 401", "Signed request for the ELEVATED whoami:elevated_scope advertises AAuth-Mission; the resource copies the mission into a resource_token and challenges with 401.", Actor.Agent, Actor.Resource), + new(7, "Exchange → 202 clarification (PS asks)", "Signed POST /token; the elevated scope is out of mission, so before any decision the PS opens a clarification chat — 202 + requirement=clarification + the question.", Actor.Agent, Actor.PersonServer), + new(8, "Answer the clarification → 204", "The agent POSTs {clarification_response} to the mission-pending URL; the PS records the answer and readies the user's decision.", Actor.Agent, Actor.PersonServer), + new(9, "Direct user to scope approval", "Agent relays the interaction URL for the user to approve the now-clarified out-of-mission elevated scope.", Actor.Agent, Actor.Agent), + new(10, "User approves the elevated scope at the PS", "User approves whoami:elevated_scope at the PS; the consent accrues to the mission.", Actor.PersonServer, Actor.PersonServer), + new(11, "Poll → 200 auth_token (elevated)", "Signed GETs to the mission-pending URL until the PS returns the elevated auth_token.", Actor.Agent, Actor.PersonServer), + new(12, "Replay GET /jwt/mission/elevated → 200", "Signed retry with the elevated auth_token returns the protected claims.", Actor.Agent, Actor.Resource), + new(13, "Mission-forwarded call chain → 200 (SILENT)", "Signed GET the Orchestrator's /mission carrying AAuth-Mission; both hops (Agent → Orchestrator, Orchestrator → WhoAmI) are in mission scope, so the whole chain resolves with NO prompt. The internal hops are shown as grouped sub-steps.", Actor.Agent, Actor.Orchestrator), + new(14, "Inspect the mission log", "Signed GET /admin/mission-log/{s256}; review the ordered, auditable trail the PS recorded for the mission — the clarification, the token grants, and the chained access.", Actor.Agent, Actor.PersonServer), + }; + private static readonly TourPlanStep[] FederatedPlan = { new(1, "Discover resource metadata", "Unsigned GET /federated/.well-known/aauth-resource.json.", Actor.Agent, Actor.Resource), @@ -383,7 +439,10 @@ public IReadOnlyList Plan /// The step number at which user approval occurs in deferred mode. public int UserApprovalStepNumber => - IsMissionMode + IsMissionCallChainMode + ? (Steps.Count <= MissionChainCreatePollStep ? MissionChainCreateApprovalStep + : MissionChainElevatedApprovalStep) + : IsMissionMode ? (Steps.Count <= MissionHop1PollStep ? MissionHop1ApprovalStep : Steps.Count <= MissionHop2PollStep ? MissionHop2ApprovalStep : MissionHop3ApprovalStep) @@ -393,7 +452,10 @@ public IReadOnlyList Plan /// The step number at which polling occurs in deferred mode. public int PollStepNumber => - IsMissionMode + IsMissionCallChainMode + ? (Steps.Count <= MissionChainCreatePollStep ? MissionChainCreatePollStep + : MissionChainElevatedPollStep) + : IsMissionMode ? (Steps.Count <= MissionHop1PollStep ? MissionHop1PollStep : Steps.Count <= MissionHop2PollStep ? MissionHop2PollStep : MissionHop3PollStep) @@ -418,6 +480,15 @@ public IReadOnlyList Plan private const int MissionHop3ApprovalStep = 18; private const int MissionHop3PollStep = 19; + // Combined mission + call-chain consent path step numbers: cycle 1 (mission + // creation, steps 4/5) and cycle 2 (the out-of-mission elevated scope token + // with its clarification round, steps 10/11). The forwarded chain (step 13) + // is silent — no approval cycle. + private const int MissionChainCreateApprovalStep = 4; + private const int MissionChainCreatePollStep = 5; + private const int MissionChainElevatedApprovalStep = 10; + private const int MissionChainElevatedPollStep = 11; + /// /// The actor the current poll loop targets: the Person Server for the /// three-party / federated / call-chain hop-1 polls, or the Orchestrator @@ -433,7 +504,7 @@ public IReadOnlyList Plan /// and the UI should expose the "Approve as user" action button. /// public bool AwaitingUserApproval => - (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode) + (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode) && Steps.Count + 1 == UserApprovalStepNumber && !_userApproved; /// The user-facing interaction URL captured during step 7 (deferred only). @@ -519,6 +590,10 @@ private void ResetTimeline() _missionResponseBody = null; _missionEndpoint = null; _permissionEndpoint = null; + _missionPendingId = null; + _clarificationQuestion = null; + _clarificationAnswer = null; + _missionChainResponseBody = null; } /// @@ -715,6 +790,47 @@ public async Task RunNextAsync(CancellationToken ct = default) case 7: StepFederatedInspectResult(); break; } } + else if (IsMissionCallChainMode) + { + switch (nextStep) + { + // Cycle 1 — mission creation (gate 1 PROMPT). + case 1: await StepMissionDiscoverPersonAsync(ct); break; + case 2: await StepMissionProposeAsync(ct); break; + case 3: StepDirectUserToInteraction(); break; + case 4: StepUserApprovesPlaceholder(); break; + case 5: + if (_pollingTask is { } mcCreate && !mcCreate.IsCompleted) + { + await mcCreate.ConfigureAwait(false); + } + else if (Steps.Count + 1 == PollStepNumber) + { + await StepMissionPollCreateAsync(ct); + } + break; + // Cycle 2 — out-of-mission elevated scope with a clarification round. + case 6: await StepMissionElevatedChallengeAsync(ct); break; + case 7: await StepMissionChainClarificationExchangeAsync(ct); break; + case 8: await StepMissionChainAnswerClarificationAsync(ct); break; + case 9: StepDirectUserToInteraction(); break; + case 10: StepUserApprovesPlaceholder(); break; + case 11: + if (_pollingTask is { } mcElev && !mcElev.IsCompleted) + { + await mcElev.ConfigureAwait(false); + } + else if (Steps.Count + 1 == PollStepNumber) + { + await StepMissionElevatedPollAsync(ct); + } + break; + case 12: await StepMissionElevatedReplayAsync(ct); break; + // Mission-forwarded call chain (SILENT) + the mission log. + case 13: await StepMissionChainForwardedAsync(ct); break; + case 14: await StepMissionChainLogAsync(ct); break; + } + } else if (IsMissionMode) { switch (nextStep) @@ -864,7 +980,7 @@ private void RefreshAgentToken() /// public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default) { - if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode)) { return Task.CompletedTask; } + if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode)) { return Task.CompletedTask; } if (Steps.Count + 1 != UserApprovalStepNumber) { throw new InvalidOperationException( @@ -904,6 +1020,46 @@ public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default) return Task.CompletedTask; } + if (IsMissionCallChainMode) + { + var hopStep = Steps.Count + 1; + var isCreation = hopStep == MissionChainCreateApprovalStep; + var title = isCreation + ? "User approves the mission at the PS" + : "User approves the elevated scope at the PS"; + var narrative = isCreation + ? "The tour opened the PS's mission-approval page in a new browser tab. " + + "The Person Server rendered its consent screen showing the proposed " + + "**mission** description and the tools it may use. The user clicked " + + "**Approve**, and the PS recorded the durable mission via " + + "`POST /interaction/approve`. Every later request — including the " + + "forwarded call chain — is checked against this mission. The agent " + + "discovers the signed approval blob on its next poll." + : "The tour opened the PS's consent page in a new browser tab. After the " + + "clarification chat resolved, the Person Server showed that the agent is " + + "requesting the elevated **whoami:elevated_scope** \u2014 a scope that " + + "falls **outside** the mission's natural-language intent. The user clicked " + + "**Approve**, and the PS recorded the consent against the mission via " + + "`POST /interaction/approve`; the decision now accrues to the mission. " + + "The agent learns the verdict on its next poll. (A **Deny** here yields " + + "`denied`.)"; + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = title, + From = Actor.PersonServer, + To = Actor.PersonServer, + Narrative = narrative, + ResponseBody = userUrl, + TokenDecoded = + $"Interaction URL opened in new tab:\n {userUrl}\n\n" + + "User performed (browser → PS):\n" + + $" GET /interaction?code={_interactionCode}\n" + + $" POST /interaction/approve (form: code={_interactionCode})", + }); + return Task.CompletedTask; + } + if (IsMissionMode) { var hopStep = Steps.Count + 1; @@ -1013,6 +1169,39 @@ public async Task PrepareConsentStateAsync(CancellationToken ct = default) return; } + // Combined mission + call-chain mode: reset, script an interactive run, + // turn ON the clarification round for the out-of-mission elevated token + // gate, and seed BOTH in-scope pairs the forwarded chain rides on — + // (Orchestrator, orchestrate) and (WhoAmI, whoami) — so the multi-agent + // chain resolves silently while only the elevated scope prompts. Matches + // the SampleApp MissionCallChain page's ConfigurePersonServerAsync script. + if (IsMissionCallChainMode) + { + var ps = _options.PersonServerUrl!.TrimEnd('/'); + try + { + await client.PostAsync($"{ps}/admin/reset", null, ct); + await client.PostAsJsonAsync($"{ps}/admin/mission-script", new + { + reset = true, + interactive = true, + approveMission = true, + approveToken = true, + approvePermission = true, + requireClarification = true, + clarificationQuestion = + "Why does this mission need elevated access to your full account history?", + inScope = new[] + { + new { resource = _options.OrchestratorUrl!.TrimEnd('/'), scope = "orchestrate" }, + new { resource = _options.WhoAmIUrl.TrimEnd('/'), scope = "whoami" }, + }, + }, ct); + } + catch { /* /admin/* only exists on MockPersonServer — swallow. */ } + return; + } + // Mission mode: reset all PS state, then script the consent screen to // be interactive (browser-driven) and seed the in-scope (resource, // whoami) pair so gate 2 is silent. Mission creation + the out-of-scope @@ -1703,7 +1892,7 @@ private async Task RunPendingPollAsync( /// public Task StartPendingPollAsync() { - if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode) || _pendingUrl is null) + if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode) || _pendingUrl is null) { return Task.CompletedTask; } @@ -1726,6 +1915,12 @@ public Task StartPendingPollAsync() var missionElevatedPoll = IsMissionMode && Steps.Count + 1 == MissionHop2PollStep; var missionPermissionPoll = IsMissionMode && Steps.Count + 1 == MissionHop3PollStep; + // Combined mission + call-chain mode has two poll cycles: cycle 1 returns + // the mission approval blob (step 5), cycle 2 returns the elevated + // auth_token after the clarification round (step 11). + var missionChainCreatePoll = IsMissionCallChainMode && Steps.Count + 1 == MissionChainCreatePollStep; + var missionChainElevatedPoll = IsMissionCallChainMode && Steps.Count + 1 == MissionChainElevatedPollStep; + // Serialize the check-then-assign so two near-simultaneous UI // events (e.g. "Open consent" + "Simulate deny") can't both kick // off a poll. Blazor Server's circuit context already serializes @@ -1748,6 +1943,8 @@ public Task StartPendingPollAsync() missionCreatePoll ? StepMissionPollCreateAsync(ct) : missionElevatedPoll ? StepMissionElevatedPollAsync(ct) : missionPermissionPoll ? StepMissionPollPermissionAsync(ct) + : missionChainCreatePoll ? StepMissionPollCreateAsync(ct) + : missionChainElevatedPoll ? StepMissionElevatedPollAsync(ct) : hop2 ? StepCallChainPollHop2Async(ct) : StepPollPendingAsync(ct); await poll.ConfigureAwait(false); @@ -3271,6 +3468,260 @@ private void StepMissionInspectResult() }); } + private async Task StepMissionChainClarificationExchangeAsync(CancellationToken ct) + { + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var resp = await client.PostAsJsonAsync(_tokenEndpoint!, new + { + resource_token = _resourceToken, + }, ct); + + var ex = capture.Last!; + if (resp.StatusCode == HttpStatusCode.Accepted) + { + // A fresh user approval is required for the elevated-scope gate, but + // first the PS runs a clarification chat: it parks the request and + // asks WHY the mission needs this out-of-scope access. The 202 carries + // the mission-pending URL (Location) + requirement=clarification + the + // question body — but NO interaction URL yet (that comes after we + // answer). Capture the pending URL + id + question for the next steps. + _userApproved = false; + var location = resp.Headers.Location?.ToString(); + if (location is not null) + { + _pendingUrl = location.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? location + : $"{_options.PersonServerUrl!.TrimEnd('/')}{location}"; + _missionPendingId = location.TrimEnd('/').Split('/').LastOrDefault(); + } + try + { + var body = JsonNode.Parse(ex.ResponseBody); + _clarificationQuestion = (string?)body?["clarification"]; + } + catch (JsonException) { /* leave the question null — raw body still shows */ } + } + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Exchange → 202 clarification (the PS asks a question)", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent POSTs the elevated `resource_token` to the `token_endpoint`. " + + "`whoami:elevated_scope` falls **outside** the mission's intent, so before " + + "it asks the user to decide the PS opens a **clarification chat** " + + "(§Clarification Chat): it returns `202` with " + + "`AAuth-Requirement: requirement=clarification`, a `Location` (the " + + "mission-pending URL), and a question in the body. No interaction URL is " + + "issued yet — the agent must answer first.", + RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}", + RequestHeaders = ex.RequestHeaders, + RequestBody = PrettyJson(ex.RequestBody), + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + TokenDecoded = _clarificationQuestion is null + ? null + : $"PS asked:\n {_clarificationQuestion}", + CodeSnippet = CodeSnippets.MissionChainClarify, + }); + } + + private async Task StepMissionChainAnswerClarificationAsync(CancellationToken ct) + { + const string answer = + "This mission needs the full account history to triage the inbox."; + _clarificationAnswer = answer; + + string? capturedBase = null; + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var signing = BuildSigningHandler( + () => _agentToken!, capture, (_, b) => capturedBase = b); + using var client = new HttpClient(signing); + + using var resp = await client.PostAsJsonAsync(_pendingUrl!, new + { + clarification_response = answer, + }, ct); + + var ex = capture.Last!; + + // The clarification is satisfied (204 No Content); the PS readies the + // user's decision. Now the agent can surface the interaction URL — the + // mission-pending id doubles as the single-use interaction code, and the + // PS's interaction page lives at {ps}/interaction. + if (_missionPendingId is not null) + { + _interactionUrl = $"{_options.PersonServerUrl!.TrimEnd('/')}/interaction"; + _interactionCode = _missionPendingId; + } + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Answer the clarification → 204", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "The agent answers the PS's question with a signed " + + "`POST {mission-pending}` carrying `{ clarification_response }`. The PS " + + "records the answer in the mission log and transitions the parked request " + + "to *awaiting the user's decision* — it returns `204 No Content`. The " + + "agent now constructs the interaction URL (the mission-pending id is the " + + "single-use code) and is ready to direct the user to approve the scope.", + RequestLine = $"{ex.RequestLine} → {_pendingUrl}", + RequestHeaders = ex.RequestHeaders, + RequestBody = PrettyJson(ex.RequestBody), + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = ex.ResponseBody, + TokenDecoded = $"Agent answered:\n {answer}", + CodeSnippet = CodeSnippets.MissionChainAnswer, + }); + } + + private async Task StepMissionChainForwardedAsync(CancellationToken ct) + { + // Fresh agent token (new jti) so the Orchestrator's replay detection + // does not reject the mission-aware challenge. + RefreshAgentToken(); + + // ── Hop A: challenge the Orchestrator's mission endpoint ───────────── + var challengeCapture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var challengeSigning = BuildSigningHandler(() => _agentToken!, challengeCapture); + using (var challengeClient = new HttpClient(challengeSigning)) + { + using var challengeReq = new HttpRequestMessage(HttpMethod.Get, MissionChainTargetUrl); + if (_missionApprover is not null && _missionS256 is not null) + { + challengeReq.Headers.TryAddWithoutValidation( + AAuthMissionHeader.Name, + AAuthMissionHeader.FormatStructured(_missionApprover, _missionS256)); + } + using var challengeResp = await challengeClient.SendAsync(challengeReq, ct); + if (challengeResp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals)) + { + foreach (var raw in reqVals) + { + if (string.IsNullOrWhiteSpace(raw)) { continue; } + try + { + _resourceToken = AAuthRequirementHeader.Parse(raw).ResourceToken; + if (_resourceToken is not null) { break; } + } + catch (FormatException) { /* try the next header value */ } + } + } + } + + // ── Hop B: exchange the Orchestrator resource_token at the PS ──────── + // The mission claim travels in the resource_token and (Orchestrator, + // orchestrate) is in mission scope, so the PS mints the auth_token + // SILENTLY — no prompt. + var exchangeCapture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var exchangeSigning = BuildSigningHandler(() => _agentToken!, exchangeCapture); + using (var exchangeClient = new HttpClient(exchangeSigning)) + { + using var exchangeResp = await exchangeClient.PostAsJsonAsync(_tokenEndpoint!, new + { + resource_token = _resourceToken, + }, ct); + var exchangeBody = JsonNode.Parse(exchangeCapture.Last!.ResponseBody); + _authToken = (string?)exchangeBody?["auth_token"]; + } + + // ── Hop C: retry the Orchestrator with the auth_token ──────────────── + // The Orchestrator validates it, forwards the mission downstream to + // WhoAmI's mission-aware path, and returns the combined chain result. + string? capturedBase = null; + var retryCapture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + var retrySigning = BuildSigningHandler( + () => _authToken!, retryCapture, (_, b) => capturedBase = b); + using var retryClient = new HttpClient(retrySigning); + await retryClient.GetAsync(MissionChainTargetUrl, ct); + var ex = retryCapture.Last!; + _missionChainResponseBody = ex.ResponseBody; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Mission-forwarded call chain → 200 (SILENT)", + From = Actor.Agent, + To = Actor.Orchestrator, + Narrative = + "The agent now drives a **mission-governed call chain**. It advertises the " + + "same `AAuth-Mission` header to the Orchestrator's `/mission` endpoint; the " + + "Orchestrator copies the mission into a `resource_token`, the agent exchanges " + + "it at the PS — and because `(Orchestrator, orchestrate)` is in the mission " + + "scope, the PS mints the `auth_token` **silently**. On the retry the " + + "Orchestrator forwards the `AAuth-Mission` header **downstream** to WhoAmI's " + + "mission-aware path, where `(WhoAmI, whoami)` is **also** in scope — so the " + + "entire Agent → Orchestrator → WhoAmI chain resolves with **no prompt**. " + + "One mission governs every hop. The internal hops are shown as grouped " + + "sub-steps; the `downstream` object is WhoAmI's mission-bound result.", + RequestLine = $"{ex.RequestLine} → {MissionChainTargetUrl}", + RequestHeaders = ex.RequestHeaders, + SignatureBase = capturedBase, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionChainForward, + SubSteps = new SubStep[] + { + new("GET /mission + AAuth-Mission (agent token)", Actor.Agent, Actor.Orchestrator), + new("401 + resource_token (mission copied)", Actor.Orchestrator, Actor.Agent, IsResponse: true), + new("POST /token + resource_token", Actor.Agent, Actor.PersonServer), + new("200 + auth_token (SILENT — in scope)", Actor.PersonServer, Actor.Agent, IsResponse: true), + new("GET /mission (auth_token)", Actor.Agent, Actor.Orchestrator), + new("Orchestrator forwards AAuth-Mission → WhoAmI /jwt/mission", Actor.Orchestrator, Actor.Resource), + new("200 + combined chain result", Actor.Orchestrator, Actor.Agent, IsResponse: true), + }, + }); + } + + private async Task StepMissionChainLogAsync(CancellationToken ct) + { + // The mission log is a DEMO-ONLY admin endpoint on the Mock Person + // Server — an unauthenticated read of the auditable trail the mission + // accrued. A real PS would gate this behind the user's own session. + var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() }; + using var client = new HttpClient(capture); + var url = $"{_options.PersonServerUrl!.TrimEnd('/')}/admin/mission-log/{_missionS256}"; + await client.GetAsync(url, ct); + var ex = capture.Last!; + + Steps.Add(new StepRecord + { + Number = Steps.Count + 1, + Title = "Inspect the mission log", + From = Actor.Agent, + To = Actor.PersonServer, + Narrative = + "Finally the agent reads the **mission log** the PS kept — the " + + "authoritative, ordered record of every governed step under this mission " + + "(§Mission Log). It shows the **clarification** round (the question and " + + "the agent's answer), the elevated-scope token grant, and the in-scope " + + "token grants the forwarded chain rode on. One durable mission, one " + + "reviewable trail: the PS was the policy-enforcement point throughout, " + + "and the resources stayed oblivious to the user's policy.", + RequestLine = $"{ex.RequestLine} → {url}", + RequestHeaders = ex.RequestHeaders, + StatusLine = ex.StatusLine, + ResponseHeaders = ex.ResponseHeaders, + ResponseBody = PrettyJson(ex.ResponseBody), + CodeSnippet = CodeSnippets.MissionChainLog, + }); + } + /// /// Capture the pending URL + interaction (URL + single-use code) from a /// mission/permission `202 Accepted` response so the user-approval and diff --git a/samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts b/samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts new file mode 100644 index 0000000..39abeca --- /dev/null +++ b/samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '../../../tests/e2e/helpers/fixtures'; +import { + openTour, + selectFlow, + runAll, + selectStep, + expectResponse, + readResponseJson, + doneSteps, + TourMode, +} from '../../../tests/e2e/helpers/tour'; +import { approveInPopup, denyInPopup } from '../../../tests/e2e/helpers/consent'; + +/** + * Mission + Call Chain — one durable, human-approved mission governs two very + * different kinds of access across 14 steps: + * + * 1. Mission creation (steps 4/5): the user approves the durable mission and + * its tools; the agent polls for the signed approval blob. + * 2. Clarified elevated scope (steps 7/8/10/11): requesting + * `whoami:elevated_scope` falls outside the mission's intent, so the PS + * first opens a CLARIFICATION CHAT — it asks WHY (step 7, 202), the agent + * answers (step 8, 204) — and only then prompts the user (step 10), + * issuing the elevated auth_token on the next poll (step 11). + * 3. Mission-forwarded call chain (step 13): the SAME mission drives an + * Agent → Orchestrator → WhoAmI chain. Both hops (`orchestrate`, + * `whoami`) are in mission scope, so the Orchestrator forwards the + * `AAuth-Mission` header downstream and the whole chain resolves SILENTLY. + * + * The PS's mission log (step 14) records it all — including the clarification + * round. Generous timeout covers two poll loops. + */ +test.describe('Mission + Call Chain (Guided Tour)', () => { + test.describe.configure({ timeout: 180_000 }); + + test('one mission governs a clarified elevated grant and a silent call chain', async ({ + page, + context, + }) => { + await openTour(page); + await selectFlow(page, TourMode.MissionCallChain); + + // ---- Cycle 1: mission creation (PROMPT) ------------------------------ + await runAll(page); + // Parked on the mission-approval step (3 done: discover, propose, direct). + await expect(doneSteps(page)).toHaveCount(3); + const createLink = page.locator('a.primary.approve'); + await expect(createLink).toBeVisible(); + const [createPopup] = await Promise.all([ + context.waitForEvent('page'), + createLink.click(), + ]); + await approveInPopup(createPopup); + // user-approval + create poll resolve (5 of 14). + await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 }); + + // ---- Clarification chat + cycle 2: elevated scope (PROMPT) ----------- + await runAll(page); + // Steps 6 (elevated challenge → 401), 7 (exchange → 202 clarification), + // 8 (answer → 204), 9 (direct-user) run, parking on the elevated-scope + // approval (9 done). + await expect(doneSteps(page)).toHaveCount(9, { timeout: 60_000 }); + const elevatedLink = page.locator('a.primary.approve'); + await expect(elevatedLink).toBeVisible(); + const [elevatedPopup] = await Promise.all([ + context.waitForEvent('page'), + elevatedLink.click(), + ]); + await approveInPopup(elevatedPopup); + // user-approval + elevated poll resolve (11 of 14). + await expect(doneSteps(page)).toHaveCount(11, { timeout: 120_000 }); + + // ---- Silent replay + mission-forwarded call chain + log -------------- + await runAll(page); + // Steps 12 (elevated replay → 200), 13 (forwarded chain → 200 SILENT), + // 14 (mission log) run with no further prompts (14 done). + await expect(doneSteps(page)).toHaveCount(14, { timeout: 60_000 }); + + // Step 7 ("Exchange → 202 clarification"): the PS asked WHY before consent. + await selectStep(page, 6); + await expectResponse(page, 202, ['clarification']); + const clarify = (await readResponseJson(page)) as Record; + expect(String(clarify.clarification)).toContain('elevated access'); + + // Step 8 ("Answer the clarification → 204"): the agent's answer is recorded. + await selectStep(page, 7); + await expectResponse(page, 204); + await expect(page.locator('section.payload')).toContainText('triage the inbox'); + + // Step 12 ("Replay GET /jwt/mission/elevated → 200"): the elevated result. + await selectStep(page, 11); + await expectResponse(page, 200, ['mission-elevated']); + const elevated = (await readResponseJson(page)) as Record; + expect(elevated.access).toBe('mission-elevated'); + expect(elevated.scope).toEqual(['whoami:elevated_scope']); + + // Step 13 ("Mission-forwarded call chain → 200 (SILENT)"): one mission + // governed every hop. The combined result nests WhoAmI's mission-bound + // downstream object reached via the Orchestrator. + await selectStep(page, 12); + await expectResponse(page, 200, ['downstream']); + const chain = (await readResponseJson(page)) as Record; + expect(String(chain.chain)).toContain('WhoAmI'); + const downstream = chain.downstream as Record; + expect(downstream.mode).toBe('three-party'); + expect(downstream.access).toBe('mission'); + expect(downstream.scope).toEqual(['whoami']); + // The mission travelled all the way downstream (silent because in scope). + expect(downstream.mission).toBeTruthy(); + + // Step 14 ("Inspect the mission log"): the PS kept an auditable trail that + // includes the clarification round. + await selectStep(page, 13); + await expectResponse(page, 200, ['entries']); + const log = (await readResponseJson(page)) as { entries: Array> }; + expect(Array.isArray(log.entries)).toBe(true); + expect(log.entries.some((e) => e.kind === 'clarification')).toBe(true); + }); + + test('deny at the clarified elevated-scope gate yields denied', async ({ page, context }) => { + await openTour(page); + await selectFlow(page, TourMode.MissionCallChain); + + // Cycle 1: approve the mission. + await runAll(page); + const createLink = page.locator('a.primary.approve'); + await expect(createLink).toBeVisible(); + const [createPopup] = await Promise.all([ + context.waitForEvent('page'), + createLink.click(), + ]); + await approveInPopup(createPopup); + await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 }); + + // Advance through the clarification chat to the elevated-scope gate, DENY. + await runAll(page); + await expect(doneSteps(page)).toHaveCount(9, { timeout: 60_000 }); + const elevatedLink = page.locator('a.primary.approve'); + await expect(elevatedLink).toBeVisible(); + const [elevatedPopup] = await Promise.all([ + context.waitForEvent('page'), + elevatedLink.click(), + ]); + await denyInPopup(elevatedPopup); + + // The flow aborts: the primary button locks to "Aborted" and the poll loop + // records a terminal denied step. + await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 }); + await expect(doneSteps(page).last()).toContainText(/denied/i); + }); +}); diff --git a/samples/GuidedTour/playwright-tests/picker.spec.ts b/samples/GuidedTour/playwright-tests/picker.spec.ts index 78a5291..d0a6fd9 100644 --- a/samples/GuidedTour/playwright-tests/picker.spec.ts +++ b/samples/GuidedTour/playwright-tests/picker.spec.ts @@ -2,16 +2,16 @@ import { test, expect } from '../../../tests/e2e/helpers/fixtures'; import { openTour } from '../../../tests/e2e/helpers/tour'; /** - * Flow picker structure: all seven flows are offered, the signing-mode picker is + * Flow picker structure: all eight flows are offered, the signing-mode picker is * Identity-only, and the description text reacts to the selected flow. This is a * UI-structure spec (no protocol result), guarding the entry point every other * spec depends on. */ -test('flow picker offers all seven flows and reacts to selection', async ({ page }) => { +test('flow picker offers all eight flows and reacts to selection', async ({ page }) => { await openTour(page); const flow = page.locator('select#flow-select'); - await expect(flow.locator('option')).toHaveCount(7); + await expect(flow.locator('option')).toHaveCount(8); await expect(flow.locator('option')).toContainText([ 'Bootstrap', 'Identity-based', @@ -20,6 +20,7 @@ test('flow picker offers all seven flows and reacts to selection', async ({ page 'Call Chain', 'Federated (Four-Party)', 'Mission (PS-Governed)', + 'Mission + Call Chain', ]); // Signing-mode picker only appears for the Identity flow. diff --git a/tests/e2e/helpers/tour.ts b/tests/e2e/helpers/tour.ts index 4fae430..12c9480 100644 --- a/tests/e2e/helpers/tour.ts +++ b/tests/e2e/helpers/tour.ts @@ -23,6 +23,7 @@ export const TourMode = { CallChain: 'CallChain', Federated: 'Federated', Mission: 'Mission', + MissionCallChain: 'MissionCallChain', } as const; export type TourMode = (typeof TourMode)[keyof typeof TourMode]; @@ -55,6 +56,10 @@ const PLAN_STEPS: Record = { // creation (4/5), the out-of-mission elevated scope token (12/13), and the // out-of-scope delete_inbox permission (18/19). Mission: 20, + // Mission + Call Chain: one mission governs a clarified elevated-scope + // grant (creation 4/5, elevated 10/11 with a clarification chat at 7/8) and + // a silent mission-forwarded call chain (Agent → Orchestrator → WhoAmI). + MissionCallChain: 14, }; /** Select a flow in the `#flow-select` picker and wait for the timeline to reset. */ From 31712675550da82f567766bc7be355f75cc6c191 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Sun, 7 Jun 2026 18:03:29 +0000 Subject: [PATCH 22/24] docs(missions): record Phase 13 GuidedTour Mission Call Chain plan --- .../implementation-plan.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md index a67891d..021b4df 100644 --- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md +++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md @@ -832,6 +832,89 @@ PS-asserted (three-party) and federated (four-party) issuer the spec defines. --- +## Phase 13 — GuidedTour combined "Mission Call Chain" flow + +**Goal:** Add a new `TourMode.MissionCallChain` flow to the GuidedTour sample so the +step-by-step raw-HTTP walkthrough demonstrates the same combined use case the +SampleApp `MissionCallChain.razor` page already shows: **one human-approved mission +governs (a) a clarification round on an out-of-mission elevated scope and (b) a +mission-forwarded delegated call chain**, then surfaces the PS-held mission log. The +GuidedTour today has *separate* Mission (`TourMode.Mission`) and Call-Chain +(`TourMode.CallChain`) flows but no combined one; this closes that gap and gives the +tour parity with the SampleApp's `/mission-call-chain` page. Ships with a guided-tour +Playwright spec mirroring `samples/SampleApp/playwright-tests/mission-call-chain.spec.ts`. + +**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` §Clarification Chat +(out-of-mission scope triggers a PS question before the prompt), §Mission Context at +Resources + §Call Chaining (the `AAuth-Mission` header is forwarded hop-to-hop so the +mission governs every hop), §Mission Log (the PS holds the ordered governed trail). +No SDK or spec change — this is an additive **sample** flow over the existing engine. + +### Implementation Decisions + +- **D1 — Additive over the existing engine.** Reuse the modular `TourSession` mode + machinery (one enum value + `IsXxxMode` property + `TotalSteps`/`Plan` cases + a + step switch + `PrepareConsentStateAsync` branch + a Tour.razor picker option + a + sequence-diagram lane set). No existing flow is changed (DC6, no regressions). +- **D2 — Mirror the SampleApp three-pillar shape.** The combined flow demonstrates + three pillars under one mission: (1) mission creation (PROMPT), (2) an elevated + out-of-mission scope that triggers a **clarification round** (§Clarification Chat) + before the user prompt, and (3) a **mission-forwarded call chain** (the + `AAuth-Mission` header carried to the Orchestrator and forwarded to its WhoAmI hop) + that resolves **silently** because both chain scopes are seeded in-scope — then the + mission log. Rendered as raw-HTTP micro-steps (the tour's idiom), not three macro + cards (the SampleApp's idiom). +- **D3 — Clarification is new to the tour engine.** The existing `TourMode.Mission` + flow has no clarification round; the combined flow adds the raw-HTTP clarification + exchange (the PS answers the out-of-mission token request with a clarification + challenge, the agent posts an answer, then the normal 202 + interaction prompt + runs). Scripted via the existing MockPersonServer `requireClarification` / + `clarificationQuestion` mission-script fields (already used by the SampleApp page; + `MissionGovernance.cs` already models `ClarificationQuestion` + `SeedInScope`). +- **D4 — Reuse the MockPersonServer admin scripting verbatim.** `PrepareConsentStateAsync` + for the new mode posts `/admin/reset` + `/admin/mission-script` with + `requireClarification=true`, a `clarificationQuestion`, and **both** chain scopes + seeded in-scope (`{WhoAmIUrl, whoami}` and `{OrchestratorUrl, orchestrate}`), so the + chain hops resolve silently. The mission log is read from `/admin/mission-log/{s256}`. + No new MockPersonServer endpoints — the SampleApp page already exercises all of them. +- **D5 — e2e parity.** Add `samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts` + driving the new flow end-to-end (propose → approve, elevated clarification + approve, + silent forwarded chain, mission-log assertions), reusing the shared e2e helpers + (`fixtures`, `blazor`, `consent`). The Playwright `webServer` array already boots PS + + AP + Orchestrator + WhoAmI for the existing guided-tour mission/call-chain specs. + +### Work items + +- **W1 — Engine: `TourMode.MissionCallChain`.** Add the enum value; `IsMissionCallChainMode`; + `TotalSteps` + `Plan` cases; the `MissionCallChainPlan` step array; the step-dispatch + switch in `RunNextAsync`; the clarification-round raw-HTTP helper; the mission-forwarded + chain step(s); the mission-log fetch/render step; `PrepareConsentStateAsync` branch. +- **W2 — UI: Tour.razor.** Add the picker `