From 8752a9deba52696dac9b316019b2dd6c6447ee2d Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 6 Oct 2026 00:17:12 +0200 Subject: [PATCH 01/50] docs: describe agent authority and implementation follow-ups Co-authored-by: Mecatl --- docs/agent-authority-implementation.md | 444 +++++++++++++++++++++++++ docs/agent-authority.md | 269 +++++++++++++++ 2 files changed, 713 insertions(+) create mode 100644 docs/agent-authority-implementation.md create mode 100644 docs/agent-authority.md diff --git a/docs/agent-authority-implementation.md b/docs/agent-authority-implementation.md new file mode 100644 index 0000000000..f01fab22ea --- /dev/null +++ b/docs/agent-authority-implementation.md @@ -0,0 +1,444 @@ +# Agent authority: implementation evidence and remaining work + +Companion to the [agent-authority design](agent-authority.md). This document holds +branch pins, observed behavior, unresolved mechanisms and implementation work. The +main page states the intended requirements; none of the snapshots below establishes +the complete interactive flow. + +## Evidence baseline: separate branches, not one product + +These are inspected snapshots, excluding uncommitted changes and later commits. +Re-pin before implementation. Individual branch tests do not prove their combination. + +### Current investigation pins + +| Label | Source and pin | Observed scope and limits | +|---|---|---| +| **I** | Mecatl `acc/singleton-identity-mcp-integration` — `2ee96819939367e5f7c57fd48ead21fca828bd0a` | Harness-attested user evidence, logical-agent issuance and exact-call Execute admission. Issuer lacks human freshness; Execute checks it separately. | +| **B** | Mecatl `acc/singleton-mcp-broker-review-remediation-squash` — `06d1ecc2c12d6533e060d90809f4425c8d78294f` | External broker, ToolHive OAuth and guarded encrypted credential recovery; recovery does not preserve the original canonical human subject. | +| **W** | ToolHive `spiffe-v2-14-docs` — `36f85e4b0f6b9e449fd2099ec164bb1204ded5cc` | SPIFFE WIP; distinct logical-agent actor validation and exchanged-token upstream linkage remain gaps. | +| **S** | Mecatl `broker-simple/04-toolhive-backend` — `e3ce6b374e74847c79f5ab9b69b987693d4540d6` | Newer committed-connection mapping with revision/provider checks; not B's storage implementation. | + +**I and B depend on ToolHive v0.50.0, not W.** These pins must not be combined into +one shipped implementation. **P**, below, is the historical richer issuer baseline; +its issuer-admission guarantees must not be attributed to I. + +### Historical evidence and branch relationships + +| Source and pin | What it supplied at that snapshot | What it did not establish | +|---|---|---| +| `origin/main` — `a711451616edf84f50e793c9bcbfb697ef400347` | Caller/owner checks, carried authority, exact placement, dispatch/event logging and configured in-process MCP broker paths | Completed external user/agent admission or exhaustive shell-effect evidence | +| `handover/agent-mcp-authority-restart` — `ed319f45cd972de3cb95031b39e57b8eec425789` | Issuer signing/verification, logical-agent credentials, adapter-neutral acting-as-user exchange contract | Production ToolHive exchange or provider credential lookup | +| Earlier broker squash — `53eab6b753869b33c317da49a67295575807ac61` | TLS/workload-OIDC remote broker, callback lifecycle, process-local Execute receipts, configured encrypted custody recovery | Logical-agent admission, fresh user proof, HA or durable operation outcomes | +| **P**: identity integration — `db5bcd8845f87f05fa0c0efecb2092fd6c5e497c` | Constrained issuer, live exact-call Cedar admission and ToolHive fixture tests | Independently verified human evidence, scheduled grants or durable broker audit completion | +| `workload-identity` — `b6dc951c091197bcb4c3ec444a80308d43dc9e5a` | Historical broker-owned SPIFFE custody/proxy experiment | Selected production transport or an integration dependency | + +At these historical pins, main was an ancestor of the squash, which superseded the +older review-remediation branch. Identity, squash and integration diverged at +`038b5739`; neither squash nor integration contained the other. P adapted the issuer +slice but had no `internal/actingaccess`: its broker-local admission was not simply +the donor exchange contract wired to ToolHive. These ancestry observations are not +an assertion about later I/B/S integration. + +## Observed implementation + +### Baseline and broker protections to preserve + +Pinned main authenticates callers, checks ownership, reattaches exact placement, +applies local permissions/hooks/authority and records effective tool results. +Child [derivation and resume checks][delegation] prevent a broader definition from +restoring authority absent from its parent. These are harness checks, not signed +external grants. Ownerless compatibility exists with enforcement disabled; a shared +static bearer does not establish individual users. + +The historical external broker authenticates workload OIDC over TLS, including +Kubernetes OIDC discovery/JWKS—not TokenReview. Workload/session-owned attachments +and receipts protect lifecycle, not the complete user/actor conjunction. Identical +calls within a receipt lease may join or return a recorded result; changed content +is rejected. Receipts do not survive process replacement. + +Configured encrypted custody can recover a **fresh attachment** using exact +session/incarnation, workload/owner partitions, profile/provider, recovery-reference +and deadline checks. An owner partition is an isolation guard, not human proof. +Credential recovery does not recover a callback, Execute receipt or effect. Sources +at the historical broker pin: [authentication][broker-auth], [custody RPCs][custody], +[protected storage][storage], [Execute receipts][receipts]. + +The alternate workload-identity proxy kept downstream workload credentials behind +the broker. Its [recorded spike results][track-b] include missing-subject, +path-containment and alternate-SVID bypass limitations. Those are historical findings, +not current-broker claims. Neither that experiment nor an SA-first checkpoint is a +substitute for verified logical-agent identity in the full design. + +### Historical P: live admission and profile findings + +P's [identity wrapper][wrapper] turns matching current user authentication into +harness-attested facts. Its workload-authenticated [issuer ingress][ingress] carries +resolved definition, tool authority and exact execution facts. Invocation verifies +workload and agent JWT-SVID, consumes matching presenter/owner/actor/expiry, +attachment/incarnation, call ID, argument digest and occurrence, then applies +registered-target checks and Cedar. Changing PR 42 to PR 43 cannot reuse admission; +a valid SVID cannot skip policy. See [one-use admission][admission] and +[Cedar gate][cedar]. + +P's [real-ToolHive fixtures][fixtures] include an admitted write and denied +credential/backend counters. Their presence is not a fresh test run or Kind proof. +Its broker-owned confidential OAuth client uses authorization code and refresh, +not an integrated user-subject/logical-agent-actor exchange. + +The [integration ADR][integration-adr] records P's trusted-attester relationship. +Earlier design handover sections 10/13 instead requested a distinct fresh broker/AS +user assertion from which the broker derives owner. The main design now explicitly +trusts harness-attested authentication and reuses ToolHive's existing subject token; +it does not select that older extra assertion layer. Audience restriction alone +never supplied independence. The remaining authorized account-association mechanism +is still unselected, and local permission provenance does not prove every +subject-authority/consent ceiling of the [acting-as-user conjunction][exchange-adr]. + +Two source-review findings at **P**, not fixes or live reproductions: + +- Ordinary Kubernetes SA tokens are classified as user grants, while issuance + requires a client-credentials grant. That blocks the ordinary-SA + [stage B](agent-authority.md#b--sa-authenticated-logical-agent-credential) flow at + this pin, between [GrantTypeFromClaims](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/engine/session/principal.go) + and [IssueAttested](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/host.go). +- The [bundle-format gap](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/bundle.go) + prevents claiming standard SPIFFE bundle interoperability merely because the + local issuer/verifier agree. The reported fields are `sequence`/`refresh_hint` + rather than SPIFFE-prefixed fields, with a missing JWT-SVID JWK usage marker. + +Recheck these defects against I before claiming either persistence or resolution. +Stage A does not require the logical-agent issuer; C requires new workload support +as well as qualified issuer compatibility. + +P custody source navigation (not I's current issuer contract): +[identity client](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/client.go), +[trust configuration](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/cmd/mecak8s/mcp_identity.go), +[issuer ingress][ingress], [gRPC presentation/verification][admission], +[ToolHive target](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbroker/toolhive_process.go) +and [OAuth flow](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbroker/auth.go). +The donor [issuer substrate][issuer-adr] and [logical-agent projection][agent-adr] +record the earlier identity slices, not completion of the intended exchange. + +### Current I: freshness is checked separately from issuance + +At I, the API creates request-local `AuthenticationEvidence`: owner issuer/subject, +`VerifiedAt` (API bearer-verification time, **not human login time**), `ExpiresAt` +(bearer expiry) and random `RequestID`. Verification requires a non-future age of +at most five minutes, expiry after now and a nonempty request ID; broker Execute +repeats these checks. The ID is **not a replay nonce**. The login bearer is neither +persisted nor forwarded. Workload-authenticated TLS/gRPC protects this trusted +harness attestation; it is not independent human proof against a compromised harness. +Sources: [API evidence creation][current-authn], [evidence contract][current-evidence]. + +Dispatch derives effective execution facts after local permissions and hooks. +I's issuer receives owner, resolved definition, tools and exact-instance hash, but +**not human freshness**: it cannot cap the SVID lifetime at human-token expiry. +Execute separately checks fresh evidence, owner, attachment, workload, SVID, exact +call and Cedar. Its [detached execution context][current-execute] drops incoming +evidence context; the target uses the attachment credential. There is **no integrated +user-subject/logical-agent-actor exchange**. HTTPS issuance and TLS-protected gRPC +invocation are distinct harness-to-broker paths; issuer and admission share +broker-owned admission state. + +Exact receipts and JTI tracking remain process-local. Restart must fail closed, +not imply durable single-use protection. Retrieving a started receipt is not a +new dispatch. The main's [interactive flow](agent-authority.md#4-interactive-execution-flow) +is a normative design, not an execution trace of I or W. + +### B, W and S: credential continuity is not subject continuity + +B already captures a verified `tsid` and preserves encrypted custody across +recovery. Storage encrypts under session/provider/field; it neither maps users nor +rewrites JWTs. B's recovered token uses `mecatl-recovered:v1:ownerPartition`, not the +original canonical ToolHive user subject. Recovering the right credentials therefore +does not establish the required user-and-agent token meaning. + +W's exchange omits the upstream link during session construction; the MCP loader +returns on a missing JWT `tsid` before consulting storage. A storage decorator alone +cannot fix a token that never reaches it. W also restricts the SPIFFE actor to the +presenter: distinct logical-agent support needs validation and an authorized +association, not simply removal of that check. S's committed-connection mapping +with revision/provider checks is separate-branch work, not B's behavior. + +The [credential-continuity brief](toolhive-credential-continuity-followup.md) owns +the detailed ToolHive task, code navigation, minimal issuance/storage handoff and +test matrix. It supersedes the [SPIFFE brief](toolhive-spiffe-followup.md)'s earlier +new-user-evidence exploration for subject reuse and linkage; the SPIFFE brief +still covers separate logical-agent actor support. Do not duplicate those adapter +details or introduce a new opaque user-evidence platform here. + +## Current gaps and foundation work + +Complete the identity/broker foundation first. These are requirements to implement +or decisions to select, not claims already satisfied by the snapshots. + +### Session-scoped definitions and uniform identity + +PR [#2003](https://github.com/stacklok/mecatl/pull/2003) introduces +`CreateSessionRequest.agent_definition_name`, allowing a main session to use a +named `AgentDef`. PR [#2055](https://github.com/stacklok/mecatl/pull/2055) adds the +mecatui command and picker. The architecture review inspected backend commit +`595be9fd0e139e856a3a18632d25cf7f9985815e` and TUI commit +`cda6eee7374c495f71eda5d9cec1875e5f5dddd6`; these are separate from the +identity-integration snapshot. + +The backend shares definition resolution while retaining separate root and child +engine construction. Preserve that distinction: a definition-bound root must +retain main-session permission asks, user-context handling and guardrails. Reusing +a definition must not make the root behave like a delegated child. + +The inspected implementation persists the selected definition name and authority +ceiling, but does not establish a complete resolved credential identity. Unsupported +restart reconstruction fails closed. It also deliberately excludes definition-bound +roots from the configured broker attachment path; enabling that path requires an +explicit integration contract. + +### Required identity integration + +**Carry one resolved identity through execution.** Trusted definition resolution +should produce a value containing the source tier and exact name. Bind that identity +to the session and pass it into execution evidence rather than reconstructing it +from role strings, authority provenance or the session’s position in the delegation +tree. Use `system/main` for the default root. + +**Keep configuration, identity and authority separate.** `AgentDef` supplies +configuration; the resolved identity identifies the agent; `Session.Authority` +supplies the effective permissions and ceiling. Do not copy permissions into the +identity value or use the ceiling in place of the invocation’s effective authority. +Identity and authority should be bound through an aggregate-controlled operation +that rejects inconsistent state and rebinding. + +**Use delegation state for root-versus-child checks.** Audit existing role and +`Session.Kind` checks before changing them. Where behavior depends only on +delegation, use the authoritative delegation relationship/depth, with zero +representing the root. Do not introduce a second depth counter. Fork and clear +ancestry must not count as delegation. Retain execution-specific distinctions where +team coordination, parallel execution, scheduling or debugging actually require them. + +**Preserve resolved identity across lifecycle operations.** A persisted definition +name alone does not establish which source tier supplied it. Fork, clear and +supported restoration must preserve the resolved identity. Select a reconstruction +policy for definition changes: restoring a pinned definition and accepting updated +configuration under restrictions are different contracts. Until supported, fail +closed rather than silently select another identity or the default engine. + +**Integrate named roots with the broker without widening authority.** Define which +broker tools a selected definition may expose, intersect discovery with its bound +ceiling, and support the required attachment and continuation paths. Do not bypass +the PR’s broker exclusion merely by attaching the deployment’s default catalog. + +**Keep credential projection at the adapter boundary.** The engine carries the +resolved identity and checked execution facts. The issuer adapter converts these +into its credential representation; it must not assume that every root is +`system/main` or parse diagnostic role strings to discover an agent’s identity. + +Verify that the same definition used as a main agent and delegated specialist +produces the same logical subject while retaining distinct authority and call +bindings. Cover default `system/main`, source-tier collisions, fork/clear behavior, +definition changes on restore, child narrowing, broker discovery beyond the +ceiling, and preservation of root permission asks and guardrails. Unsupported +execution paths must remain rejected rather than gaining credential issuance +implicitly. The concrete core representation and migration remain to be selected; +these requirements do not mandate constructing every root through an `AgentDef` +or replacing the existing root/child engine builders. + +### User evidence, exchange and account association + +- Qualify I's freshness, replay and lifetime rules and workload/invocation binding. + Prove rejection of stale, substituted or mismatched evidence. Decide how human + evidence constrains exchange lifetime without assuming I's issuer receives it. +- Select the **authorized association mechanism** linking Mecatl `(Issuer, Subject)`, + ToolHive canonical user and provider account, including recovery. Equal subjects, + callback state and PKCE do not prove identity equivalence. Recovery must retain + or authoritatively resolve canonical subject identity, not just credentials. +- Reuse the existing ToolHive auth-code access token as `subject_token`, never owner, + `ExternalBinding`, an attachment handle or `tsid`. Permit exchange in the broker + registration, which currently uses authorization code + refresh, + `openid`/`offline_access` and `client_secret_basic`. Select eligible resources, + scopes and subject/actor/policy lifetime bounds, and the broker's SVID client-auth + integration independently of harness-to-broker authentication. +- Select a distinct actor-validation/exchange profile and authorized presenter + association. Broker-admission audience is not automatically AS actor audience: + choose a separate credential or explicitly compatible profile, not casual audience + widening or a workload token relabelled as agent identity. +- Resolve an authorized local credential link from validated subject and broker + connection state, then pass it to ToolHive session construction. Verified user, + tenant, provider, connection/revision and target must agree; never copy an arbitrary + external `tsid`. Follow the continuity brief rather than inventing a new issuance + API, signing tier or consent-record platform. +- Define the scope, lifetime and withdrawal/revalidation rules for both user/account + and actor/presenter associations, including effects on new exchanges and + already-issued tokens. A valid token or connected provider account must not by + itself preserve a withdrawn association. +- Specify revocation behavior and residual lifetime of self-contained bearers, + including any live execution checks/introspection. Fresh Mecatl authentication + and broker exact-operation admission remain independent of refresh and exchange. + +These decisions are foundation work, not deferred to mandates or scheduled consent. + +### Integration, deployment and A–D qualification + +Reconcile identity integration and broker squash without silently merging their +claims. Resolve/retest credential-profile defects and qualify allow/deny, +target/argument substitution and narrowed-child authority through the real remote +path. A malicious reviewer cannot gain merge permission by spawning a broader +specialist; a separately authorized top-level deployer can act only within all its +ceilings. Coverage is registered protected MCP, not every agent action. Issuer +enablement is tracked by #478. + +| Design stage | Delivery position and outstanding work | +|---|---| +| A: workload-only SA | Selected first broker implementation/checkpoint. Narrower than logical-agent admission; never silently substitute SA identity for an actor credential. | +| B: SA + logical agent | Substantially implemented on the identity-integration branch, but needs profile correction/revalidation and deployment qualification. Historical P findings are not proof of current I behavior. | +| C: SPIRE JWT-SVID | Planned, not wired. Implement Workload API acquisition, workload allowlists, credential renewal and platform trust-bundle refresh separately from broker logical-agent keys. | +| D: SPIRE X.509-SVID | Possible later move, not a promised selectable alternative. Design TLS termination, identity propagation, certificate/bundle rotation and connection renewal together, with no silent bearer fallback. | + +Whether earlier authentication methods remain supported is unselected. C retains +B's bearer request pattern; stolen bootstrap JWT-SVIDs remain replayable until +expiry even if a subsequently issued access token uses DPoP. D does not itself +sender-bind an OAuth token; the DPoP task below remains separate. + +Select the ToolHive release/exchange capabilities and direct-client/proxy topology; +prove intended key/credential isolation and absence of alternate bypasses. The +direct client holds a broker-audience workload token; only a selected proxy would +hide it from the harness. The initial combined broker is one replica with `Recreate`, +process-local active state and no HA claim. A later narrow Unix-socket client proxy +is an open option, not a second policy engine or generic HTTP proxy. Splitting issuer +and invocation into separate deployments requires its own authentication and key +distribution design. Signing algorithm, key-storage backend and any further +structured resource constraints belong in the implementation specification. + +### Durable broker evidence and replacement + +Make identity provenance, exact operation, policy decision, dispatch and observed +completion/unknown outcome inspectable independently of the harness log and durable +across restart. Define linkage to harness session, run and tool call; existing IDs +alone are not assumed sufficient, and no new correlation identifier is selected. +Credential recovery restores access, not completion evidence; restart interrupts +active work. + +```text +known not dispatched -> eligible for a carefully authorized retry +known completed -> return the durable outcome +possibly dispatched -> unknown/ambiguous; never retry automatically +``` + +Qualify this distinction across replacement, including a provider effect completed +before its response was recorded. Reconciliation may use provider idempotency/status +evidence, not a general exactly-once claim. Durable audit retention can precede +replication. Replicated continuity separately needs durable callback claims, +refresh/disconnect fences, routing generations and operation state; adding Redis +or replicas is not that contract and is not required for a useful singleton checkpoint. + +## Security follow-on: workload-level DPoP + +DPoP is selected **after the foundation**, not implemented by the identity credential. +Implement sender binding for the worker's broker-facing OAuth credential while +retaining logical-agent assertion and exact-call checks. Define issuance, key +custody, rotation/restart, nonce handling and replay-state ownership. Prove rejection +of missing, wrong-key, stale and replayed proofs with no bearer fallback. + +[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) can use one key per worker; subagents +need not be workloads. Verify the actor's association with the authenticated +presenter separately. DPoP covers HTTP method and URI, not MCP arguments/body, and +does not change the logical-agent JWT-SVID profile. Its initial claim is stolen +access-token replay protection, not compromised-harness or per-agent isolation. + +Keep keys/tokens/proofs out of model tools, results, logs and environment variables; +expose no general-purpose signer. A process-lifetime key is a minimal custody option, +with fresh token issuance after restart and no cached-token reuse under a new key. +Custody/lifecycle still need an implementation plan. A stolen projected bootstrap +bearer may let an attacker obtain a new token bound to their own key. + +**Stronger runtime isolation is deferred.** A shell able to reach worker memory, +files or signer can defeat this protection. A keystore/HSM can prevent extraction, +but a compromised authorized client may still request signatures. Design an enforced +boundary between hostile tool execution and protected broker client; an accessible +sidecar signing socket is insufficient. Independently proving which agent made a +call is a further requirement, not provided by DPoP, a separate signer or separate +IDs/keys held by one compromised process. + +## Separate future consent: schedules + +The [generic scheduler][scheduler] restores owner and placement, not fresh user +authentication or permission to spend a provider account unattended. The live +identity wrapper does not supply the scheduled journey. Issue #373 covers a separate, +still undesigned authorization, provenance, freshness and revocation contract. + +Permission must bind to schedule revision, operation, cadence and limits. A +model-created Schedule call attributed to Alice does not prove she approved those +facts. Changes must not widen the authorized job. Before each manual or timer fire, +revalidate the applicable authority's expiry and revocation, schedule revision, +current policy, agent/integration revisions and operation eligibility before +obtaining fresh execution credentials. Neither path may fall back to a service +identity. No signed schedule grant, reference store or approval mechanism is selected. +Provider offline credentials remain in +broker custody, not as user proof, and saved login bearers are not a solution. + +The follow-up handover sketched authenticate owner → resolve agent/operations → +obtain unattended consent → establish provider offline access → store bounded +schedule authority/reference. That is a discussion sketch, not a contract. Offline +consent does not require a second broker OAuth self-client: the confidential +ToolHive client already exists, while unattended permission is an authorization +question. This work does not block the interactive foundation. + +## Exploratory extension: task mandates + +No task-mandate model is selected. A logical-agent credential establishes actor and +tool ceiling; a mandate could express authority for this task, resources and +conditions; the broker would still enforce its own current policy on the exact call. +For example, a mandate could limit a reviewer to PR 42 even if standing policy +permits other PRs. Neither identity nor the enforcement point decides who may grant +that permission or proves Alice's consent. + +One candidate is a signed token carrying Cedar policy in RAR-shaped +`authorization_details`, not merely operation data for broker-local policies. The +broker would verify grant/actor binding and require both carried and local policy +to allow the operation, with neither expanding the other. The credential stays +outside model content. This is an option, not a logical-agent profile change. + +Before selecting a model, decide: + +- Issuers, authorization/consent for issuance and user/actor/presenter binding. +- Operation/resource semantics and mapping from registered calls, including Cedar + schema and trusted entity/context inputs. +- Task versus one-operation scope, separately from reuse/single-use semantics; + narrow policy alone does not make a grant single-use. +- Expiry, revocation and delegation narrowing, including issuance ceilings. + +Do not add speculative token fields or interfaces now. Existing acting-as-user, +registered-target, tool-ceiling and exact-call checks remain. Mandates are not a +prerequisite for useful attribution/enforcement, and selecting their format would +not close consent or durable-audit gaps. + +## Delivery references + +Issue numbers are planning references, not runtime evidence. For shipped behavior, +use [architecture.md](architecture.md); for alternatives, [agent-identity-model.md](agent-identity-model.md) +and [agent-identity-outbound.md](agent-identity-outbound.md). Delivery proofs belong +in acceptance plans, decisions in ADRs and shipped/deferred status in +[PRODUCTION-READINESS.md](design/PRODUCTION-READINESS.md). Historical handovers are +conversation snapshots, not the evolving contract. + +## Pinned references + +[current-authn]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/internal/adapter/server/authn.go#L463-L495 +[current-evidence]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/engine/port/authentication_evidence.go#L12-L40 +[current-execute]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/internal/adapter/mcpbrokergrpc/server_execute.go#L216-L229 +[issuer-adr]: https://github.com/stacklok/mecatl/blob/ed319f45cd972de3cb95031b39e57b8eec425789/docs/adr/0303-identity-issuer-substrate.md +[agent-adr]: https://github.com/stacklok/mecatl/blob/ed319f45cd972de3cb95031b39e57b8eec425789/docs/adr/0304-logical-agent-identity-projection.md +[exchange-adr]: https://github.com/stacklok/mecatl/blob/ed319f45cd972de3cb95031b39e57b8eec425789/docs/adr/0305-acting-as-user-exchange.md +[delegation]: https://github.com/stacklok/mecatl/blob/a711451616edf84f50e793c9bcbfb697ef400347/engine/agent/authority_delegation.go +[scheduler]: https://github.com/stacklok/mecatl/blob/a711451616edf84f50e793c9bcbfb697ef400347/internal/app/scheduler_fire.go +[broker-auth]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbrokerserver/authentication.go +[custody]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbrokergrpc/continuity_rpc.go +[storage]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbroker/toolhive_protected_storage.go +[receipts]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbrokergrpc/server_execute.go +[track-b]: https://github.com/stacklok/mecatl/tree/b6dc951c091197bcb4c3ec444a80308d43dc9e5a/docs +[integration-adr]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/docs/adr/0306-singleton-identity-mcp-admission.md +[wrapper]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/app/mcp_identity_attester.go +[ingress]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbrokerserver/server.go +[admission]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbrokergrpc/broker.go +[cedar]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbrokergrpc/admission.go +[fixtures]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/server/mcp_broker_live_admission_toolhive_test.go diff --git a/docs/agent-authority.md b/docs/agent-authority.md new file mode 100644 index 0000000000..a0ac428e54 --- /dev/null +++ b/docs/agent-authority.md @@ -0,0 +1,269 @@ +# Agent authority: user journey and trust boundaries + +This page describes the **intended design**, not a delivery claim. The [implementation companion](agent-authority-implementation.md) records implementation evidence, unresolved choices and remaining work. + +## 1. User journey + +Alice asks `code-reviewer` to read PR 42 in `acme/payments`. Mecatl identifies the acting agent; an external broker verifies the presented evidence and applies its own policy to that exact operation before selecting credentials or dispatching. Permission to read PR 42 must not authorize merging PR 43. + +The operator can inspect which user and agent were associated with the request, why the broker allowed or denied it, and its observed outcome—including an unknown outcome—without reconstructing the decision solely from Mecatl's logs. The record must survive broker restart and show which checks the broker performed itself and which identity claims it accepted from Mecatl. + +**Mecatl identifies the user and agent; the broker applies its own policy to their request.** For Alice’s request, the broker relies on authenticated Mecatl to report that Alice’s login token was validated and that `code-reviewer` is acting for her. The broker checks the credentials and request against its own policy before allowing the operation. It does not revalidate Alice’s login token or treat her authenticated identity as proof that she approved this particular action. + +## 2. Identity and trust model + +These terms build on the [domain model](architecture/domain-model.md), not a second set of core entities. + +### User identity and persisted ownership + +Alice's identity is the `(Issuer, Subject)` pair represented by `session.Principal`, not her display name or email. Mecatl validates her login access token's signature, trusted issuer, intended audience and expiry. It records the principal as the session owner at creation; children and forks inherit it. Ownership enforcement prevents another caller from taking over her session. + +When Alice requests an operation, Mecatl sends the broker a record of her current authentication, tied to that invocation. The broker checks that the record came from an authenticated Mecatl workload, is recent enough, and identifies the same user—by issuer and subject—as the session’s saved owner. A session owned by Alice cannot be used with Bob’s authentication record. + +The saved owner tells the broker whose session this is; it does not show that Alice is still authenticated or that the operation is allowed. Those require separate checks. + +Here, *owner* and *subject* refer to the same user in different contexts: Alice owns the session and is the subject of the user token. The logical-agent credential has its own subject, identifying `code-reviewer` rather than Alice. + +### Logical agent, instance and workload + +- **Logical agent / actor:** the agent performing the work. A named agent such as `code-reviewer` gets its identity from its resolved `AgentDef`, whether it runs as the main session or as a delegated specialist. A main session without a selected definition uses `system/main`. Neither identity represents Alice or the `mecak8s` worker. +- **Execution:** a particular run of that agent in a session. Two sessions can run the same agent with different permissions. Delegation depth distinguishes the main execution at depth zero from agents working beneath it; it does not change their logical identity. Forking or clearing a session does not, by itself, make it a delegated child. +- **Mecatl workload:** the `mecak8s` worker that authenticates to the broker. Agents running inside the same worker share that workload identity. + +Effective authority and environment are context, not additional identities. Child authority is tighten-only: a definition containing write tools cannot restore writes denied to its parent. `tool.Environment` supplies a workspace and optional bound command runner, durably identified by `EnvironmentRef{Kind, ID, Revision}`. Running in Alice’s workspace does not, by itself, authorize the agent to use her GitHub account. + +Authentication happens twice: `mecak8s` authenticates to the broker, then the broker authenticates to ToolHive’s token endpoint. Neither identifies `code-reviewer`—its identity comes from a separate agent credential. ToolHive checks that credential and whether the broker is allowed to present it. Each logical agent does not need its own OAuth client registration. + +### Logical-agent naming and constrained issuance + +An agent keeps the same identity when its prompt or allowed tools change. Its identity depends on where its definition comes from (such as the project or user configuration) and its exact name. For a project-defined `code-reviewer`, the credential's subject looks like this (`` represents the full computed hash): + +```text +spiffe://agents.example.com/mecatl/agent-definition/v1/project/code-reviewer-- +``` + +The trust domain is an identity namespace, not a broker address. Tiers—`project`, `user`, `managed`, `driver`, `system`—distinguish sources, not privilege levels. The slug is readable but not unique: `code reviewer` and `code-reviewer` can share it. The deterministic digest of tier and exact name distinguishes them. Editing tools or prompt leaves the identity unchanged; renaming or changing tier does not. + +Agent identity, delegation depth and permissions describe different things. If Alice starts `code-reviewer` as her main session, it has the same logical identity as `code-reviewer` invoked as a specialist from the same definition. The specialist’s permissions are additionally restricted by its parent. Neither execution may borrow permissions from the other. + +The default main agent uses `system/main` without requiring an agent-definition file. Its credential subject has this shape: + +```text +spiffe://agents.example.com/mecatl/agent-definition/v1/system/main-- +``` + +Here the digest is computed from `system` and `main`, not the user or session ID. Alice’s and Bob’s default main sessions therefore share this logical-agent identity, but not their user identity, authority, broker attachments or provider credentials. If Alice instead starts a main session using the project-defined `code-reviewer`, its subject is the `project/code-reviewer` identity shown above—not `system/main`. Selecting a named definition changes which agent runs, not the rule that every operation must satisfy the session’s authority and broker policy. + +Two runs of `code-reviewer` can have different permissions. The broker checks the tools allowed by the credential attached to this call; it must not add tools allowed in another run. That tool list does not describe which files the agent can access, whether it may edit the parent workspace, or the chain of delegations that led to the call. + +The combined broker has two responsibilities: its issuer signs only permitted agent identities and tool ceilings requested by an authenticated harness; its invocation component verifies presented credentials and enforces operation policy. The harness also verifies the returned credential's signature, expiry, audience, identity and tool list against its request. Public keys permit verification, not signing. These credentials use SPIFFE names and JWT-SVIDs without requiring SPIRE. + +### What signatures and identity do not prove + +A signature proves who issued a credential and that its contents have not changed. +It does not independently prove which agent inside Mecatl made the call. A compromised shared harness can attach `deployer`'s credential to `code-reviewer`'s call, within the credentials it can obtain or use. Keeping the signing key outside Mecatl limits forgery, not that substitution. Separate instance IDs or keys in the same compromised process provide no process isolation; a different SPIFFE name does not change this boundary. + +**Identity never grants authority or proves consent.** The broker applies applicable authorization to the verified user, workload, logical agent and actual operation, not Mecatl's permission verdict. A separate signer or broker-audience claim does not turn trusted harness attestation into independent human proof. This design builds on authorization checks, not a new approval UI, opaque user-evidence platform, standing-grant store or mandate system. + +## 3. Identity progression + +At every stage the harness (`mecak8s`) is a client of one +logical broker deployment; the model does not authenticate or request credentials. +A–D concern **only Mecatl → broker**, not the separate broker OAuth client → ToolHive relationship in the interactive flow. + +### A — workload-only Kubernetes ServiceAccount authentication + +Kubernetes projects a short-lived ServiceAccount (SA) bearer token with the broker as audience. Over trusted TLS, the broker validates the configured Kubernetes issuer, audience and SA subject allowlist. It owns provider credentials and invokes ToolHive itself. + +```mermaid +flowchart LR + P["Kubernetes"] -->|Projected SA token| H["Harness: mecak8s"] + subgraph B["Combined broker"] + R["MCP authorization and execution"] + T["Embedded ToolHive"] + R -->|Broker-owned invocation| T + end + H -->|TLS + SA bearer + call| R +``` + +`code-reviewer` and `deployer` inside the same harness present the same SA identity. This restricts which harness may connect, but cannot distinguish those agents for policy. A self-declared agent name is not a substitute for B's credential. + +### B — SA-authenticated logical-agent credential + +B adds a harness-attested, short-lived agent credential. The broker authenticates the SA, constrains which agent identities and tools it may request, and returns a signed credential. On invocation it checks both workload and agent credentials, exact-call admission and its own policy before using provider credentials. + +```mermaid +flowchart LR + P["Kubernetes"] -->|Projected SA token| H["Harness: mecak8s"] + subgraph B["Combined broker"] + I["Logical-agent issuer"] + R["MCP authorization and execution"] + T["Embedded ToolHive"] + R -->|Broker-owned invocation| T + end + H -->|TLS + SA bearer + agent request| I + I -->|Broker-issued agent credential| H + H -->|TLS + SA bearer + agent credential + call| R +``` + +Operator configuration supplies accepted Kubernetes issuers, audiences and SAs, issuance limits and trusted public signing keys. Verification endpoints, CA trust and server names are never selected by a token. These are responsibilities within one broker deployment, not separate services or trust domains. Bearer credentials remain presentable by whoever possesses them; the other admission checks still apply. + +### C — SPIRE JWT-SVID workload authentication + +C replaces B's SA token with a platform JWT-SVID for the broker's audience, obtained through the SPIFFE Workload API and presented on issuance and invocation requests. +The broker verifies signature, audience, expiry and allowed workload SPIFFE ID. + +```mermaid +flowchart LR + P["Platform SPIRE"] -->|Platform JWT-SVID via Workload API| H["Harness: mecak8s"] + subgraph B["Combined broker"] + I["Logical-agent issuer"] + R["MCP authorization and execution"] + T["Embedded ToolHive"] + R -->|Broker-owned invocation| T + end + H -->|TLS + platform JWT-SVID + agent request| I + I -->|Broker-issued agent credential| H + H -->|TLS + platform JWT-SVID + agent credential + call| R +``` + +Agent issuance and exact-call checks remain as in B. Platform identity does not replace the broker's logical-agent signing keys or create federation. Platform credential renewal and trust-bundle refresh are distinct from logical-agent key rotation. This is still bearer authentication: a stolen JWT-SVID is replayable until expiry. + +### D — SPIRE X.509-SVID mutual TLS + +D replaces bearer workload authentication with proof of a workload private key in the TLS handshake. The harness obtains its certificate and key through the Workload API; the broker verifies platform trust and permits only configured workload IDs. + +```mermaid +flowchart LR + P["Platform SPIRE"] -->|X.509-SVID and key via Workload API| H["Harness: mecak8s"] + subgraph B["Combined broker"] + I["Logical-agent issuer"] + R["MCP authorization and execution"] + T["Embedded ToolHive"] + R -->|Broker-owned invocation| T + end + H -->|Workload mTLS + agent request| I + I -->|Broker-issued agent credential| H + H -->|Workload mTLS + agent credential + call| R +``` + +Both broker endpoints must verify mTLS identity. A TLS-terminating ingress requires explicitly trusted identity propagation; an ordinary forwarded header is not proof. +Certificate rotation, bundle refresh and connection renewal must preserve that check, with no silent bearer fallback. D authenticates the harness, not its agents, and does not automatically bind an OAuth access token to the certificate. Neither C nor D proves user consent or isolates agents sharing a harness. + +## 4. Interactive execution flow + +The flow separates provider setup from admission of each exact operation. It uses an OAuth-protected upstream requiring its native provider credential, rather than a backend that directly accepts the delegated token. Operation and scope names are illustrative, not wire names. + +The exchange reuses the broker's existing ToolHive authorization-code access token as RFC 8693 `subject_token`, the logical-agent credential as `actor_token`, and **separate broker OAuth client authentication**. The subject token is the ToolHive access token the broker already obtained through the user’s browser authorization flow. ToolHive validates it during exchange to establish the user identity. This reuses the broker’s existing token storage and refresh mechanism rather than introducing another credential to represent the user. + +The broker must establish which ToolHive user and connected provider account Alice is allowed to use. Her Mecatl login and GitHub account can have different identifiers; matching names or identifiers alone do not establish that they belong together. The browser authorization flow protects the sign-in transaction, but does not by itself prove that the account connected in the browser belongs to the user making the Mecatl request. + +Each interactive operation still requires Alice to be authenticated to Mecatl, the broker to check Mecatl’s current authentication report for that request, and policy to permit the operation. Being able to refresh a stored token keeps the connection usable; it does not authorize the next action. + +```mermaid +sequenceDiagram + actor U as Alice / human client + participant H as Mecatl trusted API and dispatch + box Combined broker + participant I as Logical-agent issuer + participant B as Identity verification and call admission + participant A as Embedded ToolHive AS + participant V as Protected vMCP / custody + end + participant P as Upstream provider + + Note over U,P: Establish user identity and provider connection + U->>H: Current login access token + H->>H: Verify authentication and persist owner, not bearer + H->>B: Workload authentication + fresh bound user evidence + B->>B: Verify trusted attester, freshness and owner match + opt Provider connection required + U->>A: Browser enrollment, state/PKCE and registered callback + A->>P: Authorize provider connection and redeem code + P-->>V: Provider access/refresh credentials + A-->>B: ToolHive auth-code access/refresh tokens + end + B->>B: Require authorized Mecatl / canonical user / connection association + + Note over U,P: Authorize and execute one exact operation + U->>H: Read PR 42 as code-reviewer, with current authentication + H->>H: Resolve agent and narrowed authority, then apply permissions/hooks + H->>I: Workload authentication + owner and effective execution facts + I-->>H: Constrained short-lived logical-agent credential + H->>H: Verify credential against requested execution facts + H->>B: Workload + agent credential + exact call + fresh user evidence + B->>B: Verify identities, bindings and exact call, then apply Cedar + Note over B,V: Denial stops before invocation credential selection or dispatch + B->>B: Obtain current ToolHive subject token from guarded custody/refresh + B->>A: subject_token + actor_token + broker client auth + eligible resource/scopes + A->>A: Validate subject, distinct actor/presenter and authorized association + A->>A: Bound access/lifetime and resolve authorized local credential link + A-->>B: Delegated token: sub=user, act.sub=agent, client_id=broker client + B->>V: Delegated token + independently admitted exact operation + V->>V: Validate token and operation policy, then load authorized connection + V->>P: Exact operation with native provider access credential + P-->>V: Operation result + V-->>B: Result + B-->>H: Sanitized result + H-->>U: Answer without credentials in model history +``` + +Before checking the operation, the broker resolves the session’s attachment and verifies that it belongs to the authenticated user and Mecatl workload. The attachment binding also identifies the broker incarnation, so a reference from an earlier broker instance cannot silently select new state. Exact-call admission is tied to this attachment. + +Before allowing a call, the broker checks that the verified user matches the session owner, validates the workload and logical-agent credentials, and confirms that the workload is allowed to present that agent’s credential. It checks the requested tool against the credential’s allowed tools, then evaluates the registered target, resource, scopes or authorization details, and exact arguments. + +Admission applies only to that specific call. It is bound to the broker attachment and incarnation, call ID, argument digest, invocation occurrence and expiry. The broker checks those bindings, validates the registered target and applies Cedar policy before dispatch. + +If the call is paused while the user connects a provider account, the broker repeats admission checks after the browser callback. Permission to connect the account does not replace permission to perform the operation. + +During token exchange, ToolHive authenticates the broker as an OAuth client. It separately checks the logical-agent credential’s issuer, signature, audience and expiry, and confirms that the broker is allowed to present it. A credential issued for admission at the broker is not automatically valid at ToolHive’s authorization server: its audience must explicitly cover that use. + +ToolHive issues a delegated token identifying the user in `sub`, the logical agent in `act.sub` and the broker’s OAuth client in `client_id`. The token’s audience is the protected MCP endpoint that will receive it, not necessarily the upstream provider, such as GitHub. + +These claims identify the parties and intended recipient; they do not specify or validate the exact tool arguments. The broker must still enforce its separate admission checks for the specific call. + +The protected resource validates issuer, audience, expiry and bounded access, +applies operation policy and resolves only the authorized user/tenant/target's provider connection. `tsid` is a ToolHive-local credential link, outside SPIFFE's identity responsibility. It must come from validated subject and authoritative connection state, never an arbitrary external claim or caller-selected storage key. + +## 5. Credential custody and safety boundaries + +- **Human login bearers terminate at Mecatl's edge.** They are transient, never saved for downstream or scheduled use. Fresh harness-attested evidence is not the bearer, a stored owner, an attachment or an authorization grant. +- **Agent credentials enter trusted Mecatl memory, not model content.** Client code verifies and attaches them without exposing them in arguments, results, conversation history, logs or environment variables. Model-hidden is not process-safe and does not guarantee secure memory erasure. +- **Signing authority stays with the broker issuer.** Mecatl receives signed credentials and public verification keys, never the private signing key. Issuance and invocation may be modules in one process: separate boxes do not protect against broker, host or key-storage compromise. Key rotation and verifier cache expiry bound trust in old keys. +- **Provider credentials stay in broker/ToolHive custody.** ToolHive retains, + refreshes and injects native access credentials for the authorized connection; + the broker holds its own ToolHive access/refresh tokens. Results contain neither + secrets nor reusable signed requests. This does not relocate all harness + credentials, including LLM-provider credentials. The broker runs no model-driven + agent loop or general-purpose shell. +- **Refresh is not fresh human authentication.** A provider connection can outlive + a login token without granting the next operation. Credential recovery is not + canonical-user identity continuity. After restart, revalidate connection state, + reacquire transient credentials and require fresh evidence; cached tokens and + persisted ownership are not authority. Self-contained bearers retain a residual + lifetime unless execution performs live checks or introspection. +- **Targets and credentials are server-resolved.** Model-chosen URLs, headers, + issuers, audiences, scopes or credential selectors cannot redirect authority. + Missing, malformed, stale, revoked or indeterminate evidence or authorization + fails closed—never to an ownerless, service, broader-agent or alternate-user + credential. Denied calls select no invocation credential and dispatch no backend + operation; enrollment/callback traffic is accounted separately. +- **Children only narrow authority.** A malicious PR comment cannot obtain merge + authority by requesting `deployer` beneath a read-only reviewer. A separately + authorized top-level deployer may merge if every ceiling permits. A worktree or + signature over a model-supplied name is not enforcement. Execution isolation must + prevent alternate credential/network bypasses; scrubbed environments and local + workspaces are not OS confinement, and shell logs are not exhaustive effect records. + +Unattended execution requires independent authority; restoring a schedule's owner +or keeping provider offline credentials is insufficient. Interactive authentication +does not silently authorize future timer or manual fires. + +A possibly dispatched operation with no recorded completion has an **unknown +outcome**, not proof that nothing happened. Never retry it automatically. Credential +recovery or a signed pre-dispatch record cannot establish whether a merge reached +the provider. Provider idempotency/status evidence can support reconciliation, but +arbitrary effects have no general exactly-once guarantee. Broker records must retain +identity provenance, exact operation, decision, dispatch and observed outcome without +secrets, correlated with harness session/run/tool-call records; knowing those +identifiers grants no authority. From b5d5cc26c7b7d9b866cc524af192bd8f02bd06a5 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 6 Oct 2026 12:57:07 +0200 Subject: [PATCH 02/50] Creds lifetime changes --- docs/agent-authority-implementation.md | 87 +++++++++++++++----------- docs/agent-authority.md | 43 +++++++++---- 2 files changed, 82 insertions(+), 48 deletions(-) diff --git a/docs/agent-authority-implementation.md b/docs/agent-authority-implementation.md index f01fab22ea..1c23fe8186 100644 --- a/docs/agent-authority-implementation.md +++ b/docs/agent-authority-implementation.md @@ -143,7 +143,7 @@ broker-owned admission state. Exact receipts and JTI tracking remain process-local. Restart must fail closed, not imply durable single-use protection. Retrieving a started receipt is not a -new dispatch. The main's [interactive flow](agent-authority.md#4-interactive-execution-flow) +new dispatch. The main's [interactive flow](agent-authority.md#5-interactive-execution-flow) is a normative design, not an execution trace of I or W. ### B, W and S: credential continuity is not subject continuity @@ -173,6 +173,57 @@ details or introduce a new opaque user-evidence platform here. Complete the identity/broker foundation first. These are requirements to implement or decisions to select, not claims already satisfied by the snapshots. +### Reusable identity and run-scoped delegation + +The intended lifetime model now separates a reusable, short-lived agent credential, +run-scoped delegated access and per-call admission. This is not I's implemented +profile: I binds the agent SVID to an exact invocation and tracks its JTI as +single-use. Move occurrence/replay enforcement into call admission before allowing +identity-credential reuse; do not merely remove the existing checks. + +Define the trusted run binding, creation/termination authority, actor transitions +for children, credential-cache partitioning, renewal limits and restart behavior. +One run may use multiple resource-specific or renewed access tokens, but none may +outlive its authorized use merely because a provider refresh token remains valid. +Select how the execution boundary checks run status and how already-issued tokens +are treated after cancellation. OAuth exchange alone does not establish these +semantics. The relationship to the newer broker SessionService, and the concrete +identity/delegation interface split, remain design work rather than selected RPCs. + +### User evidence, exchange and account association + +- Qualify I's freshness, replay and lifetime rules and workload/invocation binding. + Prove rejection of stale, substituted or mismatched evidence. Decide how human + evidence constrains exchange lifetime without assuming I's issuer receives it. +- Select the **authorized association mechanism** linking Mecatl `(Issuer, Subject)`, + ToolHive canonical user and provider account, including recovery. Equal subjects, + callback state and PKCE do not prove identity equivalence. Recovery must retain + or authoritatively resolve canonical subject identity, not just credentials. +- Reuse the existing ToolHive auth-code access token as `subject_token`, never owner, + `ExternalBinding`, an attachment handle or `tsid`. Permit exchange in the broker + registration, which currently uses authorization code + refresh, + `openid`/`offline_access` and `client_secret_basic`. Select eligible resources, + scopes and subject/actor/policy lifetime bounds, and the broker's SVID client-auth + integration independently of harness-to-broker authentication. +- Select a distinct actor-validation/exchange profile and authorized presenter + association. Broker-admission audience is not automatically AS actor audience: + choose a separate credential or explicitly compatible profile, not casual audience + widening or a workload token relabelled as agent identity. +- Resolve an authorized local credential link from validated subject and broker + connection state, then pass it to ToolHive session construction. Verified user, + tenant, provider, connection/revision and target must agree; never copy an arbitrary + external `tsid`. Follow the continuity brief rather than inventing a new issuance + API, signing tier or consent-record platform. +- Define the scope, lifetime and withdrawal/revalidation rules for both user/account + and actor/presenter associations, including effects on new exchanges and + already-issued tokens. A valid token or connected provider account must not by + itself preserve a withdrawn association. +- Specify revocation behavior and residual lifetime of self-contained bearers, + including any live execution checks/introspection. Fresh Mecatl authentication + and broker exact-operation admission remain independent of refresh and exchange. + +These decisions are foundation work, not deferred to mandates or scheduled consent. + ### Session-scoped definitions and uniform identity PR [#2003](https://github.com/stacklok/mecatl/pull/2003) introduces @@ -243,40 +294,6 @@ implicitly. The concrete core representation and migration remain to be selected these requirements do not mandate constructing every root through an `AgentDef` or replacing the existing root/child engine builders. -### User evidence, exchange and account association - -- Qualify I's freshness, replay and lifetime rules and workload/invocation binding. - Prove rejection of stale, substituted or mismatched evidence. Decide how human - evidence constrains exchange lifetime without assuming I's issuer receives it. -- Select the **authorized association mechanism** linking Mecatl `(Issuer, Subject)`, - ToolHive canonical user and provider account, including recovery. Equal subjects, - callback state and PKCE do not prove identity equivalence. Recovery must retain - or authoritatively resolve canonical subject identity, not just credentials. -- Reuse the existing ToolHive auth-code access token as `subject_token`, never owner, - `ExternalBinding`, an attachment handle or `tsid`. Permit exchange in the broker - registration, which currently uses authorization code + refresh, - `openid`/`offline_access` and `client_secret_basic`. Select eligible resources, - scopes and subject/actor/policy lifetime bounds, and the broker's SVID client-auth - integration independently of harness-to-broker authentication. -- Select a distinct actor-validation/exchange profile and authorized presenter - association. Broker-admission audience is not automatically AS actor audience: - choose a separate credential or explicitly compatible profile, not casual audience - widening or a workload token relabelled as agent identity. -- Resolve an authorized local credential link from validated subject and broker - connection state, then pass it to ToolHive session construction. Verified user, - tenant, provider, connection/revision and target must agree; never copy an arbitrary - external `tsid`. Follow the continuity brief rather than inventing a new issuance - API, signing tier or consent-record platform. -- Define the scope, lifetime and withdrawal/revalidation rules for both user/account - and actor/presenter associations, including effects on new exchanges and - already-issued tokens. A valid token or connected provider account must not by - itself preserve a withdrawn association. -- Specify revocation behavior and residual lifetime of self-contained bearers, - including any live execution checks/introspection. Fresh Mecatl authentication - and broker exact-operation admission remain independent of refresh and exchange. - -These decisions are foundation work, not deferred to mandates or scheduled consent. - ### Integration, deployment and A–D qualification Reconcile identity integration and broker squash without silently merging their diff --git a/docs/agent-authority.md b/docs/agent-authority.md index a0ac428e54..bcf848e42a 100644 --- a/docs/agent-authority.md +++ b/docs/agent-authority.md @@ -65,7 +65,15 @@ It does not independently prove which agent inside Mecatl made the call. A compr **Identity never grants authority or proves consent.** The broker applies applicable authorization to the verified user, workload, logical agent and actual operation, not Mecatl's permission verdict. A separate signer or broker-audience claim does not turn trusted harness attestation into independent human proof. This design builds on authorization checks, not a new approval UI, opaque user-evidence platform, standing-grant store or mandate system. -## 3. Identity progression +## 3. Credential roles and lifetimes + +The logical-agent identity is stable, but its credentials expire and are renewed. A short-lived agent credential can be reused across calls and runs while its audience, permitted presenter and any issuance restrictions still apply. It identifies the agent; it is not a one-use permission for a particular call. The same agent identity does not make credentials with different restrictions interchangeable. + +Delegated access is scoped to a run: one execution started by a user request, not the whole conversation session. The run's authorization binds the user, acting agent, permitted resources and limits. A run can require several access tokens—for different resources or because an earlier token expired—but each must remain within that authorization. Changing the acting agent, including delegation to a specialist, requires an authorized actor transition rather than reusing a token that names another agent. + +The broker checks the run binding and current authority before each operation, as well as admitting the exact call. Creating a fresh token at run start is not enough to prevent its use in another run. Ending or cancelling the run stops new admission and renewal under that run. An already-issued bearer can still be accepted until expiry by a recipient that does not check run status; short lifetimes limit that exposure but do not provide immediate revocation. Renewing agent, ToolHive or provider credentials does not extend the run's authority or replace required fresh user evidence. + +## 4. Identity progression At every stage the harness (`mecak8s`) is a client of one logical broker deployment; the model does not authenticate or request credentials. @@ -150,7 +158,7 @@ flowchart LR Both broker endpoints must verify mTLS identity. A TLS-terminating ingress requires explicitly trusted identity propagation; an ordinary forwarded header is not proof. Certificate rotation, bundle refresh and connection renewal must preserve that check, with no silent bearer fallback. D authenticates the harness, not its agents, and does not automatically bind an OAuth access token to the certificate. Neither C nor D proves user consent or isolates agents sharing a harness. -## 4. Interactive execution flow +## 5. Interactive execution flow The flow separates provider setup from admission of each exact operation. It uses an OAuth-protected upstream requiring its native provider credential, rather than a backend that directly accepts the delegated token. Operation and scope names are illustrative, not wire names. @@ -160,6 +168,10 @@ The broker must establish which ToolHive user and connected provider account Ali Each interactive operation still requires Alice to be authenticated to Mecatl, the broker to check Mecatl’s current authentication report for that request, and policy to permit the operation. Being able to refresh a stored token keeps the connection usable; it does not authorize the next action. +### Execution sequence + +The sequence shows one call within a run. Agent issuance and delegated-token exchange occur only when a suitable valid credential is unavailable; the run and exact-call checks apply to every invocation. + ```mermaid sequenceDiagram actor U as Alice / human client @@ -188,17 +200,22 @@ sequenceDiagram Note over U,P: Authorize and execute one exact operation U->>H: Read PR 42 as code-reviewer, with current authentication H->>H: Resolve agent and narrowed authority, then apply permissions/hooks - H->>I: Workload authentication + owner and effective execution facts - I-->>H: Constrained short-lived logical-agent credential - H->>H: Verify credential against requested execution facts - H->>B: Workload + agent credential + exact call + fresh user evidence - B->>B: Verify identities, bindings and exact call, then apply Cedar + opt No suitable valid agent credential + H->>I: Workload authentication + resolved agent identity and issuance restrictions + I-->>H: Short-lived reusable logical-agent credential + end + H->>H: Verify agent credential and its restrictions for this execution + H->>B: Workload + agent credential + run binding + exact call + fresh user evidence + B->>B: Verify identities, active run and exact call, then apply Cedar Note over B,V: Denial stops before invocation credential selection or dispatch - B->>B: Obtain current ToolHive subject token from guarded custody/refresh - B->>A: subject_token + actor_token + broker client auth + eligible resource/scopes - A->>A: Validate subject, distinct actor/presenter and authorized association - A->>A: Bound access/lifetime and resolve authorized local credential link - A-->>B: Delegated token: sub=user, act.sub=agent, client_id=broker client + opt No suitable valid delegated token for this run and resource + B->>B: Obtain current ToolHive subject token from guarded custody/refresh + B->>A: subject_token + actor_token + broker client auth + eligible resource/scopes + A->>A: Validate subject, distinct actor/presenter and authorized association + A->>A: Bound access/lifetime and resolve authorized local credential link + A-->>B: Delegated token: sub=user, act.sub=agent, client_id=broker client + B->>B: Bind delegated token to the authorized run + end B->>V: Delegated token + independently admitted exact operation V->>V: Validate token and operation policy, then load authorized connection V->>P: Exact operation with native provider access credential @@ -225,7 +242,7 @@ These claims identify the parties and intended recipient; they do not specify or The protected resource validates issuer, audience, expiry and bounded access, applies operation policy and resolves only the authorized user/tenant/target's provider connection. `tsid` is a ToolHive-local credential link, outside SPIFFE's identity responsibility. It must come from validated subject and authoritative connection state, never an arbitrary external claim or caller-selected storage key. -## 5. Credential custody and safety boundaries +## 6. Credential custody and safety boundaries - **Human login bearers terminate at Mecatl's edge.** They are transient, never saved for downstream or scheduled use. Fresh harness-attested evidence is not the bearer, a stored owner, an attachment or an authorization grant. - **Agent credentials enter trusted Mecatl memory, not model content.** Client code verifies and attaches them without exposing them in arguments, results, conversation history, logs or environment variables. Model-hidden is not process-safe and does not guarantee secure memory erasure. From 3b4bd069b160708b4cc148e0e9a9314dee24f2b3 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 7 Oct 2026 22:22:27 +0200 Subject: [PATCH 03/50] Update --- docs/agent-authority-implementation.md | 214 ++++++++++---- docs/agent-authority.md | 382 +++++++++++++------------ docs/architecture/domain-model.md | 7 + docs/architecture/mecatl.modelith.md | 22 +- docs/architecture/mecatl.modelith.yaml | 36 ++- 5 files changed, 403 insertions(+), 258 deletions(-) diff --git a/docs/agent-authority-implementation.md b/docs/agent-authority-implementation.md index 1c23fe8186..e63b691cae 100644 --- a/docs/agent-authority-implementation.md +++ b/docs/agent-authority-implementation.md @@ -16,6 +16,7 @@ Re-pin before implementation. Individual branch tests do not prove their combina |---|---|---| | **I** | Mecatl `acc/singleton-identity-mcp-integration` — `2ee96819939367e5f7c57fd48ead21fca828bd0a` | Harness-attested user evidence, logical-agent issuance and exact-call Execute admission. Issuer lacks human freshness; Execute checks it separately. | | **B** | Mecatl `acc/singleton-mcp-broker-review-remediation-squash` — `06d1ecc2c12d6533e060d90809f4425c8d78294f` | External broker, ToolHive OAuth and guarded encrypted credential recovery; recovery does not preserve the original canonical human subject. | +| **B2** | Mecatl `broker-simple-take-2` — `0d9467664c7a496b8dda287183f89dedea3ec786` | Workload-authenticated broker sessions, browser OAuth, account continuity, encrypted custody and recovery. Initial caller-to-account authorization, instance-SVID exchange, and executing-run binding remain integration work. | | **W** | ToolHive `spiffe-v2-14-docs` — `36f85e4b0f6b9e449fd2099ec164bb1204ded5cc` | SPIFFE WIP; distinct logical-agent actor validation and exchanged-token upstream linkage remain gaps. | | **S** | Mecatl `broker-simple/04-toolhive-backend` — `e3ce6b374e74847c79f5ab9b69b987693d4540d6` | Newer committed-connection mapping with revision/provider checks; not B's storage implementation. | @@ -99,7 +100,7 @@ Two source-review findings at **P**, not fixes or live reproductions: - Ordinary Kubernetes SA tokens are classified as user grants, while issuance requires a client-credentials grant. That blocks the ordinary-SA - [stage B](agent-authority.md#b--sa-authenticated-logical-agent-credential) flow at + [stage B](agent-authority.md#example-flow-serviceaccount-plus-agent-instance-jwt-svid) flow at this pin, between [GrantTypeFromClaims](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/engine/session/principal.go) and [IssueAttested](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/host.go). - The [bundle-format gap](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/bundle.go) @@ -168,6 +169,49 @@ new-user-evidence exploration for subject reuse and linkage; the SPIFFE brief still covers separate logical-agent actor support. Do not duplicate those adapter details or introduce a new opaque user-evidence platform here. +### B2: enrollment and account continuity to reuse + +B2 is the starting point for extending the broker's connection machinery. The +following findings come from source and test inspection at the pinned commit; +the tests were not run during this review. + +Mecatl records the initiating principal and checks Connect ownership when ownership +enforcement is enabled. Broker RPCs authenticate the workload instead. Production +`OWNERLESS` partitions attachments by workload and deployment; it does not mean +that ToolHive has no OAuth user, or that all sessions share one account. Each +broker session can establish its own connection baseline. See the +[host Connect path][b2-connect] and [broker partitioning][b2-partitions]. + +Enrollment binds OAuth state and the retained PKCE verifier to the broker's +logical session and authorization transaction. The callback rejects stale or +replayed state and unexpected selectors, then exchanges the code. Authenticated +observation performs ToolHive discovery before publishing the connection and +catalogue. The broker records an account digest over the configured providers' +verified user, upstream subject, and client identities. Later reauthorization or +recovery rejects a changed account. Explicit disconnect clears the baseline and +invalidates parked work before another connection is selected. Sources: +[callback][b2-callback], [connection publication][b2-publication], and +[account continuity][b2-account]. + +This establishes transaction correlation and account continuity, not the initial +caller's permission to use that account. If Alice owns the Mecatl session but Bob +completes its genuine first enrollment URL, Bob's authenticated account can become +the baseline while Alice remains the session owner. That outcome follows from the +inspected flow; no dedicated first-connection Alice/Bob test was found. It can be +legitimate account sharing, but the flow does not establish that Alice authorized +that selection. A later changed-account continuation is rejected. The +[production tests][b2-production-tests] cover that continuation check, not proof +that the first browser user equals the initiating Mecatl user. + +Preserve B2's references, catalogue checks, credential custody, and host-side +unknown-outcome fence. Its attempt identifier is not a durable idempotency key: +the [occurrence test][b2-occurrences] deliberately demonstrates repeated execution +of the same attempt. Session and account references also do not establish an +executing-run binding. Production has browser authorization-code and refresh +support, but no integrated logical-agent SVID actor exchange or admitted-run +lifecycle. Those are additions to this foundation, not reasons to replace OAuth +or invent another user-token lifecycle. + ## Current gaps and foundation work Complete the identity/broker foundation first. These are requirements to implement @@ -181,48 +225,82 @@ profile: I binds the agent SVID to an exact invocation and tracks its JTI as single-use. Move occurrence/replay enforcement into call admission before allowing identity-credential reuse; do not merely remove the existing checks. -Define the trusted run binding, creation/termination authority, actor transitions -for children, credential-cache partitioning, renewal limits and restart behavior. -One run may use multiple resource-specific or renewed access tokens, but none may -outlive its authorized use merely because a provider refresh token remains valid. -Select how the execution boundary checks run status and how already-issued tokens -are treated after cancellation. OAuth exchange alone does not establish these -semantics. The relationship to the newer broker SessionService, and the concrete -identity/delegation interface split, remain design work rather than selected RPCs. - -### User evidence, exchange and account association - -- Qualify I's freshness, replay and lifetime rules and workload/invocation binding. - Prove rejection of stale, substituted or mismatched evidence. Decide how human - evidence constrains exchange lifetime without assuming I's issuer receives it. -- Select the **authorized association mechanism** linking Mecatl `(Issuer, Subject)`, - ToolHive canonical user and provider account, including recovery. Equal subjects, - callback state and PKCE do not prove identity equivalence. Recovery must retain - or authoritatively resolve canonical subject identity, not just credentials. -- Reuse the existing ToolHive auth-code access token as `subject_token`, never owner, - `ExternalBinding`, an attachment handle or `tsid`. Permit exchange in the broker - registration, which currently uses authorization code + refresh, - `openid`/`offline_access` and `client_secret_basic`. Select eligible resources, - scopes and subject/actor/policy lifetime bounds, and the broker's SVID client-auth - integration independently of harness-to-broker authentication. -- Select a distinct actor-validation/exchange profile and authorized presenter - association. Broker-admission audience is not automatically AS actor audience: - choose a separate credential or explicitly compatible profile, not casual audience - widening or a workload token relabelled as agent identity. -- Resolve an authorized local credential link from validated subject and broker - connection state, then pass it to ToolHive session construction. Verified user, - tenant, provider, connection/revision and target must agree; never copy an arbitrary - external `tsid`. Follow the continuity brief rather than inventing a new issuance - API, signing tier or consent-record platform. -- Define the scope, lifetime and withdrawal/revalidation rules for both user/account - and actor/presenter associations, including effects on new exchanges and - already-issued tokens. A valid token or connected provider account must not by - itself preserve a withdrawn association. -- Specify revocation behavior and residual lifetime of self-contained bearers, - including any live execution checks/introspection. Fresh Mecatl authentication - and broker exact-operation admission remain independent of refresh and exchange. - -These decisions are foundation work, not deferred to mandates or scheduled consent. +Extend B2's existing session/invocation machinery with an admitted executing-run +association. The first preference is broker-held run context, addressed by a +reference bound to authenticated requests alongside the attachment. Reuse existing +reference machinery where possible; this does not select new RPCs or a token +schema. Obtain the ToolHive user from the broker-held subject token and the actor +from the instance SVID. Preserve the authorized connection association described +below. Bind each actor's own run, retaining parent/root execution links separately +from the instance's creation ancestry. + +Authenticate the initiating human when admitting work. Ordinary expiry of that +login bearer does not end the admitted run; completion, cancellation, and configured +budgets do. Renewal must remain within that run's authority, and explicit policy +withdrawal can restrict subsequent calls. Dependent child access must close with +its parent. An unfinished run can recover only when durable state establishes its +binding; restoring a session or credential alone cannot revive ended work. + +Use short-lived delegated access tokens, renewable only within an active admitted +run. One run can reuse suitable tokens or obtain resource-specific replacements. +Define cache partitioning, actor-transition checks, lifecycle propagation, and restart +behavior without confusing token refresh with new authority. Recipients that do +not check live run status can still accept an issued bearer until expiry. Preserve +unknown-outcome handling for dispatched calls rather than claiming immediate +revocation or replay safety from the run reference. + +### Caller/account association and exchange on B2 + +Reuse B2's enrollment and verified account baseline. The missing association is +which authenticated Mecatl caller may use that connection for an admitted run. +The provider account need not represent the same natural person. State/PKCE and +account-continuity checks do not establish that permission on first enrollment. + +- Select how authenticated run admission establishes the caller's permission to + use the discovered ToolHive/provider account. Owner-bound completion, + authenticated account confirmation, and trusted entitlement mapping are + alternatives, not selected mechanisms. Equal subject strings or emails across + issuers are insufficient. Preserve the accepted association through recovery + without replaying or retaining the human's login bearer. +- Extend the workload-authenticated boundary to carry or resolve the trusted + admission association. B2's injectable owner-partition resolver is not a shipped + per-human production mode, and partitioning by owner alone would not authorize + the first browser account. Keep harness-reported authentication distinct from + facts independently checked by the broker. Reconcile I's per-call freshness + gate with admission-time authentication; do not make an already-admitted run + depend on the continued validity of the original login token. +- Integrate the instance SVID and token exchange into B2's production path. Reuse + the ToolHive auth-code access token as `subject_token`, never an owner label, + attachment handle, or `tsid`; use the instance credential as `actor_token` and + authenticate the broker OAuth client separately. B2's + [client registration][b2-client] currently enables authorization code and refresh. + Configure the exchange grant, eligible resources/scopes, actor validation, and + authorized presenter association. An SVID accepted for broker admission is not + automatically valid at the ToolHive authorization server. +- Preserve or authoritatively resolve canonical ToolHive subject identity across + credential recovery. B2's [recovered bearer][b2-recovered] uses an owner-partition + subject and verified token-session linkage; recovering the connection does not + prove the required delegated token has the original human `sub`. Do not treat + the account digest as a substitute for canonical identity. +- Resolve the authorized provider credential link from validated subject and + connection state. User, tenant, provider, connection/revision, and target must + agree; never accept a caller-selected storage key or external `tsid`. Reuse the + [credential-continuity brief](toolhive-credential-continuity-followup.md) for + ToolHive-specific exchange/storage integration rather than designing a second + OAuth subsystem here. + +Qualify the additions through the real host-to-broker path: admission for the +correct caller/account; first enrollment completed by another account; unauthorized +association and changed-account refusal; cross-instance/run reference substitution; +continued admitted work after login-token expiry; completion/cancellation; and +recovery without account substitution or replay of uncertain effects. An intentional +account-sharing case must be distinguishable from an unauthorized association under +the selected mechanism. Retain B2's existing callback and continuity regressions; +their presence is not a test run of the future combination. + +Detailed association and authorization mechanisms remain separate design work. +These integration requirements do not select a new user-evidence credential, +consent UI, or mission/mandate system. ### Session-scoped definitions and uniform identity @@ -248,10 +326,11 @@ explicit integration contract. ### Required identity integration **Carry one resolved identity through execution.** Trusted definition resolution -should produce a value containing the source tier and exact name. Bind that identity -to the session and pass it into execution evidence rather than reconstructing it -from role strings, authority provenance or the session’s position in the delegation -tree. Use `system/main` for the default root. +should preserve category, namespace, and exact name, with the session incarnation +identifying the instance. Bind these to the session and pass them into execution +context rather than reconstructing them from role strings or authority provenance. +Keep the built-in `system` definitions `main` and `explorer` distinct; their +namespace assignment remains to be specified. **Keep configuration, identity and authority separate.** `AgentDef` supplies configuration; the resolved identity identifies the agent; `Session.Authority` @@ -267,12 +346,24 @@ representing the root. Do not introduce a second depth counter. Fork and clear ancestry must not count as delegation. Retain execution-specific distinctions where team coordination, parallel execution, scheduling or debugging actually require them. -**Preserve resolved identity across lifecycle operations.** A persisted definition -name alone does not establish which source tier supplied it. Fork, clear and -supported restoration must preserve the resolved identity. Select a reconstruction -policy for definition changes: restoring a pinned definition and accepting updated -configuration under restrictions are different contracts. Until supported, fail -closed rather than silently select another identity or the default engine. +**Preserve definition association across lifecycle operations.** A persisted name +alone does not establish which namespace and source supplied it. Resume/recovery +retains the instance incarnation and pinned definition revision. Peer fork/clear +retains the definition identity but creates a new incarnation and resolves the +current revision. Detailed revision/dependency retention is separate design work; +unsupported recovery must fail explicitly rather than select another definition +or silently update the existing instance. + +Choose a recoverable, issuer-verified source identifier for the instance and each +creation-ancestry hop; the readable category/name is insufficient. Source adapters +provide provenance without requiring the engine to understand Git, Kubernetes or +database revision formats. Decide whether associated state or a versioned application +claim carries what a verifier needs. A source-native revision can miss dirty local +content, while a digest cannot reconstruct it; specify coverage and snapshot +retention before relying on either. Keep private repository locations and filenames +in guarded records rather than readable JWTs. Missing optional claims imply nothing, +and claims repeating path fields must agree with them. Identifier, transport, claim +fields and source-specific mapping remain open. **Integrate named roots with the broker without widening authority.** Define which broker tools a selected definition may expose, intersect discovery with its bound @@ -285,10 +376,10 @@ into its credential representation; it must not assume that every root is `system/main` or parse diagnostic role strings to discover an agent’s identity. Verify that the same definition used as a main agent and delegated specialist -produces the same logical subject while retaining distinct authority and call -bindings. Cover default `system/main`, source-tier collisions, fork/clear behavior, -definition changes on restore, child narrowing, broker discovery beyond the -ceiling, and preservation of root permission asks and guardrails. Unsupported +retains the same definition reference while producing distinct instance subjects +and run/call bindings. Cover built-in definitions, source/namespace collisions, +fork/clear behavior, definition changes on restore, child narrowing, broker discovery +beyond the ceiling, and preservation of root permission asks and guardrails. Unsupported execution paths must remain rejected rather than gaining credential issuance implicitly. The concrete core representation and migration remain to be selected; these requirements do not mandate constructing every root through an `AgentDef` @@ -440,6 +531,15 @@ conversation snapshots, not the evolving contract. ## Pinned references +[b2-connect]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/server/session_broker.go#L127-L144 +[b2-partitions]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbrokerserver/session_api.go#L15-L64 +[b2-callback]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/auth.go#L584-L749 +[b2-publication]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/session_api.go#L662-L712 +[b2-account]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/toolhive_process.go#L120-L153 +[b2-production-tests]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/server/session_broker_production_test.go#L659-L738 +[b2-occurrences]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/session_api_occurrence_test.go#L53-L105 +[b2-client]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/toolhive_process.go#L678-L717 +[b2-recovered]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/toolhive_recovered_credential.go#L30-L94 [current-authn]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/internal/adapter/server/authn.go#L463-L495 [current-evidence]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/engine/port/authentication_evidence.go#L12-L40 [current-execute]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/internal/adapter/mcpbrokergrpc/server_execute.go#L216-L229 diff --git a/docs/agent-authority.md b/docs/agent-authority.md index bcf848e42a..b0ada3d107 100644 --- a/docs/agent-authority.md +++ b/docs/agent-authority.md @@ -1,176 +1,145 @@ # Agent authority: user journey and trust boundaries -This page describes the **intended design**, not a delivery claim. The [implementation companion](agent-authority-implementation.md) records implementation evidence, unresolved choices and remaining work. +This page records the intended design of the agent identity work in mecatl, how credentials flow in the system and what the trust model is. ## 1. User journey -Alice asks `code-reviewer` to read PR 42 in `acme/payments`. Mecatl identifies the acting agent; an external broker verifies the presented evidence and applies its own policy to that exact operation before selecting credentials or dispatching. Permission to read PR 42 must not authorize merging PR 43. +Alice asks `code-reviewer` to read PR 42 in `acme/payments`. Mecatl validates her login token and reports the verified user and acting reviewer instance to the workload-authenticated broker. The broker checks the instance credential and applies its own policy to the run and exact operation before dispatch. It trusts Mecatl’s report that Alice authenticated; it does not revalidate her login token or infer that she approved this particular action. -The operator can inspect which user and agent were associated with the request, why the broker allowed or denied it, and its observed outcome—including an unknown outcome—without reconstructing the decision solely from Mecatl's logs. The record must survive broker restart and show which checks the broker performed itself and which identity claims it accepted from Mecatl. - -**Mecatl identifies the user and agent; the broker applies its own policy to their request.** For Alice’s request, the broker relies on authenticated Mecatl to report that Alice’s login token was validated and that `code-reviewer` is acting for her. The broker checks the credentials and request against its own policy before allowing the operation. It does not revalidate Alice’s login token or treat her authenticated identity as proof that she approved this particular action. +Binding the user, agent instance, run, and operation provides the substrate for broker authorization decisions and future audit records. Reading PR 42 does not authorize merging PR 43; an audit record must distinguish the broker’s checks from Mecatl’s assertions and capture the observed outcome. ## 2. Identity and trust model -These terms build on the [domain model](architecture/domain-model.md), not a second set of core entities. - ### User identity and persisted ownership -Alice's identity is the `(Issuer, Subject)` pair represented by `session.Principal`, not her display name or email. Mecatl validates her login access token's signature, trusted issuer, intended audience and expiry. It records the principal as the session owner at creation; children and forks inherit it. Ownership enforcement prevents another caller from taking over her session. - -When Alice requests an operation, Mecatl sends the broker a record of her current authentication, tied to that invocation. The broker checks that the record came from an authenticated Mecatl workload, is recent enough, and identifies the same user—by issuer and subject—as the session’s saved owner. A session owned by Alice cannot be used with Bob’s authentication record. - -The saved owner tells the broker whose session this is; it does not show that Alice is still authenticated or that the operation is allowed. Those require separate checks. - -Here, *owner* and *subject* refer to the same user in different contexts: Alice owns the session and is the subject of the user token. The logical-agent credential has its own subject, identifying `code-reviewer` rather than Alice. +Mecatl identifies Alice by the issuer and subject of her validated login token, represented by `session.Principal`. Mecatl saves that principal as the session owner; children and forks inherit it. -### Logical agent, instance and workload +For each new human-initiated request, Mecatl validates the caller's login token signature, trusted issuer, intended audience and expiry, then checks that its issuer and subject match the saved session owner. It reports the verified user to the workload-authenticated broker for run admission; the broker checks that the report came from trusted Mecatl and matches the owner. Once a run is admitted, expiry of Alice's original login token does not end it. -- **Logical agent / actor:** the agent performing the work. A named agent such as `code-reviewer` gets its identity from its resolved `AgentDef`, whether it runs as the main session or as a delegated specialist. A main session without a selected definition uses `system/main`. Neither identity represents Alice or the `mecak8s` worker. -- **Execution:** a particular run of that agent in a session. Two sessions can run the same agent with different permissions. Delegation depth distinguishes the main execution at depth zero from agents working beneath it; it does not change their logical identity. Forking or clearing a session does not, by itself, make it a delegated child. -- **Mecatl workload:** the `mecak8s` worker that authenticates to the broker. Agents running inside the same worker share that workload identity. +### Agent definitions and execution lifetimes -Effective authority and environment are context, not additional identities. Child authority is tighten-only: a definition containing write tools cannot restore writes denied to its parent. `tool.Environment` supplies a workspace and optional bound command runner, durably identified by `EnvironmentRef{Kind, ID, Revision}`. Running in Alice’s workspace does not, by itself, authorize the agent to use her GitHub account. +- **Agent definition:** `code-reviewer` is a selected `AgentDef`; this design also gives the default main and unnamed child the built-in identities `system/main` and `system/explorer`. Broker policy would distinguish the verified source supplying `code-reviewer`, not rely on `project/code-reviewer` alone. Changing its content does not change that source identity; a task prompt cannot create a new definition. +- **Session:** The [domain model](architecture/domain-model.md) defines the `Session` aggregate. In this design, Alice and Bob have separate conversations and owners even if both select `system/main`. +- **Session incarnation:** the durable identity this design uses to distinguish their agent instances, even if an internal caller later reuses a session ID. Restoration keeps the incarnation. +- **Agent instance:** the proposed logical identity of a selected definition in one session incarnation, not a process or a run. Alice can select `code-reviewer` for her main instance or invoke it as a distinct child; a peer fork/clear creates another instance. +- **Run:** Alice's PR 42 task is one run of the reviewer instance. Continuing it after an approval pause keeps its run identity; giving the same instance a new task starts another run. The existing `RunID` correlates execution but does not authorize it: broker admission for each run is part of this design. +- **Workload:** the Mecatl service authenticating to the broker (`mecak8s` in this flow). Its identity names neither Alice nor one of the agent instances it hosts. -Authentication happens twice: `mecak8s` authenticates to the broker, then the broker authenticates to ToolHive’s token endpoint. Neither identifies `code-reviewer`—its identity comes from a separate agent credential. ToolHive checks that credential and whether the broker is allowed to present it. Each logical agent does not need its own OAuth client registration. +### Instance subject and creation ancestry -### Logical-agent naming and constrained issuance - -An agent keeps the same identity when its prompt or allowed tools change. Its identity depends on where its definition comes from (such as the project or user configuration) and its exact name. For a project-defined `code-reviewer`, the credential's subject looks like this (`` represents the full computed hash): +In this proposed design, a short-lived JWT-SVID carries the agent instance's SPIFFE ID. Each creation hop names a source category (`system`, `explicit`, `project`, `user` or `driver`), definition name and persisted session incarnation: ```text -spiffe://agents.example.com/mecatl/agent-definition/v1/project/code-reviewer-- +spiffe:///mecatl/agent///inst/[/child///inst/ ...] ``` -The trust domain is an identity namespace, not a broker address. Tiers—`project`, `user`, `managed`, `driver`, `system`—distinguish sources, not privilege levels. The slug is readable but not unique: `code reviewer` and `code-reviewer` can share it. The deterministic digest of tier and exact name distinguishes them. Editing tools or prompt leaves the identity unchanged; renaming or changing tier does not. +These illustrative subjects show Alice's main instance, its reviewer child, and a peer fork or clear of the main: -Agent identity, delegation depth and permissions describe different things. If Alice starts `code-reviewer` as her main session, it has the same logical identity as `code-reviewer` invoked as a specialist from the same definition. The specialist’s permissions are additionally restricted by its parent. Neither execution may borrow permissions from the other. +```text +# Alice's main instance +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u -The default main agent uses `system/main` without requiring an agent-definition file. Its credential subject has this shape: +# Reviewer child created by that main instance +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m -```text -spiffe://agents.example.com/mecatl/agent-definition/v1/system/main-- +# Alice forks or clears her main instance +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_45bvtrrrpkekb6336hwe6bvape ``` -Here the digest is computed from `system` and `main`, not the user or session ID. Alice’s and Bob’s default main sessions therefore share this logical-agent identity, but not their user identity, authority, broker attachments or provider credentials. If Alice instead starts a main session using the project-defined `code-reviewer`, its subject is the `project/code-reviewer` identity shown above—not `system/main`. Selecting a named definition changes which agent runs, not the rule that every operation must satisfy the session’s authority and broker policy. +`project/code-reviewer` illustrates a wider problem: a category and name do not necessarily identify the source that supplied a definition. The payments and website projects, for example, could each supply `code-reviewer`. Before applying a definition-specific policy, the broker needs a verified record of which source supplied this instance's definition. How the issuer verifies and preserves that record, including across repository moves or renames, remains to be designed. If the record is unavailable, the broker must not guess from the path. -Two runs of `code-reviewer` can have different permissions. The broker checks the tools allowed by the credential attached to this call; it must not add tools allowed in another run. That tool list does not describe which files the agent can access, whether it may edit the parent workspace, or the chain of delegations that led to the call. +The reviewer subject adds `/child/project/code-reviewer/inst/` to Alice's main subject: her main created that instance. The forked main ends in `/system/main/inst/inc_45bvtrrrpkekb6336hwe6bvape` without `/child/`: fork or clear creates another root instance. A team member started directly through the API is also a root. -The combined broker has two responsibilities: its issuer signs only permitted agent identities and tool ceilings requested by an authenticated harness; its invocation component verifies presented credentials and enforces operation policy. The harness also verifies the returned credential's signature, expiry, audience, identity and tool list against its request. Public keys permit verification, not signing. These credentials use SPIFFE names and JWT-SVIDs without requiring SPIRE. +### Credential format and source provenance -### What signatures and identity do not prove +Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs; a Mecatl cap and compatible name/depth bounds remain undecided. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. -A signature proves who issued a credential and that its contents have not changed. -It does not independently prove which agent inside Mecatl made the call. A compromised shared harness can attach `deployer`'s credential to `code-reviewer`'s call, within the credentials it can obtain or use. Keeping the signing key outside Mecatl limits forgery, not that substitution. Separate instance IDs or keys in the same compromised process provide no process isolation; a different SPIFFE name does not change this boundary. +Resume requires the instance's original definition source and revision; if either is unavailable, it cannot silently select another. Fork or clear starts a new instance using the current revision from the same source. Keep private source locations out of readable JWTs. How source records, revision snapshots, and optional claims are represented remains open. -**Identity never grants authority or proves consent.** The broker applies applicable authorization to the verified user, workload, logical agent and actual operation, not Mecatl's permission verdict. A separate signer or broker-audience claim does not turn trusted harness attestation into independent human proof. This design builds on authorization checks, not a new approval UI, opaque user-evidence platform, standing-grant store or mandate system. - -## 3. Credential roles and lifetimes +### What signatures and identity do not prove -The logical-agent identity is stable, but its credentials expire and are renewed. A short-lived agent credential can be reused across calls and runs while its audience, permitted presenter and any issuance restrictions still apply. It identifies the agent; it is not a one-use permission for a particular call. The same agent identity does not make credentials with different restrictions interchangeable. +A signed agent JWT-SVID proves its issuer signed an instance identity, not which agent inside a shared Mecatl process used it. If that process is compromised, it can present `deployer`'s credential on `code-reviewer`'s call when it has access to that credential. Different SPIFFE names or keys held in the same process do not isolate the agents. The [interactive flow](#5-interactive-execution-flow) covers the broker's separate run and call checks. -Delegated access is scoped to a run: one execution started by a user request, not the whole conversation session. The run's authorization binds the user, acting agent, permitted resources and limits. A run can require several access tokens—for different resources or because an earlier token expired—but each must remain within that authorization. Changing the acting agent, including delegation to a specialist, requires an authorized actor transition rather than reusing a token that names another agent. +## 3. Credential roles and lifetimes -The broker checks the run binding and current authority before each operation, as well as admitting the exact call. Creating a fresh token at run start is not enough to prevent its use in another run. Ending or cancelling the run stops new admission and renewal under that run. An already-issued bearer can still be accepted until expiry by a recipient that does not check run status; short lifetimes limit that exposure but do not provide immediate revocation. Renewing agent, ToolHive or provider credentials does not extend the run's authority or replace required fresh user evidence. +An instance JWT-SVID can be renewed or reused across that instance's runs; the broker admits each run separately. -## 4. Identity progression +Delegated access belongs to the acting instance's admitted run, not its conversation session or a parent run. One run may need several access tokens for different resources or after expiry, but each stays within its authorization. A specialist uses its own run and identity, never a token naming its parent. -At every stage the harness (`mecak8s`) is a client of one -logical broker deployment; the model does not authenticate or request credentials. -A–D concern **only Mecatl → broker**, not the separate broker OAuth client → ToolHive relationship in the interactive flow. +An admitted parent can start a child without Alice logging in again. The child's separate run derives its user and authority from the active parent and the child's definition ceiling; a saved owner or instance credential alone cannot open it. If Alice's run `R42` starts reviewer run `C1`, `R42` is `C1`'s parent and root run, distinct from instance ancestry in the SPIFFE ID. `C1` cannot outlive the applicable parent and root authority. -### A — workload-only Kubernetes ServiceAccount authentication +A token minted at run start cannot authorize another run. Completing or cancelling a run stops new admissions and delegated-access issuance or renewal for it and dependent children, although an operation already dispatched may finish. A recipient without live run-status checks may still accept an issued bearer until expiry. Renewing agent, ToolHive or provider credentials neither extends run authority nor authenticates another initiating request. -Kubernetes projects a short-lived ServiceAccount (SA) bearer token with the broker as audience. Over trusted TLS, the broker validates the configured Kubernetes issuer, audience and SA subject allowlist. It owns provider credentials and invokes ToolHive itself. +An approval wait or provider-enrollment pause leaves the run unfinished; continuation rechecks its original authorization and pending operation. Client disconnect follows the run's actual lifecycle, not automatic cancellation or indefinite authority. After restart, only a durable record of the unfinished run and its original authorization permits continuation; a saved owner, instance credential, connection or attachment alone cannot revive it. An ended child invoked again keeps its instance but needs a new run. Never blindly replay an operation whose outcome is unknown. -```mermaid -flowchart LR - P["Kubernetes"] -->|Projected SA token| H["Harness: mecak8s"] - subgraph B["Combined broker"] - R["MCP authorization and execution"] - T["Embedded ToolHive"] - R -->|Broker-owned invocation| T - end - H -->|TLS + SA bearer + call| R -``` +A run may have narrower resource/action limits than its definition ceiling. These broker limits govern protected external calls, not local file access or direct writes to a parent workspace; Mecatl's permission and environment controls still govern those. Pinning a definition revision across resume does not pin old operator grants. A model-proposed goal or broad definition tool list is not a trusted task grant; mission-bound authorization is a possible later approach, not a selected approval or policy-generation mechanism. Broker policy changes must not automatically widen an already admitted run. Explicit restrictions or withdrawal can constrain subsequent operations, while an expansion requires an authorized change. Policy lookup, token-carried limits, online evaluation and subset algorithms remain separate authorization-design choices; a signed SVID does not decide among them. -`code-reviewer` and `deployer` inside the same harness present the same SA identity. This restricts which harness may connect, but cannot distinguish those agents for policy. A self-declared agent name is not a substitute for B's credential. - -### B — SA-authenticated logical-agent credential +## 4. Identity progression -B adds a harness-attested, short-lived agent credential. The broker authenticates the SA, constrains which agent identities and tools it may request, and returns a signed credential. On invocation it checks both workload and agent credentials, exact-call admission and its own policy before using provider credentials. +At every stage, `mecak8s` is a client of one broker deployment; the model neither requests credentials nor authenticates. A is a workload-only checkpoint. B adds agent-instance identity, while C and D change how the same harness authenticates its workload. These are alternatives, not a required sequence; agent JWT-SVIDs do not require SPIRE. The separate broker-to-ToolHive OAuth flow appears in [the interactive sequence](#5-interactive-execution-flow). -```mermaid -flowchart LR - P["Kubernetes"] -->|Projected SA token| H["Harness: mecak8s"] - subgraph B["Combined broker"] - I["Logical-agent issuer"] - R["MCP authorization and execution"] - T["Embedded ToolHive"] - R -->|Broker-owned invocation| T - end - H -->|TLS + SA bearer + agent request| I - I -->|Broker-issued agent credential| H - H -->|TLS + SA bearer + agent credential + call| R -``` +| Stage | Workload authentication to the broker | Agent-instance credential | +|---|---|---| +| **A** | Projected Kubernetes ServiceAccount (SA) bearer over TLS | None | +| **B** | Projected SA bearer over TLS | Broker-issued instance JWT-SVID | +| **C** | SPIRE workload JWT-SVID from the Workload API over TLS | Broker-issued instance JWT-SVID | +| **D** | SPIRE workload X.509-SVID and key from the Workload API over mTLS | Broker-issued instance JWT-SVID | -Operator configuration supplies accepted Kubernetes issuers, audiences and SAs, issuance limits and trusted public signing keys. Verification endpoints, CA trust and server names are never selected by a token. These are responsibilities within one broker deployment, not separate services or trust domains. Bearer credentials remain presentable by whoever possesses them; the other admission checks still apply. +In A, the broker validates the configured Kubernetes issuer, audience and SA allowlist, then invokes ToolHive with credentials it holds. `code-reviewer` and `deployer` in one harness have the same workload identity; a self-declared agent name cannot distinguish them for policy. -### C — SPIRE JWT-SVID workload authentication +### Example flow: ServiceAccount plus agent-instance JWT-SVID -C replaces B's SA token with a platform JWT-SVID for the broker's audience, obtained through the SPIFFE Workload API and presented on issuance and invocation requests. -The broker verifies signature, audience, expiry and allowed workload SPIFFE ID. +B adds a short-lived credential for the acting instance. The broker authenticates the SA; its issuer must check the resolved definition and creation ancestry against trusted inputs rather than sign a model-supplied name. How it obtains those trusted inputs remains open. The diagram separates obtaining that credential, admitting a run and admitting one exact call: ```mermaid -flowchart LR - P["Platform SPIRE"] -->|Platform JWT-SVID via Workload API| H["Harness: mecak8s"] - subgraph B["Combined broker"] - I["Logical-agent issuer"] - R["MCP authorization and execution"] - T["Embedded ToolHive"] - R -->|Broker-owned invocation| T +sequenceDiagram + participant K as Kubernetes + participant H as mecak8s + box Combined broker + participant I as Agent-instance issuer + participant R as Run and call admission + participant T as Embedded ToolHive + end + K->>H: 1. Project short-lived SA token for broker audience + opt No suitable instance JWT-SVID + H->>I: 2. SA bearer + resolved instance, definition and ancestry + I-->>H: Signed instance JWT-SVID + end + H->>R: 3. SA bearer + instance JWT-SVID + initiating user and run + R->>R: Verify workload, instance, user and run limits + opt Run admitted + R-->>H: Admitted run reference + H->>R: 4. SA bearer + instance JWT-SVID + run reference + exact call + R->>R: Verify workload, instance, active run, target, arguments and policy + opt Exact call admitted + R->>T: Invoke admitted operation + end end - H -->|TLS + platform JWT-SVID + agent request| I - I -->|Broker-issued agent credential| H - H -->|TLS + platform JWT-SVID + agent credential + call| R ``` -Agent issuance and exact-call checks remain as in B. Platform identity does not replace the broker's logical-agent signing keys or create federation. Platform credential renewal and trust-bundle refresh are distinct from logical-agent key rotation. This is still bearer authentication: a stolen JWT-SVID is replayable until expiry. +Operator configuration fixes accepted Kubernetes issuers, audiences and SAs, issuance limits and trusted public signing keys. Verification endpoints, CA trust and server names are never selected by a token. A stolen SA token or workload JWT-SVID remains a bearer credential until expiry; workload proof does not replace per-run and per-call admission. -### D — SPIRE X.509-SVID mutual TLS +In C, `mecak8s` obtains a platform JWT-SVID for the broker's audience through the SPIFFE Workload API and presents it on issuance and invocation requests. The broker checks signature, audience, expiry and allowed workload SPIFFE ID. Platform credential renewal and trust-bundle refresh are separate from the broker issuer's key rotation; platform identity neither replaces agent-instance issuance nor creates federation. -D replaces bearer workload authentication with proof of a workload private key in the TLS handshake. The harness obtains its certificate and key through the Workload API; the broker verifies platform trust and permits only configured workload IDs. +In D, `mecak8s` obtains an X.509-SVID and key through the Workload API and proves possession during mTLS to both broker endpoints. The broker checks platform trust and permitted workload IDs. A TLS-terminating ingress needs explicitly trusted identity propagation; a forwarded header alone is insufficient. Rotation, bundle refresh and connection renewal must preserve mTLS checks without bearer fallback. Workload mTLS does not bind an OAuth access token to its certificate or prove user consent or isolation among agents in one harness. -```mermaid -flowchart LR - P["Platform SPIRE"] -->|X.509-SVID and key via Workload API| H["Harness: mecak8s"] - subgraph B["Combined broker"] - I["Logical-agent issuer"] - R["MCP authorization and execution"] - T["Embedded ToolHive"] - R -->|Broker-owned invocation| T - end - H -->|Workload mTLS + agent request| I - I -->|Broker-issued agent credential| H - H -->|Workload mTLS + agent credential + call| R -``` +## 5. Interactive execution flow -Both broker endpoints must verify mTLS identity. A TLS-terminating ingress requires explicitly trusted identity propagation; an ordinary forwarded header is not proof. -Certificate rotation, bundle refresh and connection renewal must preserve that check, with no silent bearer fallback. D authenticates the harness, not its agents, and does not automatically bind an OAuth access token to the certificate. Neither C nor D proves user consent or isolates agents sharing a harness. +The flow separates occasional provider enrollment and run admission from independently checked calls in an admitted run. It uses an OAuth-protected upstream requiring its native provider credential, rather than a backend that directly accepts the delegated token. Operation and scope names are illustrative, not wire names. -## 5. Interactive execution flow +The broker starts as a **modulith**: a constrained interface for issuing agent-instance JWT-SVIDs, ToolHive authserver and vMCP contracts, and credential-custody interfaces sit behind Mecatl-specific operations for run admission and exact-call dispatch. Those operations compose reusable interfaces while preserving authorization and custody checks; they expose neither raw signing nor credential retrieval. Mecatl-specific session, definition-source and engine adaptation stays at the integration edge. The modules are not separate security boundaries. -The flow separates provider setup from admission of each exact operation. It uses an OAuth-protected upstream requiring its native provider credential, rather than a backend that directly accepts the delegated token. Operation and scope names are illustrative, not wire names. +A broker attachment refers to broker-owned session and connection state under the authenticated workload; it is neither an agent instance nor a run grant. The preferred design holds admitted run context alongside it and checks both on authenticated calls. Existing reference machinery may be reused, but no API, persistence format or signed run assertion is selected. -The exchange reuses the broker's existing ToolHive authorization-code access token as RFC 8693 `subject_token`, the logical-agent credential as `actor_token`, and **separate broker OAuth client authentication**. The subject token is the ToolHive access token the broker already obtained through the user’s browser authorization flow. ToolHive validates it during exchange to establish the user identity. This reuses the broker’s existing token storage and refresh mechanism rather than introducing another credential to represent the user. +Before invoking a connected provider account, the broker must establish that Alice may use it. Her Mecatl and GitHub identifiers need not match; the browser flow's state/PKCE and account-continuity checks do not establish that initial permission. How to authorize the caller to use the connected ToolHive user and provider account remains open, including separately governed account sharing. -The broker must establish which ToolHive user and connected provider account Alice is allowed to use. Her Mecatl login and GitHub account can have different identifiers; matching names or identifiers alone do not establish that they belong together. The browser authorization flow protects the sign-in transaction, but does not by itself prove that the account connected in the browser belongs to the user making the Mecatl request. +Enrollment uses two OAuth clients and two authorization codes. ToolHive redeems the provider code and stores the native grant; the broker separately redeems a ToolHive AS code with its confidential-client authentication and PKCE. The broker consumes its callback state, while ToolHive AS checks the registered client, PKCE and eligible grant. Instances need no separate OAuth client registration. -Each interactive operation still requires Alice to be authenticated to Mecatl, the broker to check Mecatl’s current authentication report for that request, and policy to permit the operation. Being able to refresh a stored token keeps the connection usable; it does not authorize the next action. +The broker retains the ToolHive user access/refresh grant. A continuity record can link to ToolHive's token session but is not the provider-token store. The inspected [B2 foundation](agent-authority-implementation.md#b2-enrollment-and-account-continuity-to-reuse) has these OAuth/custody boundaries; the instance actor exchange, caller/account permission and admitted run remain intended integrations. ### Execution sequence -The sequence shows one call within a run. Agent issuance and delegated-token exchange occur only when a suitable valid credential is unavailable; the run and exact-call checks apply to every invocation. +The sequence shows Alice starting reviewer run `R42` and one PR 42 read. The run label is illustrative, not a specified broker-reference format. Agent credential issuance, enrollment and exchange occur only when needed; the operation check applies on every invocation. ```mermaid sequenceDiagram @@ -178,87 +147,102 @@ sequenceDiagram participant H as Mecatl trusted API and dispatch box Combined broker participant I as Logical-agent issuer - participant B as Identity verification and call admission - participant A as Embedded ToolHive AS - participant V as Protected vMCP / custody + participant B as Run and exact-call admission / broker OAuth client + participant A as ToolHive AS / upstream OAuth client + participant C as ToolHive upstream token storage / reader + participant V as Protected vMCP end - participant P as Upstream provider - - Note over U,P: Establish user identity and provider connection - U->>H: Current login access token - H->>H: Verify authentication and persist owner, not bearer - H->>B: Workload authentication + fresh bound user evidence - B->>B: Verify trusted attester, freshness and owner match - opt Provider connection required - U->>A: Browser enrollment, state/PKCE and registered callback - A->>P: Authorize provider connection and redeem code - P-->>V: Provider access/refresh credentials - A-->>B: ToolHive auth-code access/refresh tokens + participant P as GitHub provider + + Note over U,P: Admit Alice's reviewer run R42 + U->>H: Start R42: read acme/payments PR 42, login bearer + H->>H: Verify login and owner, then resolve reviewer instance and authority + opt No suitable valid instance JWT-SVID + H->>I: Workload authentication + instance, definition, ancestry, issuance limits + I-->>H: Broker-issued, short-lived instance JWT-SVID + H->>H: Verify issuer, audience, expiry, instance and restrictions end - B->>B: Require authorized Mecatl / canonical user / connection association - - Note over U,P: Authorize and execute one exact operation - U->>H: Read PR 42 as code-reviewer, with current authentication - H->>H: Resolve agent and narrowed authority, then apply permissions/hooks - opt No suitable valid agent credential - H->>I: Workload authentication + resolved agent identity and issuance restrictions - I-->>H: Short-lived reusable logical-agent credential + H->>B: Authenticated workload + attested initiating user + attachment + instance SVID + R42 + B->>B: Verify owner, presenter, run limits and connection if present + B-->>H: Admitted run binding for R42 (format to be designed) + + opt Provider connection needed + B-->>H: R42 waits for authorized connection + H-->>U: Broker enrollment link + U->>A: Browser authorize broker OAuth client (broker state/PKCE) + A-->>U: Redirect to provider authorization (ToolHive client) + U->>P: Approve provider connection + P-->>U: Provider redirect with upstream code + U->>A: ToolHive upstream callback with provider state and code + A->>A: Verify upstream OAuth transaction + A->>P: Redeem provider code as ToolHive upstream client + P-->>A: Native provider access/refresh tokens + A->>C: Store native grant in upstream-token storage + A-->>U: ToolHive authorization code redirect + U->>B: Broker callback with ToolHive code and broker state + B->>B: Claim one-time state and retrieve retained PKCE verifier + B->>A: Redeem ToolHive code with client authentication + verifier + A->>A: Validate broker client and PKCE challenge + A-->>B: ToolHive user access/refresh grant into broker logical-session custody + B->>B: Verify caller/account association and R42 before continuing end - H->>H: Verify agent credential and its restrictions for this execution - H->>B: Workload + agent credential + run binding + exact call + fresh user evidence - B->>B: Verify identities, active run and exact call, then apply Cedar - Note over B,V: Denial stops before invocation credential selection or dispatch - opt No suitable valid delegated token for this run and resource - B->>B: Obtain current ToolHive subject token from guarded custody/refresh - B->>A: subject_token + actor_token + broker client auth + eligible resource/scopes - A->>A: Validate subject, distinct actor/presenter and authorized association - A->>A: Bound access/lifetime and resolve authorized local credential link - A-->>B: Delegated token: sub=user, act.sub=agent, client_id=broker client - B->>B: Bind delegated token to the authorized run + + Note over U,P: Independently admit one exact operation within R42 + H->>H: Apply local permissions/hooks to registered PR 42 read + H->>B: Workload + instance SVID + R42 binding + call ID/occurrence + PR 42 arguments + B->>B: Verify active R42, instance, target, arguments and policy + alt Denied + B-->>H: Denial with no invocation credential or dispatch + H-->>U: Refusal + else Admitted exact call + opt No suitable valid delegated token for R42 and resource + B->>B: Obtain current ToolHive user subject_token from custody/refresh + B->>A: subject_token + instance actor_token + broker OAuth client authentication + A->>A: Validate user, actor issuer/audience, presenter and bounded access + A-->>B: Delegated token: sub=user, act.sub=instance, client_id=broker client + B->>B: Bind delegated-token use to R42 + end + B->>V: Delegated token + independently admitted PR 42 read + V->>V: Validate token and operation policy, then resolve authorized connection + V->>C: Resolve valid upstream grant for authorized token session and target + C-->>V: Native provider credential via ToolHive token reader + V->>P: Read PR 42 with upstream-injected provider credential + P-->>V: PR 42 result + V-->>B: Result + B-->>H: Sanitized result and observed outcome + H-->>U: Answer without credentials in model history end - B->>V: Delegated token + independently admitted exact operation - V->>V: Validate token and operation policy, then load authorized connection - V->>P: Exact operation with native provider access credential - P-->>V: Operation result - V-->>B: Result - B-->>H: Sanitized result - H-->>U: Answer without credentials in model history + Note over H,B: Another R42 call repeats exact-call admission. Login expiry alone does not end R42 ``` -Before checking the operation, the broker resolves the session’s attachment and verifies that it belongs to the authenticated user and Mecatl workload. The attachment binding also identifies the broker incarnation, so a reference from an earlier broker instance cannot silently select new state. Exact-call admission is tied to this attachment. - -Before allowing a call, the broker checks that the verified user matches the session owner, validates the workload and logical-agent credentials, and confirms that the workload is allowed to present that agent’s credential. It checks the requested tool against the credential’s allowed tools, then evaluates the registered target, resource, scopes or authorization details, and exact arguments. +The sequence shows one run. For a child run, authenticated Mecatl reports the child instance and parent/root-run links without resending Alice's login bearer. The broker verifies those links and checks the active parent's initiating user and applicable authority against the child's definition ceiling before admitting the child's own run. -Admission applies only to that specific call. It is bound to the broker attachment and incarnation, call ID, argument digest, invocation occurrence and expiry. The broker checks those bindings, validates the registered target and applies Cedar policy before dispatch. +For each call, the broker authenticates the workload and validates the instance JWT-SVID's signature, issuer, audience, expiry and permitted presenter. It checks that the attachment matches the admitted user, authenticated workload and current Mecatl session incarnation, and that the SVID names that incarnation. The attachment's separate broker incarnation prevents an old reference from selecting new broker state. Neither an attachment nor a run reference grants authority by possession. -If the call is paused while the user connects a provider account, the broker repeats admission checks after the browser callback. Permission to connect the account does not replace permission to perform the operation. +The broker resolves the issuer-verified definition source before selecting policy; missing or stale records fail closed. It checks the caller's connection permission and applies the definition ceiling and active run's limits to the registered target, resource, scopes or authorization details, and exact arguments. Admission binds one occurrence to the attachment, broker incarnation, run, call ID, argument digest and expiry. A credential's tool list is only a restriction, not the whole policy; ToolHive's Cedar checks remain a reuse candidate, not a selected policy platform. -During token exchange, ToolHive authenticates the broker as an OAuth client. It separately checks the logical-agent credential’s issuer, signature, audience and expiry, and confirms that the broker is allowed to present it. A credential issued for admission at the broker is not automatically valid at ToolHive’s authorization server: its audience must explicitly cover that use. +After enrollment, continuation of the pending run rechecks the PR read before dispatch; connecting an account does not approve that operation. -ToolHive issues a delegated token identifying the user in `sub`, the logical agent in `act.sub` and the broker’s OAuth client in `client_id`. The token’s audience is the protected MCP endpoint that will receive it, not necessarily the upstream provider, such as GitHub. +At exchange, the broker uses its existing ToolHive authorization-code access token as RFC 8693 `subject_token`, the instance JWT-SVID as `actor_token`, and its own OAuth client authentication. ToolHive AS validates the user token, actor issuer, signature, audience, expiry and permitted presenter. A credential valid for broker admission needs an explicit ToolHive AS audience; no additional user-evidence credential is introduced. -These claims identify the parties and intended recipient; they do not specify or validate the exact tool arguments. The broker must still enforce its separate admission checks for the specific call. +ToolHive issues a delegated token with the user in `sub`, the acting instance in `act.sub`, the broker OAuth client in `client_id`, and the protected MCP endpoint as audience, not GitHub. These claims identify parties and recipient, not permission for particular tool arguments; the broker still admits the exact call. The protected resource validates issuer, audience, expiry and bounded access, applies operation policy and resolves only the authorized user/tenant/target's provider connection. `tsid` is a ToolHive-local credential link, outside SPIFFE's identity responsibility. It must come from validated subject and authoritative connection state, never an arbitrary external claim or caller-selected storage key. ## 6. Credential custody and safety boundaries -- **Human login bearers terminate at Mecatl's edge.** They are transient, never saved for downstream or scheduled use. Fresh harness-attested evidence is not the bearer, a stored owner, an attachment or an authorization grant. +- **Human login bearers terminate at Mecatl's edge.** They are transient, never saved for downstream or scheduled use. Harness-attested authentication at run admission is not the bearer, a stored owner, an attachment or an authorization grant for arbitrary operations. - **Agent credentials enter trusted Mecatl memory, not model content.** Client code verifies and attaches them without exposing them in arguments, results, conversation history, logs or environment variables. Model-hidden is not process-safe and does not guarantee secure memory erasure. - **Signing authority stays with the broker issuer.** Mecatl receives signed credentials and public verification keys, never the private signing key. Issuance and invocation may be modules in one process: separate boxes do not protect against broker, host or key-storage compromise. Key rotation and verifier cache expiry bound trust in old keys. - **Provider credentials stay in broker/ToolHive custody.** ToolHive retains, refreshes and injects native access credentials for the authorized connection; - the broker holds its own ToolHive access/refresh tokens. Results contain neither + the broker holds its own ToolHive access/refresh tokens. Neither Mecatl nor + the model receives the native provider grant. Results contain neither secrets nor reusable signed requests. This does not relocate all harness credentials, including LLM-provider credentials. The broker runs no model-driven agent loop or general-purpose shell. -- **Refresh is not fresh human authentication.** A provider connection can outlive - a login token without granting the next operation. Credential recovery is not - canonical-user identity continuity. After restart, revalidate connection state, - reacquire transient credentials and require fresh evidence; cached tokens and - persisted ownership are not authority. Self-contained bearers retain a residual - lifetime unless execution performs live checks or introspection. +- **Refresh is not a new run grant.** A provider connection can outlive a login token without authorizing another request. Credential recovery alone does not establish canonical-user identity or the authorized caller/connection association. After restart, revalidate connection continuity and durable unfinished-run binding before continuing an admitted run; missing or indeterminate bindings fail closed. An ended run needs new admission. Expiry of the original login bearer alone does not end unfinished, recoverable work. Self-contained bearers retain a residual lifetime unless recipients check live run status or introspect. - **Targets and credentials are server-resolved.** Model-chosen URLs, headers, issuers, audiences, scopes or credential selectors cannot redirect authority. Missing, malformed, stale, revoked or indeterminate evidence or authorization @@ -267,10 +251,12 @@ applies operation policy and resolves only the authorized user/tenant/target's p operation; enrollment/callback traffic is accounted separately. - **Children only narrow authority.** A malicious PR comment cannot obtain merge authority by requesting `deployer` beneath a read-only reviewer. A separately - authorized top-level deployer may merge if every ceiling permits. A worktree or - signature over a model-supplied name is not enforcement. Execution isolation must - prevent alternate credential/network bypasses; scrubbed environments and local - workspaces are not OS confinement, and shell logs are not exhaustive effect records. + authorized top-level deployer may merge if every ceiling permits. A signature + over a model-supplied name is not enforcement. Native Kubernetes execution + separates Shell commands into executor Pods while the agent loop remains in + the harness. Its configured execution and network controls must prevent + protected-route bypasses; local worktrees and environment scrubbing alone do + not provide the same boundary. Shell logs are not exhaustive effect records. Unattended execution requires independent authority; restoring a schedule's owner or keeping provider offline credentials is insufficient. Interactive authentication @@ -280,7 +266,49 @@ A possibly dispatched operation with no recorded completion has an **unknown outcome**, not proof that nothing happened. Never retry it automatically. Credential recovery or a signed pre-dispatch record cannot establish whether a merge reached the provider. Provider idempotency/status evidence can support reconciliation, but -arbitrary effects have no general exactly-once guarantee. Broker records must retain -identity provenance, exact operation, decision, dispatch and observed outcome without -secrets, correlated with harness session/run/tool-call records; knowing those -identifiers grants no authority. +arbitrary effects have no general exactly-once guarantee. Broker audit inputs must join the initiating user and authorized provider connection to the authenticated workload, acting instance's readable category/name and issuer-verified definition-source association and selected revision, executing run and parent/root-run links, exact tool invocation, decision, dispatch and observed outcome, without credentials. Creation ancestry in the SPIFFE path is distinct from the run and call identifiers. Reuse ToolHive/broker audit facilities where applicable; field mapping, delivery and retention belong to that integration work, not a new audit format here. The record must survive broker restart and distinguish broker-verified checks, harness assertions and provider-reported results. Knowing a correlation identifier grants no authority. + +## 7. Future work required to establish the separation + +The architecture separates the harness from signing, credential custody, and +external authorization. The following work must establish those boundaries in +implementation; SPIFFE identities alone do not complete it. Detailed mechanisms +and qualification belong in the [implementation companion](agent-authority-implementation.md). + +1. **Qualify protected-route mediation in the execution environment.** Build on + the existing native Kubernetes execution boundary, rather than invent another + sandbox. Verify executor Pod mounts, ServiceAccount configuration, runtime + restrictions, and effective network policy in the selected deployment. Test + alternate MCP, native CLI, and direct provider routes for usable credentials + or privileged interfaces that could bypass broker checks. Record the different + guarantees of local execution; worktrees and environment scrubbing alone do + not provide the Kubernetes execution boundary. State which protected operations + the deployment actually mediates. This is integration and qualification work, + not a claim that Kubernetes execution is absent or that every cluster is safe. + +2. **Constrain issuance using trusted inputs.** Define how the issuer + verifies each resolved definition's admitted source, its instance and + creation ancestry, and which issuance limits a harness may request. The + broker must validate those bindings rather than sign arbitrary + harness-supplied names or ceilings. Qualify rejection of substituted + definitions and unauthorized parent/child associations. Admission of a + definition from a source must not automatically grant external access; + exact grant and policy mechanisms belong to the separate authorization design. + +3. **Bound shared-harness credential substitution.** Determine which instance + credentials and run references a compromised harness can obtain or reuse, and + qualify the limits enforced outside that process. Separate identifiers or keys + in one process do not independently identify the executing agent. Document the + residual substitution risk; if stronger per-agent guarantees are required, + design and qualify a stronger execution boundary rather than claiming that a + signed SVID supplies it. No per-agent workload topology is selected here. + +4. **Integrate authorized caller/account and executing-run associations.** Build + on the broker's enrollment and account-continuity machinery to establish which + initiating caller may use a connection for a particular agent instance's run. + Preserve and validate that association through credential renewal and supported + recovery, including parent/root execution links and run termination. Neither + successful enrollment nor possession of an SVID establishes that association + alone. The [B2 integration work](agent-authority-implementation.md#calleraccount-association-and-exchange-on-b2) + records the inspected foundation and remaining checks without selecting a new + user-evidence credential or account-linking platform. diff --git a/docs/architecture/domain-model.md b/docs/architecture/domain-model.md index 67b2bd0e2c..ab339ee8aa 100644 --- a/docs/architecture/domain-model.md +++ b/docs/architecture/domain-model.md @@ -22,6 +22,13 @@ letting a restarted process rebuild the same per-session engine. `SessionKind` ( scheduled, subagent, parallel branch, team member, or debug) and `SessionRelationship` record who produced the session; legacy data restores as `unknown`, which fails closed. +`SessionID` addresses the conversation; `session.IncarnationID` identifies this +lifetime of that ID. Restoration preserves the incarnation, while a newly created +session with a reused ID gets a new one. A host-supplied `RunID` identifies one +unit of work: fresh work gets a new ID, while approval or external-authorization +continuation retains the pending run's ID across restart. Engine callers may omit +`RunID`; it is a correlation label, not an authorization grant. + `Limits` and `Counters` drive the stop conditions; a zero limit disables that check. `StopReason` is the loop's pre-turn guard and mixes the recorded reason with a limit check, so persistence uses `RecordedStopReason` instead. diff --git a/docs/architecture/mecatl.modelith.md b/docs/architecture/mecatl.modelith.md index 52ec0b2159..bdbe43c360 100644 --- a/docs/architecture/mecatl.modelith.md +++ b/docs/architecture/mecatl.modelith.md @@ -270,7 +270,7 @@ A disposable harness instance — one running process — that loads `Sessions` **Relationships** -- `Run` — 1:n — referenced — executes +- `Run` — n:n — referenced — executes — A paused run can continue in another process with the same run identity. - `SessionLease` — 1:n — referenced — holds **Invariants** @@ -326,7 +326,7 @@ A session's evidence of the file versions it has read, stored independently of t ### `Run` -A single drive of a `Session` from a starting state to a terminal one — one invocation of the agent loop. A `Run` sequences `Turns`, emits a stream of `Events`, and ends with a stop reason (end-of-turn, budget, no-progress, cancelled, error). It is the execution, not the state: the `Session` it drives outlives it. +One unit of work driven through a `Session` by the agent loop. It sequences `Turns` and emits `Events`. It can pause awaiting approval or external authorization and continue with the same logical run identity, even if a new in-process loop handle drives the continuation. The `Session` outlives the `Run` and can host later runs. **Relationships** @@ -336,14 +336,14 @@ A single drive of a `Session` from a starting state to a terminal one — one in **Invariants** -- **run-one-terminal-stop** — A `Run` drives its `Session` to exactly one terminal state and reports exactly one stop reason. +- **run-one-terminal-stop** — A paused `Run` has no terminal stop. Its continuation retains the same run identity; on completion it reports one terminal stop reason. - **run-no-replay-after-first-chunk** — Once a `Turn` has streamed its first chunk, the `Run` never retries or replays that `Turn`. ### `Session` -The central aggregate and unit of work: a stateful conversation between a principal and a model, with an `Environment` binding, usage accounting, and limits. A `Session` is a state machine (idle, running, awaiting, completed, cancelled, failed) and is bound to one `Provider`. Its effective `Model` is fixed for each `Turn`; a `PermissionMode` change can re-resolve the model between turns within that provider. It survives process restarts when backed by a store. +The central aggregate: a stateful conversation between a principal and a model, with an `Environment` binding, usage accounting, and limits. A `Session` is a state machine (idle, running, awaiting, completed, cancelled, failed) and is bound to one `Provider`. Its effective `Model` is fixed for each `Turn`; a `PermissionMode` change can re-resolve the model between turns within that provider. It survives process restarts when backed by a store, preserving its incarnation identity across restoration. **Relationships** @@ -368,6 +368,8 @@ The central aggregate and unit of work: a stateful conversation between a princi - **session-recover-before-reuse** — A `Session` in a terminal state (completed, cancelled, or failed) is returned to idle before any new `Run` reuses it. +- **session-incarnation-persists** — A `Session` has one incarnation identity for the lifetime of its ID. Restoration preserves it; a new `Session` reusing the ID gets a new incarnation. + - **session-usage-survives-restart** — Cumulative token usage is preserved across reopen, interrupt, recover, and process restart. @@ -576,7 +578,7 @@ erDiagram PermissionMode ||--|| Session : "posture of" PermissionMode }o--|| Model : "selects via slot" PermissionRule }o--o{ ToolCall : "authorizes" - Process ||--o{ Run : "executes" + Process }o--o{ Run : "executes" Process ||--o{ SessionLease : "holds" Provider ||--o{ Model : "offers" ProviderDiscovery }o--|| Provider : "discovers metadata for" @@ -1005,7 +1007,7 @@ erDiagram - **run-no-replay-after-first-chunk** — Once a `Turn` has streamed its first chunk, the `Run` never retries or replays that `Turn`. -- **run-one-terminal-stop** — A `Run` drives its `Session` to exactly one terminal state and reports exactly one stop reason. +- **run-one-terminal-stop** — A paused `Run` has no terminal stop. Its continuation retains the same run identity; on completion it reports one terminal stop reason. ### A session resumes after a restart at a pending ask @@ -1015,12 +1017,14 @@ erDiagram **Steps** 1. A `Session` is parked on a `PermissionAsk` when the process dies; the `EventLog` holds every `Event` up to that point. -2. `Operator` restarts the harness; a new `Process` recovers the `Session` to idle before reuse, preserving cumulative usage. -3. `Client` approves the pending ask; the `Run` re-enters the loop at the ask and the pending `ToolCall` executes exactly once. +2. `Operator` restarts the harness; a new `Process` restores the awaiting `Session`, preserving its incarnation, run identity and cumulative usage. +3. `Client` approves the pending ask; the same logical `Run` continues at the ask and the pending `ToolCall` executes exactly once. **Invariants touched** -- **session-recover-before-reuse** — A `Session` in a terminal state (completed, cancelled, or failed) is returned to idle before any new `Run` reuses it. +- **session-incarnation-persists** — A `Session` has one incarnation identity for the lifetime of its ID. Restoration preserves it; a new `Session` reusing the ID gets a new incarnation. + +- **run-one-terminal-stop** — A paused `Run` has no terminal stop. Its continuation retains the same run identity; on completion it reports one terminal stop reason. - **event-log-records-terminal-tail** — The `EventLog` records every `Event` a `Run` emits, including the terminal result emitted after a `Client` disconnects. diff --git a/docs/architecture/mecatl.modelith.yaml b/docs/architecture/mecatl.modelith.yaml index 92f06ea13f..94e99c946e 100644 --- a/docs/architecture/mecatl.modelith.yaml +++ b/docs/architecture/mecatl.modelith.yaml @@ -27,13 +27,13 @@ glossary: entities: Session: definition: > - The central aggregate and unit of work: a stateful conversation between a - principal and a model, with an `Environment` binding, usage accounting, and - limits. A `Session` is a state machine (idle, running, awaiting, completed, + The central aggregate: a stateful conversation between a principal and a + model, with an `Environment` binding, usage accounting, and limits. A + `Session` is a state machine (idle, running, awaiting, completed, cancelled, failed) and is bound to one `Provider`. Its effective `Model` is fixed for each `Turn`; a `PermissionMode` change can re-resolve the model between turns within that provider. It survives process restarts when - backed by a store. + backed by a store, preserving its incarnation identity across restoration. relationships: - entity: Conversation cardinality: "1:1" @@ -110,6 +110,11 @@ entities: statement: > A `Session` in a terminal state (completed, cancelled, or failed) is returned to idle before any new `Run` reuses it. + - id: session-incarnation-persists + statement: > + A `Session` has one incarnation identity for the lifetime of its ID. + Restoration preserves it; a new `Session` reusing the ID gets a new + incarnation. - id: session-usage-survives-restart statement: > Cumulative token usage is preserved across reopen, interrupt, recover, @@ -157,11 +162,11 @@ entities: Run: definition: > - A single drive of a `Session` from a starting state to a terminal one — - one invocation of the agent loop. A `Run` sequences `Turns`, emits a stream - of `Events`, and ends with a stop reason (end-of-turn, budget, no-progress, - cancelled, error). It is the execution, not the state: the `Session` it - drives outlives it. + One unit of work driven through a `Session` by the agent loop. It sequences + `Turns` and emits `Events`. It can pause awaiting approval or external + authorization and continue with the same logical run identity, even if a + new in-process loop handle drives the continuation. The `Session` outlives + the `Run` and can host later runs. relationships: - entity: Session cardinality: "n:1" @@ -178,8 +183,8 @@ entities: invariants: - id: run-one-terminal-stop statement: > - A `Run` drives its `Session` to exactly one terminal state and reports - exactly one stop reason. + A paused `Run` has no terminal stop. Its continuation retains the same + run identity; on completion it reports one terminal stop reason. - id: run-no-replay-after-first-chunk statement: > Once a `Turn` has streamed its first chunk, the `Run` never retries or @@ -864,9 +869,10 @@ entities: `Session` from its snapshot plus the `EventLog`. relationships: - entity: Run - cardinality: "1:n" + cardinality: "n:n" ownership: referenced role: executes + note: A paused run can continue in another process with the same run identity. - entity: SessionLease cardinality: "1:n" ownership: referenced @@ -1110,10 +1116,10 @@ scenarios: actors: [Operator, Client] steps: - "A `Session` is parked on a `PermissionAsk` when the process dies; the `EventLog` holds every `Event` up to that point." - - "`Operator` restarts the harness; a new `Process` recovers the `Session` to idle before reuse, preserving cumulative usage." - - "`Client` approves the pending ask; the `Run` re-enters the loop at the ask and the pending `ToolCall` executes exactly once." + - "`Operator` restarts the harness; a new `Process` restores the awaiting `Session`, preserving its incarnation, run identity and cumulative usage." + - "`Client` approves the pending ask; the same logical `Run` continues at the ask and the pending `ToolCall` executes exactly once." invariants_touched: - [session-recover-before-reuse, event-log-records-terminal-tail, ask-suspends-run-until-resolved, session-usage-survives-restart, rehydrate-tool-exactly-once] + [session-incarnation-persists, run-one-terminal-stop, event-log-records-terminal-tail, ask-suspends-run-until-resolved, session-usage-survives-restart, rehydrate-tool-exactly-once] - name: A rehydrated session does not re-ask a previously granted tool actors: [Operator, Client] From a12a097d079b7b18abc95e430c0621a67bf3aea8 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 8 Oct 2026 20:02:32 +0200 Subject: [PATCH 04/50] docs: fix agent identity draft links after rebase Co-Authored-By: Mecatl --- docs/agent-authority-implementation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-authority-implementation.md b/docs/agent-authority-implementation.md index e63b691cae..c084f3d3df 100644 --- a/docs/agent-authority-implementation.md +++ b/docs/agent-authority-implementation.md @@ -523,8 +523,8 @@ not close consent or durable-audit gaps. ## Delivery references Issue numbers are planning references, not runtime evidence. For shipped behavior, -use [architecture.md](architecture.md); for alternatives, [agent-identity-model.md](agent-identity-model.md) -and [agent-identity-outbound.md](agent-identity-outbound.md). Delivery proofs belong +use [architecture.md](architecture.md); for alternatives, [agent-identity-model.md](drafts/agent-identity-model.md) +and [agent-identity-outbound.md](drafts/agent-identity-outbound.md). Delivery proofs belong in acceptance plans, decisions in ADRs and shipped/deferred status in [PRODUCTION-READINESS.md](design/PRODUCTION-READINESS.md). Historical handovers are conversation snapshots, not the evolving contract. From 2721b5857a04bb81649ae995a6df90513111840f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 9 Oct 2026 10:30:00 +0200 Subject: [PATCH 05/50] docs: introduce agent identity architecture overview Co-authored-by: Mecatl --- docs/drafts/agent-identity/README.md | 92 ++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/drafts/agent-identity/README.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md new file mode 100644 index 0000000000..4015362ea2 --- /dev/null +++ b/docs/drafts/agent-identity/README.md @@ -0,0 +1,92 @@ +# Agent identity and authority + +This document proposes how Mecatl identifies **who is acting** in a multi-user, multi-session deployment: a user starts an agent, the agent delegates to subagents, each delegation stays within its parent's authority, and the resulting actions leave a durable audit trail. The goal is to let an operator or downstream service trace an action back to the agent and user behind it, with authorization enforced outside the agent loop. + +**Workload identity alone cannot answer these questions.** It identifies the Mecatl service, but one service can host many users, agent definitions, and running instances. This architecture gives those agents identities of their own and connects them to the users they act for. It follows an agent from the trusted definition loaded at session creation, through its execution and delegation, to the tool calls made on its behalf, which may use a human user's credentials to access an external service. + +*This document describes the intended architecture, not current implementation status.* + +## Why this matters + +Suppose Alice asks her main agent to review PR 42. It delegates to `code-reviewer`, which calls an MCP tool using Alice's GitHub credentials. GitHub sees access under Alice's account. That alone does not tell an auditor which agent made the call, why it had access, or whether the action stayed within the delegated task. + +Four problems follow: + +- **An agent name is only a claim until its definition is established.** An audit record naming `code-reviewer` is useful only if we can establish which definition it refers to, where that definition came from, and what was loaded for the session. Those facts need a trusted source outside the agent loop. +- **The user's credentials hide the acting agent.** We need to preserve both identities: whose access was used and which agent exercised it. +- **Access to a user's credentials is not permission to exercise all of their authority.** A reviewer must remain limited to reviewing even if Alice's GitHub credential permits merging. The design needs to bring user permissions and agent permissions together, and delegation must preserve or narrow the resulting authority. +- **The harness cannot be its own independent witness.** Its execution log helps explain what it attempted, but evidence of an external action must come from the component that checked and dispatched it, together with the observed result. + +These guarantees also depend on the path an operation takes: a call that bypasses the authorization boundary escapes its checks and audit trail. + +The architecture must connect these facts. An auditor should be able to follow Alice's request through the delegation to the tool call, identify the authority under which it was permitted, and distinguish a successful operation from a denied call or an unknown outcome. + +## The identity model + +A definition describes a kind of agent, such as `code-reviewer`. Its source is part of that identity: a repository's definition and an administrator's definition with the same name are not interchangeable. + +An instance is one use of a definition in a session. Alice and Bob can use the same definition but have distinct instances. Restoration preserves an instance; a new session creates another. + +A run is one execution by an instance. Reviewing PR 42 and later PR 57 uses the same reviewer identity but separate authorization contexts. A delegated child has its own instance and run, linked to the creating instance and delegating run. + +The user is the person on whose behalf the agents act. The workload is the Mecatl service hosting them, potentially serving many users at once. + +## Establishing an agent's identity + +A trusted loader resolves definitions from administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. + +This requires separating the concerns currently combined in `AgentDef`: instructions and execution limits configure the harness, identity and provenance establish the agent, and permissions govern access. Changing a turn limit should not redefine identity; loading a definition should not grant access to Alice's accounts. + +## Where trust lives + +The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. + +The harness hosts the API and agent loop and authenticates the initiating user. Definition loading supplies the session's configuration and establishes the corresponding identity and provenance records. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. + +The broker's authorization role extends beyond MCP to filesystem operations, Shell, and agent spawning. Those operations can execute in different places; the broker governs access without becoming the filesystem or command executor. + +```mermaid +flowchart LR + U["Client"] -->|"Session creation and requests"| H["mecak8s"] + F["Agent-definition sources"] --> R["Definition loading (placement TBD)"] + R -->|"Session configuration"| H + subgraph B["Identity broker (combined deployment)"] + I["Identity issuance and authorization"] + T["ToolHive AS, credential storage, and vMCP"] + I <-->|"Authorized operations"| T + end + R -->|"Initialize session definitions"| I + H <-->|"User report, tool requests, and results"| I + T <-->|"Protected calls"| P["MCP backends / providers"] + I -->|"Decisions and observed outcomes"| A["Audit records"] +``` + +The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. + +Before an agent starts, definition loading supplies each component with what it needs. `mecak8s` receives the configuration for running the agent; the broker receives its identity and provenance and owns the authorization decisions. Where loading runs, and how it supplies both components, remain open. + +The broker contains the agent trust issuer alongside ToolHive's authorization server, credential storage, and vMCP. The issuer signs agent identity credentials; the ToolHive components handle access to downstream tools. The tools themselves execute the operations. + +The remaining trust question is how the broker knows that the reported user and agent definition are genuine. The agent loop must not be able to substitute either. Giving agents distinct credentials helps identify them, but does not isolate agents sharing one `mecak8s` process. + +## Following an action through the architecture + +Alice asks her main agent to review PR 42. Once the session's definitions are loaded, the main agent delegates to `code-reviewer`. The reviewer requests an MCP tool call to read the pull request. + +The broker checks the call using Alice's identity, the reviewer instance, and its delegation. If permitted, ToolHive dispatches it using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. + +GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. + +## Detailed chapters + +The proposed chapters develop each part of the architecture. Filenames below are planned pages, not links to unwritten documents. + +| Chapter | Focus | +| --- | --- | +| `audit.md` | Follow a delegated action through concrete token and audit-record examples, including what the evidence establishes. | +| `agent-definitions.md` | Load trusted, session-bound definitions and separate execution configuration, identity, and permissions. | +| `mcp-authorization.md` | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | +| `credentials-and-lifecycle.md` | Explain instance credentials, run bindings, downstream access, and continuity across pauses and restarts. | +| `all-tool-mediation.md` | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | + +Chapters can have implementation companions for integration details and evidence. Earlier material remains available in the [identity model](../agent-identity-model.md), [authority design](../../agent-authority.md), and [implementation notes](../../agent-authority-implementation.md). The old model's harness-owned signing and audit responsibilities are replaced here by the broker-centered architecture. From 7e7e42f674fc6a444b80e5cb3b6cf1f4e2ab5ee7 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sat, 10 Oct 2026 17:55:23 +0200 Subject: [PATCH 06/50] docs: consolidate agent identity design into focused chapters Preserve the reviewed broker-centered design and implementation notes, and mark the old identity model superseded with unresolved migration work explicit. Co-authored-by: Mecatl --- docs/drafts/agent-identity-model.md | 11 +- docs/drafts/agent-identity/README.md | 32 +-- .../agent-identity/agent-definitions.md | 49 ++++ docs/drafts/agent-identity/audit.md | 19 ++ .../credentials-and-lifecycle.md | 231 ++++++++++++++++++ docs/drafts/agent-identity/implementation.md | 49 ++++ .../agent-identity/mcp-authorization.md | 19 ++ 7 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 docs/drafts/agent-identity/agent-definitions.md create mode 100644 docs/drafts/agent-identity/audit.md create mode 100644 docs/drafts/agent-identity/credentials-and-lifecycle.md create mode 100644 docs/drafts/agent-identity/implementation.md create mode 100644 docs/drafts/agent-identity/mcp-authorization.md diff --git a/docs/drafts/agent-identity-model.md b/docs/drafts/agent-identity-model.md index 681ddc9dea..6de663d524 100644 --- a/docs/drafts/agent-identity-model.md +++ b/docs/drafts/agent-identity-model.md @@ -1,6 +1,15 @@ # Agent identity: mecatl as its own SPIFFE trust domain -Same tier as [`docs/scoped-resource-grants.md`](scoped-resource-grants.md). +**Status: superseded by [Agent identity and authority](agent-identity/README.md).** +The replacement chapters consolidate this model with the newer authority design +and the design-review decisions. This document remains as historical source +material; its harness-owned issuer, credential layout, and delivery phases are +not the selected architecture. + +Supersession does not mean every design question is resolved. The `may_act` +question remains pending, and the authority and implementation donors still need +a final completeness review. Follow the replacement chapters for the proposed +architecture and its explicit open questions. This doc proposes how mecatl identifies **who is acting** in a multi-user, multi-session, autoscaled deployment: a user spawns an agent, the agent spawns diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 4015362ea2..b2633e29e9 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -1,6 +1,6 @@ # Agent identity and authority -This document proposes how Mecatl identifies **who is acting** in a multi-user, multi-session deployment: a user starts an agent, the agent delegates to subagents, each delegation stays within its parent's authority, and the resulting actions leave a durable audit trail. The goal is to let an operator or downstream service trace an action back to the agent and user behind it, with authorization enforced outside the agent loop. +This document proposes how Mecatl identifies **who is acting** in a multi-user, multi-session deployment: a user starts an agent, the agent delegates to subagents, each delegation stays within its parent's authority, and the resulting actions leave a durable audit trail. The goal is to let an operator trace an action back to the agent and user behind it, with authorization enforced outside the agent loop. **Workload identity alone cannot answer these questions.** It identifies the Mecatl service, but one service can host many users, agent definitions, and running instances. This architecture gives those agents identities of their own and connects them to the users they act for. It follows an agent from the trusted definition loaded at session creation, through its execution and delegation, to the tool calls made on its behalf, which may use a human user's credentials to access an external service. @@ -33,29 +33,29 @@ The user is the person on whose behalf the agents act. The workload is the Mecat ## Establishing an agent's identity -A trusted loader resolves definitions from administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. +The [agent-definition loader](agent-definitions.md#loading-definitions-for-a-session) resolves definitions from configured sources, such as administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. -This requires separating the concerns currently combined in `AgentDef`: instructions and execution limits configure the harness, identity and provenance establish the agent, and permissions govern access. Changing a turn limit should not redefine identity; loading a definition should not grant access to Alice's accounts. +The existing `AgentDef` structure currently combines instructions, execution settings, and tool configuration. This design proposes a split that gives the harness the configuration needed to execute the agent and the broker the definition records and authority ceilings needed to authorize MCP calls. Source and revision records still need a representation. Changing a turn limit should not redefine the agent's identity; loading a definition should not grant access to a user's accounts. ## Where trust lives The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. -The harness hosts the API and agent loop and authenticates the initiating user. Definition loading supplies the session's configuration and establishes the corresponding identity and provenance records. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. +The harness hosts the API and agent loop and authenticates the initiating user. An **agent-definition loader** supplies harness execution configuration and broker definition records and authority ceilings. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. -The broker's authorization role extends beyond MCP to filesystem operations, Shell, and agent spawning. Those operations can execute in different places; the broker governs access without becoming the filesystem or command executor. +This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a future direction; their authorization and routing remain to be designed. ```mermaid flowchart LR U["Client"] -->|"Session creation and requests"| H["mecak8s"] - F["Agent-definition sources"] --> R["Definition loading (placement TBD)"] - R -->|"Session configuration"| H + F["Agent-definition sources"] --> R["Agent-definition loader (placement TBD)"] + R -->|"Execution configuration"| H subgraph B["Identity broker (combined deployment)"] I["Identity issuance and authorization"] T["ToolHive AS, credential storage, and vMCP"] I <-->|"Authorized operations"| T end - R -->|"Initialize session definitions"| I + R -->|"Definition records and authority ceilings"| I H <-->|"User report, tool requests, and results"| I T <-->|"Protected calls"| P["MCP backends / providers"] I -->|"Decisions and observed outcomes"| A["Audit records"] @@ -63,7 +63,7 @@ flowchart LR The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. -Before an agent starts, definition loading supplies each component with what it needs. `mecak8s` receives the configuration for running the agent; the broker receives its identity and provenance and owns the authorization decisions. Where loading runs, and how it supplies both components, remain open. +Before the agent loop starts, the agent-definition loader supplies `mecak8s` with execution configuration and initializes the corresponding definition records and authority ceilings in the broker. The loader is outside the untrusted agent loop. Its process placement and how the broker authenticates its input remain to be specified. The broker contains the agent trust issuer alongside ToolHive's authorization server, credential storage, and vMCP. The issuer signs agent identity credentials; the ToolHive components handle access to downstream tools. The tools themselves execute the operations. @@ -77,16 +77,18 @@ The broker checks the call using Alice's identity, the reviewer instance, and it GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. +See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed credentials sequence. + ## Detailed chapters -The proposed chapters develop each part of the architecture. Filenames below are planned pages, not links to unwritten documents. +The proposed chapters develop each part of the architecture. Linked chapters have been started; unlinked filenames are planned pages. | Chapter | Focus | | --- | --- | -| `audit.md` | Follow a delegated action through concrete token and audit-record examples, including what the evidence establishes. | -| `agent-definitions.md` | Load trusted, session-bound definitions and separate execution configuration, identity, and permissions. | -| `mcp-authorization.md` | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | -| `credentials-and-lifecycle.md` | Explain instance credentials, run bindings, downstream access, and continuity across pauses and restarts. | +| [Audit](audit.md) | Follow a delegated action through concrete token and audit-record examples, including what the evidence establishes. | +| [Agent definitions](agent-definitions.md) | Explain `AgentDef` and separate harness execution configuration from broker-enforced authority. | +| [MCP authorization](mcp-authorization.md) | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | +| [Credentials and lifecycle](credentials-and-lifecycle.md) | Explain instance credentials, run bindings, downstream access, and continuity across pauses and restarts. | | `all-tool-mediation.md` | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | -Chapters can have implementation companions for integration details and evidence. Earlier material remains available in the [identity model](../agent-identity-model.md), [authority design](../../agent-authority.md), and [implementation notes](../../agent-authority-implementation.md). The old model's harness-owned signing and audit responsibilities are replaced here by the broker-centered architecture. +The [implementation and verification companion](implementation.md) records integration requirements and checks. Earlier material remains available in the [identity model](../agent-identity-model.md), [authority design](../../agent-authority.md), and [implementation notes](../../agent-authority-implementation.md). The old model's harness-owned signing and audit responsibilities are replaced here by the broker-centered architecture. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md new file mode 100644 index 0000000000..30367494aa --- /dev/null +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -0,0 +1,49 @@ +# Agent definitions + +*This chapter describes the intended architecture. The `AgentDef` inventory below describes the existing code; the harness/broker split is proposed.* + +## Definitions and executing instances + +An agent definition describes how an agent should perform work. An executing instance uses that definition within a session and can perform multiple runs. Mecatl represents the definition with `AgentDef`, a value containing metadata and instructions, rather than an executing loop. + +## What AgentDef contains + +The existing [`AgentDef`](../../../engine/tool/agentsource.go) combines several concerns: + +| Concern | Fields | Current purpose | +| --- | --- | --- | +| Naming and routing | `Name`, `Description` | Identify a definition and help the model select it. | +| Instructions and context | `Body`, `Skills`, `Memory` | Supply instructions, preloaded skills, and a memory-tier selection. | +| Model selection | `Model`, `Provider` | Configure the specialist's model and provider. | +| Tool configuration | `Tools`, `DisallowedTools`, `MCPServers` | Filter the specialist's tool catalog and configure its MCP servers. | +| Execution controls | `PermissionMode`, `MaxTurns`, `MaxToolCalls`, `Hooks` | Supply a session permission-mode hint, execution budgets, and lifecycle hooks. | +| Presentation | `Color` | Supply a UI hint. | +| Source tier | `Origin` | Record the admission tier: `explicit`, `project`, `user`, or `driver`. | + +`Origin` is an observability label, not a source location or an authorization input. `AgentDef` has no definition-source identifier or revision field. + +Tool catalog filtering and authority enforcement are distinct in the existing code. Composition registers the tools selected for the specialist. It also derives a harness-side authority ceiling from resolved tools and MCP capabilities for managed, explicitly sourced definitions. Delegation intersects that ceiling with parent and candidate authority. The ceiling is derived separately, not stored as a field in `AgentDef`, and this path does not establish broker enforcement. + +## Harness configuration and broker authority + +The proposed split keeps execution configuration in the harness and supplies the broker with the authority ceiling needed for independent MCP authorization. Tool information serves both components for different reasons: the harness configures which tools are available, while the broker decides whether a requested MCP call is allowed. Turn and tool-call budgets remain harness concerns. + +The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the agent's available tools. The broker needs an independently established ceiling on permitted MCP operations. A tool appearing in the harness catalog is not sufficient authorization to call it, and a model-supplied tool list cannot establish the broker's ceiling. + +The concrete field mapping remains to be specified. The existing definition-source snapshot contract is not, by itself, broker initialization. + +## Loading definitions for a session + +The agent-definition loader resolves the definitions selected for a session from configured sources, such as administrator-managed files, repositories, or APIs. It supplies the harness with execution configuration and the broker with the corresponding definition records and authority ceilings. + +Broker initialization must complete before the agent loop starts. The selected definitions remain fixed for the session, so both components work from the same selection. + +The loader is outside the untrusted agent loop. Its process placement and how the broker authenticates its input remain to be specified. + +Restoring a session must preserve its selected definitions rather than reload whatever happens to be current at their sources. The original source association and revision must remain recoverable; restoration must not silently substitute another definition. + +Keeping a definition fixed does not freeze user permissions or broker policy. The broker evaluates subsequent MCP calls against the applicable authorization policy. + +## Source records and policy representation + +Definition-source and revision records, and the representation of broker policy, remain unresolved. Generating Cedar from definition tool lists was discussed as an option, not selected as the mechanism. The relationship between definition-derived ceilings and separately managed user and agent permissions also needs to be described without conflating them. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md new file mode 100644 index 0000000000..3008673a33 --- /dev/null +++ b/docs/drafts/agent-identity/audit.md @@ -0,0 +1,19 @@ +# Audit + +*This chapter describes the intended architecture, not current implementation status. The audit requirement applies to tool calls generally; the current design scope is broker-mediated MCP calls.* + +## Following a tool call and its result + +An audit record must connect the initiating user, authenticated workload, acting agent instance, definition source and revision, and the authorized provider connection used, where applicable, to the requested tool call and its result. It must preserve the run and delegation links needed to trace how that instance came to act. + +The broker records its authorization decision, whether it dispatched the operation, and the outcome it observed. Audit records must survive broker restart. The record distinguishes broker-verified facts from harness assertions and provider-reported results. A missing completion is an unknown outcome, not evidence that no action occurred. + +Where an external SaaS provider supports tracing or audit correlation, the integration must preserve the identifiers needed to connect the broker's record to the provider's operation or audit event. This allows an investigation to follow an action into the provider, or trace a provider event back to the initiating user and agent chain. + +End-to-end correlation depends on the provider's capabilities and configuration; the system cannot guarantee it for every external tool. When that evidence is unavailable, the audit trail must make clear where correlation ends. + +Credentials must not appear in audit records. + +## Additional audit hardening + +Protecting persisted audit evidence against unauthorized disclosure or modification is additional work beyond this identity and authorization design. That work must address access to the stored records and their integrity after collection; recording authenticated identities does not itself protect the audit store. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md new file mode 100644 index 0000000000..00fd215a7d --- /dev/null +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -0,0 +1,231 @@ +# Credentials and lifecycle + +*This chapter describes the intended architecture, not current implementation status.* + +An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution, while the broker authorizes MCP tool calls using the acting instance's identity and the applicable user, delegation, and run context. Renewing the instance credential does not itself extend or widen permission to call tools. + +## Credential roles + +| Credential | What it establishes | Where it is presented | +| --- | --- | --- | +| **Human login token** | The identity of the user making a request to Mecatl. | Human client → Mecatl's API. It is not forwarded as the downstream user token. | +| **Workload credential** | Which Mecatl service is contacting the broker, not which user or agent it represents. | Mecatl → broker. | +| **Agent-instance JWT-SVID** | Which agent instance is acting. It can span multiple runs. | Mecatl → broker; the broker also uses it as the actor token in the proposed ToolHive exchange. | +| **Broker OAuth client credential** | Which OAuth client is requesting access from ToolHive. | Broker → ToolHive authorization server. | +| **ToolHive user grant** | The user's authorization granted through ToolHive. Its access token supplies the user side of the exchange; its refresh token supports renewal. | Held by the broker; the access token is presented to ToolHive as `subject_token`. | +| **Delegated access token** | The user and acting agent together: `sub=user`, `act.sub=instance`, with the protected MCP endpoint as audience. | Broker → protected MCP endpoint. | +| **Native provider credential** | Access to the external provider under the connected account. | ToolHive → provider, such as GitHub. It stays outside Mecatl. | + +These are roles, not necessarily seven credentials sent on every call. Enrollment, issuance, and refresh happen when needed. + +Run IDs, session attachments, and broker context references are separate from credentials. Their precise purpose remains to be designed; they do not imply that the broker authorizes run execution. + +## Interactive execution + +Alice asks a reviewer to read PR 42 in `acme/payments`. The session's definitions have already been loaded into the harness and broker. Mecatl starts and manages run `R42`; the broker authorizes its tool calls. `R42` is an illustrative execution identifier, not an authorization credential. + +Instance credential issuance, provider enrollment, and token exchange occur only when needed. The broker checks every requested operation (see [delegated authority](mcp-authorization.md#delegated-authority) and [operations and resources](mcp-authorization.md#operations-and-resources)). + +```mermaid +sequenceDiagram + actor U as Alice / human client + participant H as Mecatl API and dispatch + box Combined broker + participant I as Agent-instance issuer + participant B as Tool authorization / broker OAuth client + participant A as ToolHive AS / upstream OAuth client + participant C as ToolHive upstream token storage / reader + participant V as Protected vMCP + end + participant P as GitHub provider + + Note over H,I: Session definitions initialized before the agent loop starts + U->>H: Read acme/payments PR 42, login bearer + H->>H: Validate login and session owner; start reviewer run R42 + + opt No suitable valid instance JWT-SVID + H->>I: Authenticated workload + instance credential request + I->>I: Check instance and issuance limits against trusted definition records + I-->>H: Short-lived instance JWT-SVID + H->>H: Validate credential and expected instance + end + + H->>B: Authenticated workload + reported initiating user + instance SVID + execution context + Note over H,B: User report is a trust assumption, not independent proof of login + + opt Provider connection needed + B-->>H: Provider enrollment required + H->>H: Pause pending work + H-->>U: Broker enrollment link + U->>A: Authorize broker OAuth client using broker state and PKCE + A-->>U: Redirect to provider authorization + U->>P: Approve provider connection + P-->>U: Redirect with provider authorization code + U->>A: ToolHive callback with provider state and code + A->>A: Verify upstream OAuth transaction + A->>P: Redeem provider code as ToolHive upstream client + P-->>A: Native provider access/refresh tokens + A->>C: Store native provider grant + A-->>U: Redirect with ToolHive authorization code + U->>B: Broker callback with ToolHive code and broker state + B->>B: Consume one-time state and retrieve retained PKCE verifier + B->>A: Redeem ToolHive code with client authentication and verifier + A->>A: Validate broker client and PKCE + A-->>B: ToolHive user access/refresh grant + B->>B: Establish authorized caller/account association + end + + Note over H,B: Authorize the pending operation, including after enrollment + H->>H: Apply local permissions and hooks + H->>B: Workload + instance SVID + execution context + call ID + PR 42 arguments + B->>B: Validate identity and context; check target, arguments and applicable policy + + alt Denied + B-->>H: Denial without backend dispatch + H-->>U: Refusal + else Allowed + opt No suitable valid delegated token + B->>B: Obtain current ToolHive user access token from custody or refresh + B->>A: User subject_token + instance actor_token + broker client authentication + A->>A: Validate user token, actor credential, presenter and bounded access + A-->>B: Delegated token with sub=user, act.sub=instance, client_id=broker + B->>B: Restrict token use to the applicable execution context + end + B->>V: Delegated token + authorized PR 42 read + V->>V: Validate token and operation policy; resolve authorized connection + V->>C: Resolve valid upstream grant for authorized connection and target + C-->>V: Native provider credential + V->>P: Read PR 42 with injected provider credential + P-->>V: PR 42 result + V-->>B: Result + B-->>H: Sanitized result and observed outcome + H-->>U: Answer without credentials in model history + end + + Note over H,B: Every subsequent tool call repeats authorization +``` + +- **User reporting:** the broker trusts the API component's report; protecting that report from substitution by the agent loop remains unresolved. +- **Account association:** completing OAuth enrollment does not itself establish that the initiating user may use the connected account. That authorization mechanism remains open. +- **Execution context:** the diagram does not select a binding format or registration API, and no broker response grants permission to execute a run. +- **Exchange audience:** the instance credential must be valid for ToolHive AS. The resulting delegated token targets the protected MCP endpoint, not GitHub. +- **Delegation and audit:** child calls carry their own instance and execution relationships. The broker records decisions, dispatch, and observed outcomes; provider-side correlation remains conditional on provider support. + +## User identity + +Mecatl identifies the authenticated user by the issuer and subject of the validated login token, represented by `session.Principal`. The user's association with the acting agent must remain traceable through delegation and audit. This design does not introduce a user SPIFFE path or a separate user SVID; the agent-instance SPIFFE ID identifies the agent, not the user. + +The Mecatl user identity and the ToolHive user subject are not necessarily the same identifier. Their association with the authorized provider connection must be established rather than inferred from matching names or subjects. + +The authority design proposes trusting Mecatl's authenticated API component to report the initiating user to the broker, rather than forwarding the original login token. The broker authenticates the reporting workload, but this does not independently prove the user's authentication. How the report is protected from substitution by the untrusted agent loop remains to be specified. Audit records must distinguish this reported identity from facts the broker verified itself. + +## Credential custody + +Human login tokens terminate at Mecatl's API. They are validated for incoming requests, not retained for downstream access or scheduled execution. + +Mecatl's client code holds and presents agent-instance credentials without exposing them to the model. They stay out of tool arguments, results, conversation history, logs, and environment variables. Keeping credentials hidden from the model does not protect them from a compromised harness process or isolate agents sharing that process. + +The broker's issuer holds the agent signing authority. Mecatl receives signed instance credentials and public verification keys, never the issuer's private signing key. + +The broker holds its ToolHive user access and refresh tokens. ToolHive holds, refreshes, and injects native provider credentials for the authorized connection. Neither Mecatl nor the model receives those native provider credentials. This boundary concerns protected tool access; it does not relocate other harness credentials, such as those used to call an LLM provider. + +Agent identity does not protect against compromise of the broker's signing authority or provider-credential storage. Separating issuance, authorization, and custody into modules does not create security boundaries against compromise of their shared process or host. + +## Credential lifetimes and renewal + +An agent instance keeps its identity across runs, while its short-lived instance JWT-SVID can expire and be renewed. Renewing that credential establishes continued use of the same identity; the broker still authorizes each MCP tool call. + +A single run may need several delegated access tokens, either for different protected MCP endpoints or because an earlier token expires. Each token has its own audience, expiry, and access limits. Obtaining or refreshing one does not widen permission to call tools. + +The user's login token, the agent-instance credential, and downstream access tokens have separate lifetimes. Expiry of the login token used to start work does not, by itself, end that work. A provider connection can also remain available after that login token expires. Neither continued connectivity nor successful token refresh authorizes a later tool call without the broker's policy checks. + +Ending a run in the harness does not automatically invalidate tokens already issued. A recipient that validates a self-contained bearer token without checking current authorization state may accept it until expiry. The mechanism for communicating cancellation or withdrawal to the broker and downstream recipients remains to be specified. + +## Pauses and restart + +An approval wait or provider-enrollment pause leaves the current run unfinished. Before dispatching the pending MCP operation, the broker checks it against the applicable authorization policy. Connecting a provider account does not approve the pending operation. + +After restart, recovering credentials is not enough to continue safely. The system must recover the unfinished execution context and establish the association between the user, acting agent, and provider connection. If that association cannot be established, the tool call must fail closed. The harness determines whether execution resumes; the broker authorizes subsequent MCP calls. + +Continuation must preserve the unfinished run's original authorization bounds. The broker checks subsequent MCP calls against those bounds and applicable current policy. Restrictions or withdrawal can constrain continuation; credential renewal or policy expansion must not automatically widen the recovered authority. + +An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an applicable idempotency mechanism. + +## Scheduled runs + +A scheduled invocation executes without a live interactive user. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. + +Human login tokens are not retained for later invocations. Provider credentials remain in broker/ToolHive custody. A saved owner or an available provider connection does not, by itself, authorize an MCP operation. + +The harness starts and manages scheduled runs. The broker authorizes their MCP tool calls. The mechanism for establishing unattended authority is a separate design concern. + +## Workload authentication + +Workload authentication identifies the Mecatl service contacting the broker. Agent-instance identity distinguishes the agents hosted by that service. The proposed deployment options separate these two roles: + +| Workload authentication to the broker | Agent-instance identity | +| --- | --- | +| Kubernetes ServiceAccount bearer over TLS | None: workload-only identification | +| Kubernetes ServiceAccount bearer over TLS | Broker-issued instance JWT-SVID | +| SPIRE workload JWT-SVID over TLS | Broker-issued instance JWT-SVID | +| SPIRE workload X.509-SVID over mTLS | Broker-issued instance JWT-SVID | + +These are alternatives, not a required implementation sequence. Broker-issued agent-instance JWT-SVIDs do not require SPIRE. + +A workload credential alone cannot distinguish `code-reviewer` from `deployer` when both run in the same Mecatl service. A self-declared agent name does not establish that distinction for broker policy. + +## Downstream token exchange + +To obtain delegated access to a protected MCP endpoint, the broker presents three distinct credentials to ToolHive's authorization server: its ToolHive user access token as `subject_token`, the agent-instance JWT-SVID as `actor_token`, and its own OAuth client authentication. + +The instance credential must be valid for presentation to ToolHive's authorization server. Acceptance by the broker alone does not establish that audience. + +ToolHive issues a delegated access token identifying the user in `sub`, the acting instance in `act.sub`, and the broker OAuth client in `client_id`. Its audience is the protected MCP endpoint, not the external provider such as GitHub. These identities do not by themselves authorize particular tool arguments; the broker still checks the requested MCP operation. + +The protected MCP endpoint validates the delegated token and applies operation policy. ToolHive resolves the authorized provider connection and uses its native credential for the external call. + +## Provider-connection selection + +ToolHive resolves the provider connection from validated identity and authoritative connection records. + +The model cannot select another user's credentials or redirect them by supplying a URL, header, audience, or credential selector. The broker and ToolHive resolve the target and credentials within the authorized connection. + +If the required identity, connection, or authorization evidence is missing, invalid, stale, or revoked, the call fails closed. It must not fall back to a service credential, another user, or a more privileged agent. A denied tool call dispatches no backend operation. + +## Instance identity and creation ancestry + +The instance JWT-SVID identifies the acting agent instance through its SPIFFE ID. The proposed identity path records the instance's definition category and name, its session incarnation, and the ancestry of instances that created it. + +Creation ancestry is distinct from execution delegation. The same instance can participate in multiple runs, so its SPIFFE ID alone does not identify which parent run delegated the current task. Run and call records supply that context. + +A definition category and name are not enough to establish provenance. Two repositories can both define `code-reviewer`. Before selecting definition-specific policy, the broker needs a verified association with the source that supplied the definition. + +### Example instance identities + +Alice's main instance: + +```text +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u +``` + +A reviewer instance created by that main: + +```text +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m +``` + +A new root instance after a fork or clear: + +```text +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_45bvtrrrpkekb6336hwe6bvape +``` + +The reviewer's ID extends its creating instance's path with a child segment. A fork or clear creates a new root instance rather than extending that path. Restoring an existing instance preserves its identity. + +These IDs describe instance identity and creation ancestry. The initiating user and the current run's delegation context are recorded separately. + +## Sender binding + +Workload-level DPoP is follow-on work to bind the broker-facing OAuth credential to a presenting worker's key and reduce replay of stolen access tokens. It does not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent identity and tool-call authorization remain separate checks. + +Key custody, renewal, restart behavior, and replay handling belong in the implementation design. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md new file mode 100644 index 0000000000..5426fad730 --- /dev/null +++ b/docs/drafts/agent-identity/implementation.md @@ -0,0 +1,49 @@ +# Implementation and verification + +This companion records implementation and verification requirements for the [agent identity and authority design](README.md). Requirements here are not claims about current implementation status or known vulnerabilities. + +## Session ownership and isolation + +Verify that session enumeration, session access, and event replay enforce the authenticated caller's ownership, including on shared storage. Include negative tests proving another user cannot obtain session titles, prompts, or archived conversation content. + +Verify that scheduled invocations preserve owner attribution without treating saved ownership as fresh user authentication or permission to use a provider connection. + +## SPIFFE identifier construction + +Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. + +Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs; a Mecatl cap and compatible name/depth bounds remain undecided. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. + +## Credential verification + +Operator configuration fixes accepted workload issuers, audiences and identities, issuance limits, and trusted public signing keys. Tokens must not select verification endpoints, CA trust, or server names. + +For each tool call, the broker authenticates the workload and validates the agent-instance JWT-SVID's signature, issuer, audience, expiry, and permitted presenter. It checks that the credential identifies the expected instance and session incarnation. Credential validation does not replace authorization of the requested operation. + +At token exchange, ToolHive's authorization server validates the user token and the actor credential's issuer, signature, audience, expiry, and permitted presenter. The instance JWT-SVID needs an explicit ToolHive-AS audience; validity for presentation to the broker alone is insufficient. + +The protected MCP endpoint validates the delegated token's issuer, audience, expiry, and bounded access, applies operation policy, and resolves only the authorized user/tenant/target's provider connection. + +Whether the two presentation points use one multi-audience instance credential or separately issued credentials remains to be specified. + +## Workload-specific verification + +### Kubernetes ServiceAccount bearer + +The broker validates the configured Kubernetes issuer, audience, and ServiceAccount allowlist. A stolen ServiceAccount token remains a bearer credential until expiry. + +### SPIRE workload JWT-SVID + +`mecak8s` obtains a platform JWT-SVID for the broker's audience through the SPIFFE Workload API and presents it on issuance and invocation requests. The broker checks signature, audience, expiry, and allowed workload SPIFFE ID. Platform credential renewal and trust-bundle refresh are separate from the broker issuer's key rotation; platform identity neither replaces agent-instance issuance nor creates federation. + +### SPIRE workload X.509-SVID + +`mecak8s` obtains an X.509-SVID and key through the Workload API and proves possession during mTLS to both broker endpoints. The broker checks platform trust and permitted workload IDs. A TLS-terminating ingress needs explicitly trusted identity propagation; a forwarded header alone is insufficient. Rotation, bundle refresh, and connection renewal must preserve mTLS checks without bearer fallback. + +Workload mTLS does not bind an OAuth access token to its certificate, prove user consent, or isolate agents sharing a harness process. + +## Parking and recovery: implementation status + +Protected MCP calls can park while awaiting provider reauthorization and resume with account, connection, catalogue, and permission checks. Offline fixtures cover credential and enrolled-connection recovery after host and broker replacement. + +Parked broker flows remain process-local. Resuming the same pending operation after broker replacement and renewing agent-instance JWT-SVIDs across runs remain unfinished integration work. The existing recovery path does not establish authenticated human ownership. Credential recovery does not recover external effects; unknown outcomes remain fenced against automatic replay. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md new file mode 100644 index 0000000000..2baf7200dc --- /dev/null +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -0,0 +1,19 @@ +# MCP authorization + +*This chapter describes the intended architecture, not current implementation status.* + +## Delegated authority + +A child's permitted MCP operations must stay within the authority delegated by its parent and the child's definition ceiling. Delegation can preserve or narrow that authority, not widen it. + +Delegation history records how the acting instance came to act. An ancestor's permissions do not give the current actor additional rights. The broker authorizes the requested operation against the applicable user permissions, delegated limits, and definition ceiling. + +An instance JWT-SVID identifies the agent; it does not select the policy representation or prove that a child policy is a valid subset. Policy storage, token-carried restrictions, and subset evaluation remain separate design choices. + +## Operations and resources + +Authorization must distinguish the requested operation from the resource it acts on. Permission to call a tool does not imply permission to use it against every resource or with arbitrary arguments. + +Agent identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. + +See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed issuance, enrollment, exchange, and dispatch sequence. From c6cf31e8819c0323624972785460d30eb27f21c7 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 12 Oct 2026 13:43:30 +0200 Subject: [PATCH 07/50] docs: checkpoint agent credential and audit walkthrough Co-authored-by: Mecatl --- docs/drafts/agent-identity/README.md | 4 +- .../agent-identity/agent-definitions.md | 10 + docs/drafts/agent-identity/audit.md | 12 +- .../credentials-and-lifecycle.md | 351 +++++++++++++++--- docs/drafts/agent-identity/implementation.md | 8 +- .../agent-identity/mcp-authorization.md | 8 + 6 files changed, 331 insertions(+), 62 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index b2633e29e9..160b8cf3c1 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -85,10 +85,10 @@ The proposed chapters develop each part of the architecture. Linked chapters hav | Chapter | Focus | | --- | --- | -| [Audit](audit.md) | Follow a delegated action through concrete token and audit-record examples, including what the evidence establishes. | +| [Audit](audit.md) | Define attribution, correlation, evidence, redaction, and durability requirements. | | [Agent definitions](agent-definitions.md) | Explain `AgentDef` and separate harness execution configuration from broker-enforced authority. | | [MCP authorization](mcp-authorization.md) | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | -| [Credentials and lifecycle](credentials-and-lifecycle.md) | Explain instance credentials, run bindings, downstream access, and continuity across pauses and restarts. | +| [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | | `all-tool-mediation.md` | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | The [implementation and verification companion](implementation.md) records integration requirements and checks. Earlier material remains available in the [identity model](../agent-identity-model.md), [authority design](../../agent-authority.md), and [implementation notes](../../agent-authority-implementation.md). The old model's harness-owned signing and audit responsibilities are replaced here by the broker-centered architecture. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 30367494aa..045cdb4824 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -44,6 +44,16 @@ Restoring a session must preserve its selected definitions rather than reload wh Keeping a definition fixed does not freeze user permissions or broker policy. The broker evaluates subsequent MCP calls against the applicable authorization policy. +## Registering instances + +The proposed trusted session/agent lifecycle responsibility is to register each particular instance with the broker using the loader's fixed session selection. The broker needs to establish the association between session incarnation, instance SPIFFE ID, definition source and revision, authority ceiling, and creating instance; this does not select a combined record schema. It checks that the authenticated workload is permitted to act for that session and instance before issuing an instance credential. A known definition or a loop-supplied name alone is insufficient. + +Creation ancestry describes which instance created another. Execution delegation separately links the current parent run, child run, and delegated MCP bounds; the same instance can participate in several runs. The harness manages instance creation and run execution. Registration supplies trusted identity records, not broker admission of runs or authorization to spawn. + +In this proposal, restoring an instance preserves its identity, fixed definition selection, and original authority bounds; a new incarnation must not inherit the old instance's authority merely by reusing a session name. This identity continuity is separate from the existing broker's attachment recovery. The [credential walkthrough](credentials-and-lifecycle.md#3-broker-session-and-instance-registration) distinguishes the stable broker session reference from a replaceable process-local attachment binding. On the cited branch, recovery remains within the exact authenticated workload and deployment partition: another Pod with the same workload identity is not a grant of access to a different workload. + +The loader and lifecycle component are outside the untrusted loop as responsibilities. Their authenticated registration channel, durable record representation, and protection from a compromised loop sharing their process still need implementation design. Their placement alone does not prove isolation. The API's user report remains a trusted assertion, separate from the broker's workload verification. + ## Source records and policy representation Definition-source and revision records, and the representation of broker policy, remain unresolved. Generating Cedar from definition tool lists was discussed as an option, not selected as the mechanism. The relationship between definition-derived ceilings and separately managed user and agent permissions also needs to be described without conflating them. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 3008673a33..830e466bed 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -4,15 +4,21 @@ ## Following a tool call and its result -An audit record must connect the initiating user, authenticated workload, acting agent instance, definition source and revision, and the authorized provider connection used, where applicable, to the requested tool call and its result. It must preserve the run and delegation links needed to trace how that instance came to act. +An audit record must connect the initiating user, authenticated workload, acting agent instance, definition source and revision, and the authorized provider connection used, where applicable, to the requested tool call and its result. It must preserve the session incarnation, run, creation ancestry, and separate execution-delegation links needed to trace how that instance came to act. Qualify subjects by their issuer or configured trust domain; a workload subject alone is not globally unique. -The broker records its authorization decision, whether it dispatched the operation, and the outcome it observed. Audit records must survive broker restart. The record distinguishes broker-verified facts from harness assertions and provider-reported results. A missing completion is an unknown outcome, not evidence that no action occurred. +Use a per-operation correlation identifier across decision, dispatch, and outcome records. This is an audit requirement, not an established cross-service join or authenticated propagation contract; those mechanisms remain unspecified. A token identifier such as `jti`, when present, is supplementary: multiple operations can reuse one token. Preserve the target, tool, safe operation/resource metadata, policy and delegation references, and authorized connection needed to explain the decision. + +Each observer must distinguish what it verified from what it received as a report. API authentication establishes the company user at the API; the broker verifies the workload but trusts the API's user report. ToolHive AS and vMCP record the identities and decisions they validated themselves. Propagated run context or provider responses do not become independently verified facts merely because they appear in an audit record. + +The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. Decision, dispatch, and observed-outcome evidence must be durable and survive broker restart. This requirement does not select a persistence ordering or cross-service delivery protocol. A missing completion after possible dispatch is an unknown outcome, not evidence that no action occurred. Observed success does not guarantee provider-side effects. Where an external SaaS provider supports tracing or audit correlation, the integration must preserve the identifiers needed to connect the broker's record to the provider's operation or audit event. This allows an investigation to follow an action into the provider, or trace a provider event back to the initiating user and agent chain. End-to-end correlation depends on the provider's capabilities and configuration; the system cannot guarantee it for every external tool. When that evidence is unavailable, the audit trail must make clear where correlation ends. -Credentials must not appear in audit records. +Credentials must not appear in audit records or logs: exclude serialized tokens, refresh tokens, private keys, client secrets, authorization headers, and OAuth transaction secrets. Record selected validated metadata and nonsecret record references instead. Redact sensitive tool arguments and results while retaining sufficient safe operation attribution; dumping decoded credential payloads is not an audit strategy. + +The [PR 42 credential walkthrough](credentials-and-lifecycle.md#interactive-execution) places credential examples, validation checks, and selected audit evidence alongside each producing step. ## Additional audit hardening diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 00fd215a7d..d475b85e2a 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -18,18 +18,26 @@ An agent instance can participate in multiple runs. Its instance credential iden These are roles, not necessarily seven credentials sent on every call. Enrollment, issuance, and refresh happen when needed. -Run IDs, session attachments, and broker context references are separate from credentials. Their precise purpose remains to be designed; they do not imply that the broker authorizes run execution. +Run IDs, session attachments, and broker session references are separate from credentials. A broker session reference grants neither run execution nor MCP access; step (3) distinguishes existing references from proposed registration responsibilities. ## Interactive execution -Alice asks a reviewer to read PR 42 in `acme/payments`. The session's definitions have already been loaded into the harness and broker. Mecatl starts and manages run `R42`; the broker authorizes its tool calls. `R42` is an illustrative execution identifier, not an authorization credential. +Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, owns harness session `S42`. She asks Mecatl to read PR 42 in `acme/payments`. Her main instance's run `R41` delegates this task through `D42` to reviewer run `R42`, which requests tool `pull_request_read` with repository `acme/payments` and number `42`. The main instance also created the reviewer, but that creation ancestry is distinct from this run's delegation. `reviewer` below always means the exact SPIFFE ID in step (4), under session incarnation `inc_cejrv7re4uy3ubxyzj2gfxfp2u`; `R42` identifies execution, not a credential. + +The walkthrough assumes the proposed trusted loader and lifecycle registration described in [agent definitions](agent-definitions.md#registering-instances): the loader fixes definitions, sources, revisions, and ceilings, and the lifecycle component registers instances. Mecatl manages execution; the broker authorizes tool calls. These proposed inputs are not all available through today's broker contract. + +Operation `op-pr42-001` follows this one PR read throughout. Times are Unix seconds around `1800000000`, matching the delegated token. Examples are fictional decoded payloads or credential references, never usable bearer values. Audit examples contain only selected metadata; they are not copies of credentials or implemented cross-service events. The common operation ID denotes desired correlation, not an established authenticated propagation mechanism. Audit placement does not specify durable-write ordering. + +For this example only, configured workload audience `https://broker.example.com` covers the combined broker's issuance and invocation endpoints. The instance credential has two explicit audiences: that broker and `https://toolhive.example.com/token`. This illustrates a multi-audience deployment so the same actor credential can reach both validators; it does not select the architecture over separately issued credentials. Each recipient requires its own configured audience and trust material, never endpoints chosen by token claims. Instance credential issuance, provider enrollment, and token exchange occur only when needed. The broker checks every requested operation (see [delegated authority](mcp-authorization.md#delegated-authority) and [operations and resources](mcp-authorization.md#operations-and-resources)). +The numbered markers identify the credential and authorization boundaries discussed below. They group related messages rather than number every network request. The participants are logical roles; grouping them does not establish process isolation. + ```mermaid sequenceDiagram actor U as Alice / human client - participant H as Mecatl API and dispatch + participant H as mecak8s (API / harness client) box Combined broker participant I as Agent-instance issuer participant B as Tool authorization / broker OAuth client @@ -39,77 +47,269 @@ sequenceDiagram end participant P as GitHub provider - Note over H,I: Session definitions initialized before the agent loop starts - U->>H: Read acme/payments PR 42, login bearer - H->>H: Validate login and session owner; start reviewer run R42 - - opt No suitable valid instance JWT-SVID - H->>I: Authenticated workload + instance credential request - I->>I: Check instance and issuance limits against trusted definition records - I-->>H: Short-lived instance JWT-SVID - H->>H: Validate credential and expected instance + Note over H,B: Before the loop starts, load this session's selected agent definitions
Execution configuration into mecak8s, definition records and authority ceilings into the broker + Note over U,H: (1) Company IdP access token obtained before this request
Login flow not shown + U->>H: Read acme/payments PR 42, API access token + H->>H: Validate token and session owner
Main R41 delegates via D42 to reviewer R42 + + Note over H,I: (3) Broker has trusted session, instance and definition records
Registration protocol and input protection remain unspecified + opt Reviewer has no suitable valid instance credential + H->>I: (2, 4) Request credential for reviewer instance
Present projected workload SA token over TLS + I->>I: Authenticate workload
Check it may represent this registered reviewer, its fixed definition and creation ancestry + I-->>H: Reviewer's own short-lived agent-instance JWT-SVID + H->>H: Validate bundle, subject, audience and expiry end - H->>B: Authenticated workload + reported initiating user + instance SVID + execution context - Note over H,B: User report is a trust assumption, not independent proof of login - - opt Provider connection needed - B-->>H: Provider enrollment required - H->>H: Pause pending work - H-->>U: Broker enrollment link - U->>A: Authorize broker OAuth client using broker state and PKCE - A-->>U: Redirect to provider authorization - U->>P: Approve provider connection - P-->>U: Redirect with provider authorization code - U->>A: ToolHive callback with provider state and code - A->>A: Verify upstream OAuth transaction - A->>P: Redeem provider code as ToolHive upstream client - P-->>A: Native provider access/refresh tokens - A->>C: Store native provider grant - A-->>U: Redirect with ToolHive authorization code - U->>B: Broker callback with ToolHive code and broker state - B->>B: Consume one-time state and retrieve retained PKCE verifier - B->>A: Redeem ToolHive code with client authentication and verifier - A->>A: Validate broker client and PKCE - A-->>B: ToolHive user access/refresh grant - B->>B: Establish authorized caller/account association - end - - Note over H,B: Authorize the pending operation, including after enrollment + H->>H: Reviewer requests pull_request_read for acme/payments PR 42 + loop Invocation attempts until result or terminal failure + Note over H,B: (6) Repeat all checks on every attempt, including after enrollment H->>H: Apply local permissions and hooks - H->>B: Workload + instance SVID + execution context + call ID + PR 42 arguments - B->>B: Validate identity and context; check target, arguments and applicable policy - - alt Denied - B-->>H: Denial without backend dispatch + H->>B: Request MCP tool invocation: pull_request_read
Workload SA token + reviewer JWT-SVID + execution context + call ID + PR 42 arguments + B->>B: Validate workload and reviewer credentials
Verify presenter and execution-context binding + B->>B: Evaluate MCP operation and connection access
Check target, arguments, user permissions and agent authority + Note over B: Missing enrollment may leave authorization incomplete
Enrollment never implies permission to dispatch + B->>B: For eligible requests, check connection readiness
Refresh in custody where possible, otherwise require enrollment + B->>B: Record denial, enrollment-required outcome, or final allow + + alt Invalid credentials or context, or policy denial + B-->>H: Rejection without backend dispatch H-->>U: Refusal - else Allowed + else Enrollment or user reauthorization required + B-->>H: Enrollment required + broker enrollment link
No backend dispatch + H-->>U: Pause pending operation and present enrollment link + U->>A: (5) Complete ToolHive and provider enrollment + A->>P: Upstream OAuth client authentication + P-->>A: Provider access token and optional refresh token + A->>C: Store provider credentials + B->>A: Broker OAuth client authentication + A-->>B: ToolHive user access token and refresh token + Note over H,B: Retry pending invocation through all checks above
User / subject / connection association mechanism remains unspecified + else Fully authorized and credentials ready opt No suitable valid delegated token B->>B: Obtain current ToolHive user access token from custody or refresh - B->>A: User subject_token + instance actor_token + broker client authentication + B->>A: (7) User subject_token + instance actor_token + broker client authentication A->>A: Validate user token, actor credential, presenter and bounded access - A-->>B: Delegated token with sub=user, act.sub=instance, client_id=broker + A-->>B: Proposed token profile: sub=ToolHive user, act.sub=instance, client_id=broker B->>B: Restrict token use to the applicable execution context end - B->>V: Delegated token + authorized PR 42 read - V->>V: Validate token and operation policy; resolve authorized connection + B->>V: (8) Dispatch authorized MCP tool call: pull_request_read
Delegated token + PR 42 arguments + V->>V: Validate token and operation policy, then resolve authorized connection V->>C: Resolve valid upstream grant for authorized connection and target C-->>V: Native provider credential V->>P: Read PR 42 with injected provider credential P-->>V: PR 42 result V-->>B: Result + B->>B: Record observed outcome and provider correlation if available B-->>H: Sanitized result and observed outcome H-->>U: Answer without credentials in model history end + end + Note over H,B: Decision, dispatch and outcome require durable evidence
Persistence ordering and cross-service propagation are unspecified Note over H,B: Every subsequent tool call repeats authorization ``` -- **User reporting:** the broker trusts the API component's report; protecting that report from substitution by the agent loop remains unresolved. -- **Account association:** completing OAuth enrollment does not itself establish that the initiating user may use the connected account. That authorization mechanism remains open. -- **Execution context:** the diagram does not select a binding format or registration API, and no broker response grants permission to execute a run. -- **Exchange audience:** the instance credential must be valid for ToolHive AS. The resulting delegated token targets the protected MCP endpoint, not GitHub. -- **Delegation and audit:** child calls carry their own instance and execution relationships. The broker records decisions, dispatch, and observed outcomes; provider-side correlation remains conditional on provider support. +### (1) Company API access token + +The company IdP issues an API access token to Alice's client, which presents it to Mecatl over TLS. The client holds it; it terminates at the API and is not retained for downstream access. This fictional JWT access-token payload is not an ID token. + +```json +{ + "iss": "https://login.example.com", + "sub": "alice-204", + "aud": "https://mecatl.example.com/api", + "iat": 1799999900, + "exp": 1800003500 +} +``` + +This example uses mecak8s with OIDC authentication and session ownership enforcement enabled. Alice submits her request to existing session `S42`, which belongs to her. mecak8s validates her access token and checks that its issuer and subject match the session's stored owner. If either check fails, it rejects the request. Otherwise, main run `R41` delegates the PR read through `D42`, and the harness starts reviewer run `R42`. + +**Illustrative API evidence:** mecak8s accepted request `req-pr42-001` from authenticated user `(https://login.example.com, alice-204)` for session `S42`. The user identity comes from the validated token; session ownership comes from server-side state. This records API authentication and session ownership only. The broker has not yet authorized an MCP call to read PR 42. + +### (2) Workload credential + +Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s presents it to the broker over TLS, keeping it outside model context. This fictional decoded payload identifies the calling service: + +```json +{ + "iss": "https://kubernetes.example.com", + "sub": "system:serviceaccount:mecatl:mecak8s", + "aud": ["https://broker.example.com"], + "iat": 1799999900, + "exp": 1800003500, + "kubernetes.io": { + "namespace": "mecatl", + "serviceaccount": {"name": "mecak8s", "uid": "sa-example-42"}, + "pod": {"name": "mecak8s-worker-7", "uid": "pod-example-7"} + } +} +``` + +The broker checks the signature using trusted cluster keys, the issuer, allowed signing algorithm, broker audience, and expiry. It then checks that the authenticated ServiceAccount is allowed to contact it. If either check fails, it rejects the request. + +**Illustrative broker evidence:** the caller authenticated as `(https://kubernetes.example.com, system:serviceaccount:mecatl:mecak8s)`. This identifies the mecak8s service, not Alice or the reviewer instance, and does not authorize the PR 42 call. + +[SPIRE alternatives](#workload-authentication) replace this workload credential without changing its role. + +### (3) Broker session and instance registration + +The broker session reference identifies broker-owned state; knowing it grants no access. The broker checks the calling service's authenticated identity and deployment. This associates the session with mecak8s, not with Alice, and is separate from harness session `S42`. See [pauses and restart](#pauses-and-restart) for attachment and recovery details. + +Under the proposed registration model, the trusted lifecycle component supplies the reviewer's instance and selected definition. Before issuing the credential in (4), the broker checks that this workload may represent that instance and that its definition, revision, and creation ancestry match trusted records. The registration protocol remains unspecified; it does not authorize spawning or run execution. + +Alice's identity is a separate API report, not established by the workload credential. Protecting registration and user reports from substitution by a compromised loop in the same process remains unresolved. The issuance example in (4) distinguishes these sources. + +### (4) Agent-instance JWT-SVID + +The broker's agent issuer signs the registered reviewer's JWT-SVID. The harness client holds it outside model history and presents it to the broker; the broker later presents it as the actor token to ToolHive AS. The issuer retains its private signing key. This decoded example deliberately uses only required JWT-SVID claims: + +```json +{ + "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", + "aud": ["https://broker.example.com", "https://toolhive.example.com/token"], + "exp": 1800000600 +} +``` + +Before issuance, the issuer checks the workload and instance against the trusted registration in step (3); a definition's existence alone is insufficient. The harness checks that the returned subject is the registered reviewer, then validates its signature, allowed algorithm, expiry, and intended audiences against the configured agent trust-domain bundle. The broker and ToolHive AS each require their own audience and a permitted presenter. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. + +**Illustrative issuance evidence for (3) and (4):** only if the proposed loader and lifecycle registration supply the definition provenance and instance association, and the API supplies its user report, could the broker correlate the issued subject with the following facts. Today's broker contract does not supply these registration/user proofs, and `AgentDef` has no source or revision fields today. This is conditional audit evidence, not implemented behavior, additional JWT claims, or a registration API; protection against same-process substitution remains unresolved. + +```json +{ + "issuance_ref": "issue-42", + "definition": { + "name": "project/code-reviewer", + "revision": "review-def-r17", + "source": "repo://acme/payments/.mecatl/agents/code-reviewer.md", + "evidence_source": "trusted lifecycle registration" + }, + "initiating_user": { + "iss": "https://login.example.com", + "sub": "alice-204", + "evidence_source": "API report", + "request_ref": "req-pr42-001" + } +} +``` + +The JWT's `sub` identifies the reviewer. It does not carry Alice's identity or the definition revision; the correlated evidence records those separately and preserves their different sources. + +See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. + +### (5) Enrollment and authorized connection + +For this same PR read, `th-user-731` is the ToolHive-side subject associated with Alice **in this example**, not her company-login subject. The example assumes Alice is authorized to use that subject and provider account `github-account-731` through connection `github-conn-42` for `acme/payments`. The association mechanism remains open: referring to the same person, matching names or ID suffixes, and successful OAuth do not prove authorization. `github-account-731` is a distinct provider account ID; its shared suffix with the ToolHive subject has no evidentiary meaning. Model arguments cannot select another user's connection. + +If enrollment is needed, Alice completes it before the pending call is reauthorized. The broker holds the ToolHive user grant and uses its access token as `subject_token`; ToolHive holds the native provider grant for this connection. Neither grant reaches the harness or model. Client authentication and credential placeholders are shown once under [credential custody](#credential-custody); see also [provider-connection selection](#provider-connection-selection). + +**Illustrative enrollment evidence:** ToolHive records verified client `mecatl-broker`, authenticated subject `th-user-731`, enrollment `enroll-42`, connection `github-conn-42`, and the provider-reported account. The broker records connection readiness with Alice still API-reported. Records exclude tokens, client authentication material, codes, and transaction secrets. + +Refresh behavior belongs in [credential lifetimes and renewal](#credential-lifetimes-and-renewal). + +### (6) Per-operation authorization + +Local permissions and hooks run first. Trusted harness client code then presents the workload and reviewer credentials with operation `op-pr42-001`, run `R42`, delegation `D42`, and this illustrative request: + +```json +{ + "tool": "pull_request_read", + "arguments": {"repository": "acme/payments", "number": 42} +} +``` + +The broker validates both credentials and requires trusted attribution of the actor, initiating user, run, and delegation; the context-binding mechanism remains unresolved. It checks authorization to use the ToolHive subject and resolved provider connection, then evaluates the complete final request against current user permissions, the fixed definition ceiling, original delegated bounds, and current policy. A transformed request needs another check. A denial dispatches nothing; an allow authorizes this MCP call, not a run or spawn. + +**Illustrative broker evidence:** `op-pr42-001` was allowed under `payments-read-policy-r9`, with the request above, `S42`, `R42`, `D42`, verified workload and reviewer, API-reported Alice, revision `review-def-r17`, delegation/ceiling references, and authorized connection `github-conn-42`. A denial records its reason instead; neither decision proves dispatch or success. + +Recovered authority remains subject to the original bounds described in [pauses and restart](#pauses-and-restart). + +### (7) Token exchange and delegated MCP access token + +At marker (7), ToolHive exchanges the user access token and agent-instance JWT-SVID for the access token that the broker presents to vMCP. This example proposes the [RFC 9068 JWT access-token profile](https://www.rfc-editor.org/rfc/rfc9068.html#section-2) with [RFC 8693 delegation claims](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). It is a proposed contract, not a claim about existing ToolHive behavior. The exchange output is an OAuth access token, not a JWT-SVID. + +Before reading the token: `sub=th-user-731` names Alice's assumed authorized subject in ToolHive's namespace, not `(https://login.example.com, alice-204)` from her company login. `act.sub` names the same reviewer executing `R42`; `client_id=mecatl-broker` names the broker requesting the token, not another agent or user. The token is for the same PR read at `https://mcp.example.com/payments/mcp` through `github-conn-42`; it does not itself establish that connection association. + +The decoded header and payload below use fictional values. A real token is signed; its signature and serialized bearer value are omitted here. + +**Header:** + +```json +{ + "typ": "at+jwt", + "alg": "RS256", + "kid": "toolhive-example-key-1" +} +``` + +**Payload:** + +```json +{ + "iss": "https://toolhive.example.com", + "sub": "th-user-731", + "act": { + "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m" + }, + "client_id": "mecatl-broker", + "aud": "https://mcp.example.com/payments/mcp", + "iat": 1800000000, + "exp": 1800000300, + "jti": "d680052b-7081-41aa-b777-147239446ab3" +} +``` + +ToolHive AS validates the user access token, the instance SVID for its own audience, and the broker's client authentication. It checks that the broker may present this actor and that the requested MCP target and access bounds are authorized before issuing the token. + +The audience is vMCP, not GitHub. The token lasts five minutes. No scope was requested in this example; operation policy still bounds access, so omitting `scope` does not grant unrestricted access. + +**Illustrative AS evidence:** exchange `exchange-42` records the verified user, actor, client, target, and issued token's `(iss, jti)`. Caller-supplied run context remains reported. The broker holds the bearer outside the harness and limits reuse to the applicable execution context. + +See [downstream token exchange](#downstream-token-exchange) for token correlation and actor-history limits, and [sender binding](#sender-binding) for replay protection beyond this bearer example. + +### (8) Dispatch and observed outcome + +The broker presents the delegated token from step (7) to vMCP with the PR 42 request. vMCP checks the expected token type, trusted issuer, allowed algorithm, signature against configured issuer keys, audience, and expiry before relying on its claims. It then checks the operation and arguments against policy and verifies authorization to use `github-conn-42`. + +ToolHive supplies the provider credential from custody for the authorized target. GitHub validates that native credential, not the SVID or delegated MCP token. + +**Illustrative vMCP evidence:** + +```json +{ + "event": "vmcp.authorization", + "time": 1800000002, + "operation_id": "op-pr42-001", + "provider_connection": "github-conn-42", + "token_ref": { + "iss": "https://toolhive.example.com", + "jti": "d680052b-7081-41aa-b777-147239446ab3" + }, + "validated_identity": { + "subject": "th-user-731", + "actor": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", + "oauth_client": "mecatl-broker" + }, + "request": { + "tool": "pull_request_read", + "arguments": { + "repository": "acme/payments", + "number": 42 + } + }, + "authorization": { + "decision": "allow", + "source": "vmcp-policy-evaluation" + } +} +``` + +This illustrative record captures validated claims and vMCP's policy decision, not a bearer credential or proof of dispatch. + +**Illustrative outcome evidence:** the broker and vMCP record their dispatch attempts and observed responses for `op-pr42-001`. vMCP retains provider reference `provider-request-42` only if returned and safe to store. The broker records the MCP response it received; the API returns a sanitized answer without credentials in model history. A successful response describes what the observer received, not guaranteed provider-side effects or a complete provider audit trail. Missing completion leaves an unknown outcome; see [pauses and restart](#pauses-and-restart). + +The [general audit contract](audit.md) requires durable decision, dispatch, and outcome evidence. Persistence ordering and authenticated cross-service propagation remain unspecified; recipients must label propagated context as reported unless independently verified. ## User identity @@ -127,6 +327,30 @@ Mecatl's client code holds and presents agent-instance credentials without expos The broker's issuer holds the agent signing authority. Mecatl receives signed instance credentials and public verification keys, never the issuer's private signing key. +The broker authenticates as OAuth client `mecatl-broker` to ToolHive AS; ToolHive authenticates separately to the provider. Operators provision each client's authentication material and server-side registration. These references leave the authentication methods open; a client ID alone is not a credential. + +| OAuth client | Client ID | Authentication material held by that client | +| --- | --- | --- | +| Broker → ToolHive AS | `mecatl-broker` | `` | +| ToolHive → provider AS | `` | `` | + +Enrollment yields these opaque credential placeholders; their token formats are not selected: + +```json +{ + "toolhive": { + "access_token": "", + "refresh_token": "" + }, + "provider": { + "access_token": "", + "refresh_token": "" + } +} +``` + +This groups examples for reading, not storage or transport. Each AS validates its OAuth client's authentication against its registration. ToolHive AS validates the ToolHive user grant, subject, intended use, and validity; the provider validates its native access token. Enrollment requires OAuth transaction protections; callbacks and code redemption are omitted. Client credentials and grants stay outside the harness and model. + The broker holds its ToolHive user access and refresh tokens. ToolHive holds, refreshes, and injects native provider credentials for the authorized connection. Neither Mecatl nor the model receives those native provider credentials. This boundary concerns protected tool access; it does not relocate other harness credentials, such as those used to call an LLM provider. Agent identity does not protect against compromise of the broker's signing authority or provider-credential storage. Separating issuance, authorization, and custody into modules does not create security boundaries against compromise of their shared process or host. @@ -137,12 +361,18 @@ An agent instance keeps its identity across runs, while its short-lived instance A single run may need several delegated access tokens, either for different protected MCP endpoints or because an earlier token expires. Each token has its own audience, expiry, and access limits. Obtaining or refreshing one does not widen permission to call tools. +The broker presents its ToolHive refresh token to ToolHive AS with required client authentication to renew the user access token. ToolHive similarly presents an optional provider refresh token to the provider AS with required upstream client authentication. Each AS validates its refresh grant; custodians must safely store rotated replacements. Without a provider refresh token, renewal requires another supported path or enrollment. + The user's login token, the agent-instance credential, and downstream access tokens have separate lifetimes. Expiry of the login token used to start work does not, by itself, end that work. A provider connection can also remain available after that login token expires. Neither continued connectivity nor successful token refresh authorizes a later tool call without the broker's policy checks. Ending a run in the harness does not automatically invalidate tokens already issued. A recipient that validates a self-contained bearer token without checking current authorization state may accept it until expiry. The mechanism for communicating cancellation or withdrawal to the broker and downstream recipients remains to be specified. ## Pauses and restart +The broker's session reference and incarnation are separate from harness and agent incarnations. An attachment binding identifies the exact logical attachment incarnation; separate recovery references and continuity checks govern stored-credential recovery. An existing stable broker session can recover a new process-local attachment and binding through those checks. Expired custody withdraws connected authority without necessarily destroying the broker session. Those references do not combine user, agent, definition, and authority into one record. + +Workload-only session access checks the exact authenticated workload and configured deployment, rejecting caller-supplied owner assertions. A replacement Pod with the same workload identity can serve that partition; a different identity cannot assume access. + An approval wait or provider-enrollment pause leaves the current run unfinished. Before dispatching the pending MCP operation, the broker checks it against the applicable authorization policy. Connecting a provider account does not approve the pending operation. After restart, recovering credentials is not enough to continue safely. The system must recover the unfinished execution context and establish the association between the user, acting agent, and provider connection. If that association cannot be established, the tool call must fail closed. The harness determines whether execution resumes; the broker authorizes subsequent MCP calls. @@ -172,6 +402,13 @@ Workload authentication identifies the Mecatl service contacting the broker. Age These are alternatives, not a required implementation sequence. Broker-issued agent-instance JWT-SVIDs do not require SPIRE. +| SPIRE alternative | Credential example and custody | Broker validation | +| --- | --- | --- | +| Workload JWT-SVID | SPIRE supplies `{"sub":"spiffe://platform.example.com/ns/mecatl/sa/mecak8s","aud":["https://broker.example.com"],"exp":1800003500}` through the Workload API; the harness client holds and presents it. | Check signature against the configured platform JWT bundle, required claims, audience, expiry, and permitted SPIFFE ID. | +| Workload X.509-SVID / mTLS | SPIRE supplies a certificate with URI SAN `spiffe://platform.example.com/ns/mecatl/sa/mecak8s` and illustrative validity `1799999900..1800003500`. The client retains the private key and proves possession in TLS. | Check the chain against the configured platform trust bundle, validity, SPIFFE identity, and permitted workload. TLS termination elsewhere requires trusted identity propagation. | + +For SPIRE authentication, audit evidence identifies the configured trust domain/bundle association and verified SPIFFE ID. A stolen bearer remains usable until expiry or an effective revocation check; signature validation alone does not prove possession of a private key. + A workload credential alone cannot distinguish `code-reviewer` from `deployer` when both run in the same Mecatl service. A self-declared agent name does not establish that distinction for broker policy. ## Downstream token exchange @@ -182,7 +419,11 @@ The instance credential must be valid for presentation to ToolHive's authorizati ToolHive issues a delegated access token identifying the user in `sub`, the acting instance in `act.sub`, and the broker OAuth client in `client_id`. Its audience is the protected MCP endpoint, not the external provider such as GitHub. These identities do not by themselves authorize particular tool arguments; the broker still checks the requested MCP operation. -The protected MCP endpoint validates the delegated token and applies operation policy. ToolHive resolves the authorized provider connection and uses its native credential for the external call. +The protected MCP endpoint validates the delegated token and applies operation policy. ToolHive resolves the authorized provider connection and uses its native credential for the external call. Validated claims identify the user and current actor directly; permissions and account associations can still require authoritative records. + +The issuer-qualified `jti` supports token correlation but does not prevent replay or replace a per-operation identifier: several calls can reuse one token. The walkthrough's shared operation ID expresses desired correlation, not an established authenticated join between services. + +The token's single `act` object identifies the current actor. RFC 8693 nested `act` objects describe previous actors and are informational for access control. Creation ancestry and current execution delegation remain separate evidence, not actor history inferred from the SPIFFE path or copied into nested claims. ## Provider-connection selection @@ -226,6 +467,8 @@ These IDs describe instance identity and creation ancestry. The initiating user ## Sender binding +The walkthrough's delegated token is a bearer without sender binding. Its signature protects the claims from alteration but does not prove which holder presented it. + Workload-level DPoP is follow-on work to bind the broker-facing OAuth credential to a presenting worker's key and reduce replay of stolen access tokens. It does not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent identity and tool-call authorization remain separate checks. Key custody, renewal, restart behavior, and replay handling belong in the implementation design. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 5426fad730..96fffcc49b 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -18,13 +18,15 @@ Apply any issuance limit to the **whole URI**, including child hops, without tru Operator configuration fixes accepted workload issuers, audiences and identities, issuance limits, and trusted public signing keys. Tokens must not select verification endpoints, CA trust, or server names. -For each tool call, the broker authenticates the workload and validates the agent-instance JWT-SVID's signature, issuer, audience, expiry, and permitted presenter. It checks that the credential identifies the expected instance and session incarnation. Credential validation does not replace authorization of the requested operation. +For each tool call, the broker authenticates the workload and validates the agent-instance JWT-SVID's signature against the configured agent trust-domain bundle, required `sub`/`aud`/`exp` claims, expected audience, expiry, and permitted presenter. JWT-SVID does not require `iss` or `jti`. The broker checks that the credential identifies the expected registered instance and session incarnation. Credential validation does not replace authorization of the requested operation. -At token exchange, ToolHive's authorization server validates the user token and the actor credential's issuer, signature, audience, expiry, and permitted presenter. The instance JWT-SVID needs an explicit ToolHive-AS audience; validity for presentation to the broker alone is insufficient. +At token exchange, ToolHive's authorization server validates the user token and the actor credential's trusted bundle association, signature, required claims, audience, expiry, and permitted presenter. The instance JWT-SVID needs an explicit ToolHive-AS audience; validity for presentation to the broker alone is insufficient. The protected MCP endpoint validates the delegated token's issuer, audience, expiry, and bounded access, applies operation policy, and resolves only the authorized user/tenant/target's provider connection. -Whether the two presentation points use one multi-audience instance credential or separately issued credentials remains to be specified. +Whether the two presentation points use one multi-audience instance credential or separately issued credentials remains to be specified. The [walkthrough](credentials-and-lifecycle.md#interactive-execution) illustrates the former without selecting it as the deployment contract. + +Verify issuance rejects a known definition without a trusted particular-instance registration, an unauthorized workload, mismatched ancestry, or a stale session incarnation. These are proposed checks; the registration protocol and input protection remain unspecified. Verify restoration preserves the instance identity, fixed definition selection, and original bounds. Separately, the existing broker recovery path permits a new process-local attachment binding for a stable broker session within the exact authenticated workload and deployment partition; a different workload cannot assume access. Knowing a noncredential broker session reference must not suffice. Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. ## Workload-specific verification diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 2baf7200dc..a869848f70 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -16,4 +16,12 @@ Authorization must distinguish the requested operation from the resource it acts Agent identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. +## User reports and the authorized request + +Mecak8s reports the initiating user from the verified session owner. The broker authenticates the Mecak8s workload and relies on its user report under an explicit trust relationship; it does not independently authenticate the human from that report. Model-generated arguments must not choose or override the reported user. + +The broker authorizes the actual MCP server, tool, provider connection, and complete tool arguments that will be used. Permission to read `acme/payments` PR 42 does not grant permission to read a different repository or pull request merely because the tool name is unchanged. + +Request transformations must occur before the authorization check, or the changed request must be checked again. Dispatch must use the request and provider connection that were authorized. + See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed issuance, enrollment, exchange, and dispatch sequence. From 36a22d3e54e38c0bbc511c4e89034da5f7c291ee Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 12 Oct 2026 19:24:00 +0200 Subject: [PATCH 08/50] docs: define Cedar MCP ceilings and audit evidence Clarify definition and instance lifecycle, show main and reviewer tool calls, and scope Cedar v1 to definition-derived MCP ceilings. Expand audit requirements and add the future all-tool mediation chapter. Co-authored-by: Mecatl --- docs/drafts/agent-identity/README.md | 8 +- .../agent-identity/agent-definitions.md | 38 +++-- .../agent-identity/all-tool-mediation.md | 17 ++ docs/drafts/agent-identity/audit.md | 52 ++++-- .../credentials-and-lifecycle.md | 156 ++++++++---------- docs/drafts/agent-identity/implementation.md | 14 +- .../agent-identity/mcp-authorization.md | 61 ++++++- 7 files changed, 229 insertions(+), 117 deletions(-) create mode 100644 docs/drafts/agent-identity/all-tool-mediation.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 160b8cf3c1..26ae5ae3b1 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -43,7 +43,7 @@ The agent loop uses those definitions to perform work, but it cannot decide whic The harness hosts the API and agent loop and authenticates the initiating user. An **agent-definition loader** supplies harness execution configuration and broker definition records and authority ceilings. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. -This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a future direction; their authorization and routing remain to be designed. +This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. ```mermaid flowchart LR @@ -73,7 +73,7 @@ The remaining trust question is how the broker knows that the reported user and Alice asks her main agent to review PR 42. Once the session's definitions are loaded, the main agent delegates to `code-reviewer`. The reviewer requests an MCP tool call to read the pull request. -The broker checks the call using Alice's identity, the reviewer instance, and its delegation. If permitted, ToolHive dispatches it using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. +The broker checks the registered reviewer's definition-derived server/tool ceiling with Cedar and retains existing user/connection checks. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. @@ -81,7 +81,7 @@ See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) ## Detailed chapters -The proposed chapters develop each part of the architecture. Linked chapters have been started; unlinked filenames are planned pages. +The proposed chapters develop each part of the architecture. | Chapter | Focus | | --- | --- | @@ -89,6 +89,6 @@ The proposed chapters develop each part of the architecture. Linked chapters hav | [Agent definitions](agent-definitions.md) | Explain `AgentDef` and separate harness execution configuration from broker-enforced authority. | | [MCP authorization](mcp-authorization.md) | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | -| `all-tool-mediation.md` | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | +| [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | The [implementation and verification companion](implementation.md) records integration requirements and checks. Earlier material remains available in the [identity model](../agent-identity-model.md), [authority design](../../agent-authority.md), and [implementation notes](../../agent-authority-implementation.md). The old model's harness-owned signing and audit responsibilities are replaced here by the broker-centered architecture. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 045cdb4824..a1e82c6303 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -30,30 +30,44 @@ The proposed split keeps execution configuration in the harness and supplies the The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the agent's available tools. The broker needs an independently established ceiling on permitted MCP operations. A tool appearing in the harness catalog is not sufficient authorization to call it, and a model-supplied tool list cannot establish the broker's ceiling. -The concrete field mapping remains to be specified. The existing definition-source snapshot contract is not, by itself, broker initialization. +The broker ceiling is the set of permitted registered MCP server/tool pairs resolved under the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. The existing definition-source snapshot contract is not, by itself, broker initialization. ## Loading definitions for a session -The agent-definition loader resolves the definitions selected for a session from configured sources, such as administrator-managed files, repositories, or APIs. It supplies the harness with execution configuration and the broker with the corresponding definition records and authority ceilings. +In the proposed lifecycle, mecak8s's trusted session lifecycle code resolves the session's definitions from configured sources, such as administrator-managed files, repositories, or APIs. It persists the fixed selection, source associations, revisions, and authority ceilings, and supplies execution configuration to the harness. This is a responsibility within mecak8s, not a new registrar component. -Broker initialization must complete before the agent loop starts. The selected definitions remain fixed for the session, so both components work from the same selection. +For Alice's session `S42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. -The loader is outside the untrusted agent loop. Its process placement and how the broker authenticates its input remain to be specified. +Before the main loop starts, mecak8s allocates and persists the main's durable incarnation, then authenticates to the broker with its workload ServiceAccount to register the selected definitions and main instance with no parent. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. -Restoring a session must preserve its selected definitions rather than reload whatever happens to be current at their sources. The original source association and revision must remain recoverable; restoration must not silently substitute another definition. +## Registering instances -Keeping a definition fixed does not freeze user permissions or broker policy. The broker evaluates subsequent MCP calls against the applicable authorization policy. +The broker validates the workload-authenticated registration records and constructs the canonical instance subject. It checks that the workload may act for the session and instance and associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any. mecak8s allocates incarnations; the broker does not. A known definition or loop-supplied name alone is insufficient for issuance. -## Registering instances +When the main creates the reviewer, mecak8s allocates and persists the child's incarnation. It registers that particular child using the persisted incarnation, the reviewer definition already registered from the session selection, and the registered main-instance reference. The broker validates these records and constructs the child subject. This is a lifecycle assertion authenticated by mecak8s's workload credential, not proof derived from possession of the parent's SVID. + +Creation ancestry records who created an instance. Execution delegation separately links parent run `R41`, reviewer run `R42`, and the bounds of delegation `D42`. Registration establishes identity records; it does not authorize spawning, run execution, or a tool call. The harness manages execution, and the broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). -The proposed trusted session/agent lifecycle responsibility is to register each particular instance with the broker using the loader's fixed session selection. The broker needs to establish the association between session incarnation, instance SPIFFE ID, definition source and revision, authority ceiling, and creating instance; this does not select a combined record schema. It checks that the authenticated workload is permitted to act for that session and instance before issuing an instance credential. A known definition or a loop-supplied name alone is insufficient. +The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The API's initiating-user report is also a trusted assertion, separate from workload verification. -Creation ancestry describes which instance created another. Execution delegation separately links the current parent run, child run, and delegated MCP bounds; the same instance can participate in several runs. The harness manages instance creation and run execution. Registration supplies trusted identity records, not broker admission of runs or authorization to spawn. +## Identity continuity across runs and restarts -In this proposal, restoring an instance preserves its identity, fixed definition selection, and original authority bounds; a new incarnation must not inherit the old instance's authority merely by reusing a session name. This identity continuity is separate from the existing broker's attachment recovery. The [credential walkthrough](credentials-and-lifecycle.md#3-broker-session-and-instance-registration) distinguishes the stable broker session reference from a replaceable process-local attachment binding. On the cited branch, recovery remains within the exact authenticated workload and deployment partition: another Pod with the same workload identity is not a grant of access to a different workload. +An instance can perform several runs without changing identity. Its fixed definition selection provides continuity, while each call remains subject to applicable current user permissions and broker policy. -The loader and lifecycle component are outside the untrusted loop as responsibilities. Their authenticated registration channel, durable record representation, and protection from a compromised loop sharing their process still need implementation design. Their placement alone does not prove isolation. The API's user report remains a trusted assertion, separate from the broker's workload verification. +| Event | What remains fixed or must be checked | +| --- | --- | +| New run on the same instance | The incarnation and selected definition stay fixed. The new run has its own execution context and applicable bounds; the instance credential alone does not authorize it. | +| Definition source edited | The session's pinned source and revision selection does not change. Restoration must not substitute whatever is current at the source. | +| Process restart or session restoration | Restore the original incarnation, definition selection, parent association, and authority bounds. Recover the unfinished execution context and its user/actor/connection association or fail closed. | +| Instance credential expires | Renew for the registered identity after the required checks. Renewal grants no new authority and does not widen recovered bounds. | +| Fresh incarnation | Register a distinct identity. Reusing a session or definition name does not inherit the old instance's authority. | + +Continuation preserves the unfinished run's original bounds. Current restrictions or withdrawal can constrain it; credential renewal or policy expansion must not automatically widen recovered authority. If the original identity or selection cannot be recovered, restoration must fail closed rather than silently substitute another instance or definition. + +Identity continuity is separate from broker attachment recovery. A stable broker session reference can have a replaceable process-local attachment binding within the exact authenticated workload and deployment partition; another Pod with the same workload identity does not grant access to a different workload. See [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart) for credential recovery and unknown operation outcomes. ## Source records and policy representation -Definition-source and revision records, and the representation of broker policy, remain unresolved. Generating Cedar from definition tool lists was discussed as an option, not selected as the mechanism. The relationship between definition-derived ceilings and separately managed user and agent permissions also needs to be described without conflating them. +The proposal adopts workload-authenticated mecak8s lifecycle assertions, host-allocated durable incarnations, and broker validation and subject construction as its semantic mechanism. The exact registration wire API, durable record schema, and execution-context binding remain unresolved, as does protection from same-process substitution. These are proposed semantics, not implemented contracts. + +Cedar is selected for the definition-derived ceiling. The representation of definition-source and revision records, registration API, and Cedar policies remains an implementation choice. V1 generates policy only from resolved definition facts; separately managed user, resource, argument, and dynamic delegation policies are deferred. Existing user/connection authorization checks remain required. diff --git a/docs/drafts/agent-identity/all-tool-mediation.md b/docs/drafts/agent-identity/all-tool-mediation.md new file mode 100644 index 0000000000..0aa16a9a62 --- /dev/null +++ b/docs/drafts/agent-identity/all-tool-mediation.md @@ -0,0 +1,17 @@ +# All-tool mediation + +*This chapter describes a future direction, not current implementation status. It is outside the current MCP authorization and audit design scope.* + +The goal in broker-backed remote deployments is for the broker to authorize all tools and spawning or delegation operations, including filesystem tools such as `Read`, `Write`, `Grep`, and `Glob`, and `Shell`. Local direct deployments can retain direct tool access. This direction does not require an immediate migration. + +The [MCP authorization](mcp-authorization.md) chapter covers checks on protected MCP calls. That boundary alone does not govern a file read or a Shell command performed through another execution path. For example, authorizing an MCP repository read says nothing about whether the same agent may read a checkout through `Read`; the broader goal requires a decision for that operation too. + +## Authorization and execution + +Broker authorization does not require moving all execution into the broker. A remote driver, execution provider, or harness can execute an operation. The authorization/execution integration must bind enforcement to the exact authorized request and its execution context, and prevent bypass through alternate paths. How to establish that binding, handle request changes, and enforce it at execution remains to be designed. + +## Unresolved design + +Policy representation remains open, including how to express filesystem resources, Shell operations, and delegated limits. The spawn boundary also needs a design: authorizing child creation or delegation must be distinct from authorizing the child's later operations. + +The [audit requirements](audit.md) distinguish authorization decisions, dispatch, and observed outcomes. Extending them beyond MCP requires deciding which component records each fact, how execution evidence reaches the audit trail, and how denied, failed, or unknown outcomes remain distinguishable. This chapter selects neither a wire API nor a new service; those integration and evidence questions remain future work. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 830e466bed..041b3fd6bc 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -2,24 +2,54 @@ *This chapter describes the intended architecture, not current implementation status. The audit requirement applies to tool calls generally; the current design scope is broker-mediated MCP calls.* -## Following a tool call and its result +The audit trail must explain which registered instance requested a call, which definition-derived ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. A v1 Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. -An audit record must connect the initiating user, authenticated workload, acting agent instance, definition source and revision, and the authorized provider connection used, where applicable, to the requested tool call and its result. It must preserve the session incarnation, run, creation ancestry, and separate execution-delegation links needed to trace how that instance came to act. Qualify subjects by their issuer or configured trust domain; a workload subject alone is not globally unique. +## Evidence and provenance -Use a per-operation correlation identifier across decision, dispatch, and outcome records. This is an audit requirement, not an established cross-service join or authenticated propagation contract; those mechanisms remain unspecified. A token identifier such as `jti`, when present, is supplementary: multiple operations can reuse one token. Preserve the target, tool, safe operation/resource metadata, policy and delegation references, and authorized connection needed to explain the decision. +Records must distinguish verified facts from trusted reports. Qualify subjects by issuer or configured trust domain; a workload subject alone is not globally unique. -Each observer must distinguish what it verified from what it received as a report. API authentication establishes the company user at the API; the broker verifies the workload but trusts the API's user report. ToolHive AS and vMCP record the identities and decisions they validated themselves. Propagated run context or provider responses do not become independently verified facts merely because they appear in an audit record. +| Evidence | Source and what it establishes | Limit | +| --- | --- | --- | +| Authenticated workload and instance | Broker validation of workload credentials and the instance JWT-SVID, with the registered session association | Does not authenticate the human or prove a task-specific delegation. | +| Definition source, admitted revision, ceiling, and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | +| Initiating user and session ownership | API authentication and server-side ownership checks, reported to the broker | The broker trusts the API report; it does not independently authenticate the human from it. | +| Run and execution-delegation links | Attributed lifecycle/run reports under the execution-context binding | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. | +| Ceiling evaluation and effective broker decision | Broker evaluation of the registered actor's generated policy and the exact server/tool pair, plus applicable existing checks | An allow proves neither dispatch nor downstream success. | +| Downstream identity and authorization | ToolHive AS and vMCP records of their own validations and decisions | Forwarded context remains reported unless independently verified there. | +| Observed result and provider correlation | The response received by the recording component and safe provider references, when available | A broker-observed MCP response is not independent proof of provider-side effects. | -The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. Decision, dispatch, and observed-outcome evidence must be durable and survive broker restart. This requirement does not select a persistence ordering or cross-service delivery protocol. A missing completion after possible dispatch is an unknown outcome, not evidence that no action occurred. Observed success does not guarantee provider-side effects. +## Illustrative authorization evidence -Where an external SaaS provider supports tracing or audit correlation, the integration must preserve the identifiers needed to connect the broker's record to the provider's operation or audit event. This allows an investigation to follow an action into the provider, or trace a provider event back to the initiating user and agent chain. +The following readable fields illustrate a proposed broker authorization event for the reviewer's call in the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution). They are an audit shape to guide implementation, not an immutable event API or a Cedar policy representation. -End-to-end correlation depends on the provider's capabilities and configuration; the system cannot guarantee it for every external tool. When that evidence is unavailable, the audit trail must make clear where correlation ends. +| Field group | Illustrative values or required evidence | +| --- | --- | +| Event and call references | Event `authz-42`, operation `op-pr42-001`, attempt `attempt-1`, recording component, and observation time. Distinguish another attempt from another event for the same attempt. | +| Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; session `S42` and its incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; API-reported Alice with issuer/subject and request reference. Preserve the provenance above. | +| Definition and policy | Registered `project/code-reviewer`, source reference, admitted revision `review-def-r17`, resolved ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | +| Target | Registered payments MCP server reference, exact `pull_request_read` tool, authorized connection `github-conn-42`. | +| Evaluation | Cedar nominal decision `allow`, evaluation status `completed-without-errors`, relevant policy IDs, and generated policy revision. | +| Effective decision | `allow` only after all required checks pass; safe reason `definition-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. | +| Safe request metadata | Repository `acme/payments` and PR number `42` only if explicitly allowlisted for audit. Their presence does not mean Cedar evaluated a PR-specific policy. | -Credentials must not appear in audit records or logs: exclude serialized tokens, refresh tokens, private keys, client secrets, authorization headers, and OAuth transaction secrets. Record selected validated metadata and nonsecret record references instead. Redact sensitive tool arguments and results while retaining sufficient safe operation attribution; dumping decoded credential payloads is not an audit strategy. +The policy revision and policy IDs must lead to retained generation inputs and policy evidence sufficient to explain the ceiling result. A definition name alone cannot explain which policy was used. Error and denial records need the same correlation and attribution where established; unavailable or unverified facts must remain marked as such. -The [PR 42 credential walkthrough](credentials-and-lifecycle.md#interactive-execution) places credential examples, validation checks, and selected audit evidence alongside each producing step. +## Decision, dispatch, and observed outcome -## Additional audit hardening +The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed as separate facts. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. If completion is missing after possible dispatch, the outcome is unknown, not evidence of nonexecution. Credential recovery cannot justify automatic replay of an unknown operation. -Protecting persisted audit evidence against unauthorized disclosure or modification is additional work beyond this identity and authorization design. That work must address access to the stored records and their integrity after collection; recording authenticated identities does not itself protect the audit store. +Decision, dispatch, and observed-outcome evidence must be durable and survive broker restart. The storage mechanism, write ordering relative to dispatch, failure handling when evidence cannot be persisted, and cross-service delivery and authenticated correlation remain explicitly unresolved implementation requirements. This design does not select a transactional ledger or claim atomicity between an audit write and an external effect. + +Use operation and attempt references to connect the evidence; individual events also need their own references. A token identifier such as `jti`, when present, is supplementary because several calls can reuse one token. This correlation requirement does not establish an authenticated propagation contract between services. + +## Following a call in an investigation + +Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. The decision explains why the server/tool pair passed or failed and whether any other required check prevented dispatch. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with reported user and lifecycle facts distinguished from broker-verified credentials. + +The investigator then follows dispatch and observed-outcome records for that attempt, rather than inferring execution from the allow. ToolHive records can explain downstream validation or denial. Where the provider supports safe tracing or audit correlation, retain the references needed to reach its operation or audit record and to trace that record back to the actor. Mark where correlation ends if provider evidence or authenticated cross-service correlation is unavailable. A successful response describes what the observer received, not a guarantee of provider-side effects. + +## Redaction and audit-store protection + +Record only allowlisted argument and result metadata needed for attribution, with redaction applied before persistence or logging. Safe status categories and nonsecret provider references usually suffice for outcomes. Exclude tokens, refresh tokens, private keys, client secrets, raw headers, OAuth transaction secrets, and unrestricted argument or result payloads. Policy diagnostics and error text need the same treatment; dumping decoded credentials is not an audit strategy. + +Protecting persisted evidence against unauthorized disclosure or modification requires audit-store access and integrity controls. Recording authenticated identities does not itself protect the store. The [credential walkthrough](credentials-and-lifecycle.md#interactive-execution) owns credential validation and OAuth examples; this chapter specifies the evidence needed to investigate their use. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index d475b85e2a..ef1c2d0891 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status.* -An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution, while the broker authorizes MCP tool calls using the acting instance's identity and the applicable user, delegation, and run context. Renewing the instance credential does not itself extend or widen permission to call tools. +An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. In v1, the broker checks the acting instance's definition-derived server/tool ceiling with Cedar, alongside existing user/connection checks and trusted execution-context binding. User, resource, argument, and dynamic delegation policies are deferred; carrying run or delegation context does not mean Cedar evaluates those bounds. Renewing the instance credential does not itself extend or widen permission to call tools. ## Credential roles @@ -18,21 +18,23 @@ An agent instance can participate in multiple runs. Its instance credential iden These are roles, not necessarily seven credentials sent on every call. Enrollment, issuance, and refresh happen when needed. -Run IDs, session attachments, and broker session references are separate from credentials. A broker session reference grants neither run execution nor MCP access; step (3) distinguishes existing references from proposed registration responsibilities. +Run IDs, session attachments, and broker session references are separate from credentials. A broker session reference grants neither run execution nor MCP access; the proposed registration discussion distinguishes existing references from registration responsibilities. ## Interactive execution -Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, owns harness session `S42`. She asks Mecatl to read PR 42 in `acme/payments`. Her main instance's run `R41` delegates this task through `D42` to reviewer run `R42`, which requests tool `pull_request_read` with repository `acme/payments` and number `42`. The main instance also created the reviewer, but that creation ancestry is distinct from this run's delegation. `reviewer` below always means the exact SPIFFE ID in step (4), under session incarnation `inc_cejrv7re4uy3ubxyzj2gfxfp2u`; `R42` identifies execution, not a credential. +Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, owns harness session `S42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` first calls `pull_request_list`. After the list returns, it delegates review of PR 42 through `D42` to reviewer run `R42`, which calls `pull_request_read`. These tool names and arguments are illustrative, not a selected MCP server contract. The main instance has incarnation `inc_cejrv7re4uy3ubxyzj2gfxfp2u`; its reviewer child has incarnation `inc_gh56irfcpnupjqgfdw7dhzzw4m`. Runs identify execution, not credentials. In the diagram, `main` and `reviewer` abbreviate the exact SPIFFE subjects in [example instance identities](#example-instance-identities). The reviewer uses definition `project/code-reviewer`. Creation ancestry and delegation `D42` are separate facts, even though the main both creates this reviewer and delegates this task to it. -The walkthrough assumes the proposed trusted loader and lifecycle registration described in [agent definitions](agent-definitions.md#registering-instances): the loader fixes definitions, sources, revisions, and ceilings, and the lifecycle component registers instances. Mecatl manages execution; the broker authorizes tool calls. These proposed inputs are not all available through today's broker contract. +This proposed protocol uses mecak8s's trusted session lifecycle code and the existing broker/agent-instance issuer roles. Before execution starts, mecak8s resolves and persists the session's fixed definition selection, source, revision, and ceiling. It allocates and persists a durable session incarnation for the main instance. An incarnation is the durable identity mecak8s allocates for an instance. mecak8s then registers the main definition and instance with no parent while authenticated as its workload ServiceAccount. For a child, mecak8s supplies the persisted child incarnation, registered definition, and registered main-instance reference. The broker validates those records and constructs the canonical subject. It does not allocate incarnations. This protocol is not implemented behavior, and today's broker contract does not supply all of these registration inputs. -Operation `op-pr42-001` follows this one PR read throughout. Times are Unix seconds around `1800000000`, matching the delegated token. Examples are fictional decoded payloads or credential references, never usable bearer values. Audit examples contain only selected metadata; they are not copies of credentials or implemented cross-service events. The common operation ID denotes desired correlation, not an established authenticated propagation mechanism. Audit placement does not specify durable-write ordering. +This trusts mecak8s lifecycle assertions. A ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same mecak8s process, so authenticated same-process protection remains unresolved. Registration establishes identity records only. It does not authorize spawning, run execution, or a tool call. + +Operation `op-list-prs-001` identifies the main's list call. Operation `op-pr42-001` identifies the reviewer's PR read; the detailed credential and audit examples below follow this second call. Times are Unix seconds around `1800000000`, matching the delegated token. Examples are fictional decoded payloads or credential references, never usable bearer values. Audit examples contain only selected metadata; they are not copies of credentials or implemented cross-service events. The common operation ID denotes desired correlation, not an established authenticated propagation mechanism. Audit placement does not specify durable-write ordering. For this example only, configured workload audience `https://broker.example.com` covers the combined broker's issuance and invocation endpoints. The instance credential has two explicit audiences: that broker and `https://toolhive.example.com/token`. This illustrates a multi-audience deployment so the same actor credential can reach both validators; it does not select the architecture over separately issued credentials. Each recipient requires its own configured audience and trust material, never endpoints chosen by token claims. Instance credential issuance, provider enrollment, and token exchange occur only when needed. The broker checks every requested operation (see [delegated authority](mcp-authorization.md#delegated-authority) and [operations and resources](mcp-authorization.md#operations-and-resources)). -The numbered markers identify the credential and authorization boundaries discussed below. They group related messages rather than number every network request. The participants are logical roles; grouping them does not establish process isolation. +The diagram follows two successful calls, with enrollment already complete and usable provider credentials in custody. Failure handling, enrollment, and internal checks are in the linked legend rather than separate arrows. The numbered explanatory sections are not message order. Participants are logical roles, so grouping them does not establish process isolation. ```mermaid sequenceDiagram @@ -41,74 +43,62 @@ sequenceDiagram box Combined broker participant I as Agent-instance issuer participant B as Tool authorization / broker OAuth client - participant A as ToolHive AS / upstream OAuth client - participant C as ToolHive upstream token storage / reader + participant A as ToolHive AS participant V as Protected vMCP end - participant P as GitHub provider - - Note over H,B: Before the loop starts, load this session's selected agent definitions
Execution configuration into mecak8s, definition records and authority ceilings into the broker - Note over U,H: (1) Company IdP access token obtained before this request
Login flow not shown - U->>H: Read acme/payments PR 42, API access token - H->>H: Validate token and session owner
Main R41 delegates via D42 to reviewer R42 - - Note over H,I: (3) Broker has trusted session, instance and definition records
Registration protocol and input protection remain unspecified - opt Reviewer has no suitable valid instance credential - H->>I: (2, 4) Request credential for reviewer instance
Present projected workload SA token over TLS - I->>I: Authenticate workload
Check it may represent this registered reviewer, its fixed definition and creation ancestry - I-->>H: Reviewer's own short-lived agent-instance JWT-SVID - H->>H: Validate bundle, subject, audience and expiry - end - - H->>H: Reviewer requests pull_request_read for acme/payments PR 42 - loop Invocation attempts until result or terminal failure - Note over H,B: (6) Repeat all checks on every attempt, including after enrollment - H->>H: Apply local permissions and hooks - H->>B: Request MCP tool invocation: pull_request_read
Workload SA token + reviewer JWT-SVID + execution context + call ID + PR 42 arguments - B->>B: Validate workload and reviewer credentials
Verify presenter and execution-context binding - B->>B: Evaluate MCP operation and connection access
Check target, arguments, user permissions and agent authority - Note over B: Missing enrollment may leave authorization incomplete
Enrollment never implies permission to dispatch - B->>B: For eligible requests, check connection readiness
Refresh in custody where possible, otherwise require enrollment - B->>B: Record denial, enrollment-required outcome, or final allow - - alt Invalid credentials or context, or policy denial - B-->>H: Rejection without backend dispatch - H-->>U: Refusal - else Enrollment or user reauthorization required - B-->>H: Enrollment required + broker enrollment link
No backend dispatch - H-->>U: Pause pending operation and present enrollment link - U->>A: (5) Complete ToolHive and provider enrollment - A->>P: Upstream OAuth client authentication - P-->>A: Provider access token and optional refresh token - A->>C: Store provider credentials - B->>A: Broker OAuth client authentication - A-->>B: ToolHive user access token and refresh token - Note over H,B: Retry pending invocation through all checks above
User / subject / connection association mechanism remains unspecified - else Fully authorized and credentials ready - opt No suitable valid delegated token - B->>B: Obtain current ToolHive user access token from custody or refresh - B->>A: (7) User subject_token + instance actor_token + broker client authentication - A->>A: Validate user token, actor credential, presenter and bounded access - A-->>B: Proposed token profile: sub=ToolHive user, act.sub=instance, client_id=broker - B->>B: Restrict token use to the applicable execution context - end - B->>V: (8) Dispatch authorized MCP tool call: pull_request_read
Delegated token + PR 42 arguments - V->>V: Validate token and operation policy, then resolve authorized connection - V->>C: Resolve valid upstream grant for authorized connection and target - C-->>V: Native provider credential - V->>P: Read PR 42 with injected provider credential - P-->>V: PR 42 result - V-->>B: Result - B->>B: Record observed outcome and provider correlation if available - B-->>H: Sanitized result and observed outcome - H-->>U: Answer without credentials in model history - end - end - Note over H,B: Decision, dispatch and outcome require durable evidence
Persistence ordering and cross-service propagation are unspecified - Note over H,B: Every subsequent tool call repeats authorization + Note over H,I: Proposed session bootstrap with fixed definitions and host-allocated incarnations + H->>I: Register main under workload SA authentication
Main incarnation + selected definition + no parent + I-->>H: Registered main reference and canonical subject + H->>I: Request main SVID
Workload credential + registered main reference + I-->>H: Main JWT-SVID + + U->>H: List pull requests and review PR 42 in acme/payments
API access token + Note over H,V: First call: main run R41 lists pull requests + H->>B: pull_request_list(repository=acme/payments)
Workload + main SVID + R41 + op-list-prs-001 + B->>B: Authenticate and verify context
Check main server/tool ceiling and connection access + B->>A: Exchange user subject_token + main actor_token
Broker client authentication + A->>A: Validate user and main actor tokens and broker client
Authorize requested MCP target and access bounds + A-->>B: Delegated token with sub=ToolHive user and act.sub=main + B->>V: pull_request_list with main's delegated token + V->>V: Authenticate delegated token and authorize list operation + V-->>B: Pull request list including PR 42 + B-->>H: Sanitized list including PR 42 and observed outcome + + H->>H: Main delegates PR 42 review via D42 to reviewer R42
Allocate and persist child incarnation and definition + H->>I: Register reviewer under workload SA authentication
Child persisted incarnation + registered reviewer definition + registered parent reference + I-->>H: Registered reviewer reference and child subject + H->>I: Request reviewer SVID
Workload credential + registered reviewer reference + I-->>H: Reviewer's own JWT-SVID + + Note over H,V: Second call: reviewer run R42 reads PR 42 + H->>B: pull_request_read(repository=acme/payments, number=42)
Workload + reviewer SVID + R42 + D42 + op-pr42-001 + B->>B: Authenticate and verify context
Check reviewer server/tool ceiling and connection access + B->>A: Exchange user subject_token + reviewer actor_token
Broker client authentication + A->>A: Validate user and reviewer actor tokens and broker client
Authorize requested MCP target and delegated access bounds + A-->>B: Delegated token with sub=ToolHive user and act.sub=reviewer + B->>V: pull_request_read PR 42 with reviewer's delegated token + V->>V: Authenticate delegated token and authorize PR 42 read + V-->>B: PR 42 result + B-->>H: Sanitized PR result and observed outcome + H-->>U: Review produced from reviewer findings
No credentials in model history ``` +| Legend key | Explanatory check or record (not a chronological message) | +| --- | --- | +| [(1) API access token](#1-company-api-access-token) | After the API request and before the main run, mecak8s validates the token and checks its issuer and subject against the stored session owner; only then does it start `R41`. This authenticates the API request and enforces session ownership, not MCP dispatch authorization. | +| [(2) Workload credential](#2-workload-credential) | The broker authenticates mecak8s for registration, issuance, and both invocations. This authenticates the service's lifecycle assertions, not isolation from compromised code in that service. Invalid workload credentials stop the request. | +| [(3) Broker session and instance registration](#3-broker-session-and-instance-registration) | Before returning the registered reference and before (4), the issuer validates the workload and persisted registration records, then constructs the canonical subject. Registration remains identity recording only; it authorizes neither spawning, run execution, nor a tool call. | +| [(4) Agent-instance JWT-SVID](#4-agent-instance-jwt-svid) | After the SVID is returned and before an invocation, mecak8s verifies its signature using the configured agent trust bundle and checks the registered subject, allowed algorithm, audience, and expiry. | +| [(5) Enrollment](#5-enrollment-and-authorized-connection) | If an invocation reveals missing enrollment or a grant requiring user reauthorization, the broker returns an enrollment-required response without dispatch. mecak8s pauses, presents the broker link, and retries after completion through all checks. Enrollment is not permission to execute. Refreshable expiry is handled in custody where possible. | +| [(6) Per-operation authorization](#6-per-operation-authorization) | Before each invocation, mecak8s applies local permissions and hooks. The broker authenticates workload and actor credentials and verifies execution-context binding, then evaluates the exact registered server/tool pair against the actor's fixed definition-derived Cedar policy and checks connection access. `D42` is context, not a v1 dynamic delegation policy. Invalid credentials/context, denied policy, or any evaluation diagnostic error fail closed without dispatch. The broker records the effective decision and dispatches only when all required checks pass and credentials are ready. | +| [(7) Token exchange and delegated MCP access token](#7-token-exchange-and-delegated-mcp-access-token) | Before requesting exchange, the broker obtains a current user access token from custody or refreshes it when needed. Before returning the delegated token, ToolHive AS validates the user token, actor credential, presenter, and bounded access. The broker restricts any delegated token to the applicable execution context; reuse never exceeds those bounds. | +| [(8) Dispatch and observed outcome](#8-dispatch-and-observed-outcome) | Before resolving the upstream grant or calling the provider, vMCP validates the delegated token and operation policy and resolves the authorized connection. It performs each GitHub operation using the native provider credential held by ToolHive, never an SVID as the provider credential. After the provider result and before returning the sanitized result, the broker records the observed outcome and provider correlation when available. | + +Decision, dispatch, and outcome require durable evidence; persistence ordering and cross-service propagation are unspecified. Every subsequent tool call repeats authorization. + +A child call uses the same invocation flow with the child's own JWT-SVID and attributed run and delegation context. It does not require or present the parent's SVID. The broker checks the child's registered parent-instance reference, not a parent token, when it constructs the child subject, then evaluates the child's own definition ceiling for the call. Broker enforcement of dynamic delegated bounds remains future work. + ### (1) Company API access token The company IdP issues an API access token to Alice's client, which presents it to Mecatl over TLS. The client holds it; it terminates at the API and is not retained for downstream access. This fictional JWT access-token payload is not an ID token. @@ -123,7 +113,7 @@ The company IdP issues an API access token to Alice's client, which presents it } ``` -This example uses mecak8s with OIDC authentication and session ownership enforcement enabled. Alice submits her request to existing session `S42`, which belongs to her. mecak8s validates her access token and checks that its issuer and subject match the session's stored owner. If either check fails, it rejects the request. Otherwise, main run `R41` delegates the PR read through `D42`, and the harness starts reviewer run `R42`. +This example uses mecak8s with OIDC authentication and session ownership enforcement enabled. Alice submits her request to existing session `S42`, which belongs to her. mecak8s validates her access token and checks that its issuer and subject match the session's stored owner. If either check fails, it rejects the request. Otherwise, the harness starts main run `R41`. **Illustrative API evidence:** mecak8s accepted request `req-pr42-001` from authenticated user `(https://login.example.com, alice-204)` for session `S42`. The user identity comes from the validated token; session ownership comes from server-side state. This records API authentication and session ownership only. The broker has not yet authorized an MCP call to read PR 42. @@ -156,13 +146,13 @@ The broker checks the signature using trusted cluster keys, the issuer, allowed The broker session reference identifies broker-owned state; knowing it grants no access. The broker checks the calling service's authenticated identity and deployment. This associates the session with mecak8s, not with Alice, and is separate from harness session `S42`. See [pauses and restart](#pauses-and-restart) for attachment and recovery details. -Under the proposed registration model, the trusted lifecycle component supplies the reviewer's instance and selected definition. Before issuing the credential in (4), the broker checks that this workload may represent that instance and that its definition, revision, and creation ancestry match trusted records. The registration protocol remains unspecified; it does not authorize spawning or run execution. +In the proposed bootstrap, mecak8s's trusted session lifecycle code persists the selected definition and durable main incarnation, then registers the main instance with no parent under workload ServiceAccount authentication. The broker validates the workload and records, constructs the canonical main subject, and issues its instance credential. For the reviewer child in this example, mecak8s supplies its persisted incarnation, registered definition, and registered main-instance reference, not a main token; the broker validates these records and constructs the child subject. The broker does not allocate either incarnation. The exact registration wire API and the durable record schema remain unspecified. -Alice's identity is a separate API report, not established by the workload credential. Protecting registration and user reports from substitution by a compromised loop in the same process remains unresolved. The issuance example in (4) distinguishes these sources. +Alice's identity is a separate API report, not established by the workload credential. A ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same mecak8s process, so protecting registration and user reports from same-process substitution remains unresolved. Registration does not authorize spawning, run execution, or tool calls. The issuance example in (4) distinguishes these sources. ### (4) Agent-instance JWT-SVID -The broker's agent issuer signs the registered reviewer's JWT-SVID. The harness client holds it outside model history and presents it to the broker; the broker later presents it as the actor token to ToolHive AS. The issuer retains its private signing key. This decoded example deliberately uses only required JWT-SVID claims: +The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. The harness client holds it outside model history and presents it to the broker; the broker later presents it as the actor token to ToolHive AS. The issuer retains its private signing key. This decoded example deliberately uses only required JWT-SVID claims: ```json { @@ -172,9 +162,9 @@ The broker's agent issuer signs the registered reviewer's JWT-SVID. The harness } ``` -Before issuance, the issuer checks the workload and instance against the trusted registration in step (3); a definition's existence alone is insufficient. The harness checks that the returned subject is the registered reviewer, then validates its signature, allowed algorithm, expiry, and intended audiences against the configured agent trust-domain bundle. The broker and ToolHive AS each require their own audience and a permitted presenter. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. +Before issuance, the issuer checks the workload and reviewer instance against the proposed registration in step (3); a definition's existence alone is insufficient. The harness checks that the returned subject is the registered reviewer instance, then validates its signature, allowed algorithm, expiry, and intended audiences against the configured agent trust-domain bundle. The broker and ToolHive AS each require their own audience and a permitted presenter. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. -**Illustrative issuance evidence for (3) and (4):** only if the proposed loader and lifecycle registration supply the definition provenance and instance association, and the API supplies its user report, could the broker correlate the issued subject with the following facts. Today's broker contract does not supply these registration/user proofs, and `AgentDef` has no source or revision fields today. This is conditional audit evidence, not implemented behavior, additional JWT claims, or a registration API; protection against same-process substitution remains unresolved. +**Illustrative issuance evidence for (3) and (4):** only if mecak8s's proposed lifecycle registration supplies the definition provenance and instance association, and the API supplies its user report, could the broker correlate the issued subject with the following facts. Today's broker contract does not supply these registration/user proofs, and `AgentDef` has no source or revision fields today. This is conditional audit evidence, not implemented behavior, additional JWT claims, or a registration API; protection against same-process substitution remains unresolved. ```json { @@ -183,7 +173,7 @@ Before issuance, the issuer checks the workload and instance against the trusted "name": "project/code-reviewer", "revision": "review-def-r17", "source": "repo://acme/payments/.mecatl/agents/code-reviewer.md", - "evidence_source": "trusted lifecycle registration" + "evidence_source": "mecak8s lifecycle registration" }, "initiating_user": { "iss": "https://login.example.com", @@ -194,7 +184,7 @@ Before issuance, the issuer checks the workload and instance against the trusted } ``` -The JWT's `sub` identifies the reviewer. It does not carry Alice's identity or the definition revision; the correlated evidence records those separately and preserves their different sources. +The JWT's `sub` identifies the reviewer instance. It does not carry Alice's identity or the definition revision; the correlated evidence records those separately and preserves their different sources. See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. @@ -210,7 +200,7 @@ Refresh behavior belongs in [credential lifetimes and renewal](#credential-lifet ### (6) Per-operation authorization -Local permissions and hooks run first. Trusted harness client code then presents the workload and reviewer credentials with operation `op-pr42-001`, run `R42`, delegation `D42`, and this illustrative request: +Local permissions and hooks run before each call. For the second call, trusted harness client code presents the workload and reviewer credentials with operation `op-pr42-001`, run `R42`, delegation `D42`, and this illustrative request: ```json { @@ -219,17 +209,17 @@ Local permissions and hooks run first. Trusted harness client code then presents } ``` -The broker validates both credentials and requires trusted attribution of the actor, initiating user, run, and delegation; the context-binding mechanism remains unresolved. It checks authorization to use the ToolHive subject and resolved provider connection, then evaluates the complete final request against current user permissions, the fixed definition ceiling, original delegated bounds, and current policy. A transformed request needs another check. A denial dispatches nothing; an allow authorizes this MCP call, not a run or spawn. +The broker validates both credentials and requires trusted attribution of the actor, initiating user, and run; the context-binding mechanism remains unresolved. It checks authorization to use the ToolHive subject and resolved provider connection, then evaluates the exact registered server/tool pair against the generated Cedar policy associated with the actor's fixed definition revision in trusted registration. Default deny applies, and any evaluation diagnostic error forces denial even with a nominal allow. A transformed request needs the applicable checks again. A denial dispatches nothing; an allow covers this MCP call, not a run or spawn. The ceiling result would be the same for PR 43 on this pair; v1 does not verify PR-specific bounds from `D42`. -**Illustrative broker evidence:** `op-pr42-001` was allowed under `payments-read-policy-r9`, with the request above, `S42`, `R42`, `D42`, verified workload and reviewer, API-reported Alice, revision `review-def-r17`, delegation/ceiling references, and authorized connection `github-conn-42`. A denial records its reason instead; neither decision proves dispatch or success. +**Illustrative broker evidence:** `op-pr42-001` passed the definition-ceiling check under generated policy revision `review-ceiling-r17-g1` without evaluation errors, and the broker allowed the call after the required connection and context checks. Evidence links session `S42`, reviewer run `R42`, reported delegation `D42` from main run `R41`, verified workload and reviewer instance (the exact `sub` in (4)), API-reported Alice, definition `project/code-reviewer` at revision `review-def-r17`, the resolved server/tool pair, and authorized connection `github-conn-42`. Only allowlisted, redacted request metadata is retained. A denial records a safe reason instead; neither decision proves dispatch or success. See the [illustrative authorization evidence](audit.md#illustrative-authorization-evidence) for event, attempt, and policy references. Recovered authority remains subject to the original bounds described in [pauses and restart](#pauses-and-restart). ### (7) Token exchange and delegated MCP access token -At marker (7), ToolHive exchanges the user access token and agent-instance JWT-SVID for the access token that the broker presents to vMCP. This example proposes the [RFC 9068 JWT access-token profile](https://www.rfc-editor.org/rfc/rfc9068.html#section-2) with [RFC 8693 delegation claims](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). It is a proposed contract, not a claim about existing ToolHive behavior. The exchange output is an OAuth access token, not a JWT-SVID. +In this token exchange, ToolHive exchanges the user access token and agent-instance JWT-SVID for the access token that the broker presents to vMCP. This example proposes the [RFC 9068 JWT access-token profile](https://www.rfc-editor.org/rfc/rfc9068.html#section-2) with [RFC 8693 delegation claims](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). It is a proposed contract, not a claim about existing ToolHive behavior. The exchange output is an OAuth access token, not a JWT-SVID. -Before reading the token: `sub=th-user-731` names Alice's assumed authorized subject in ToolHive's namespace, not `(https://login.example.com, alice-204)` from her company login. `act.sub` names the same reviewer executing `R42`; `client_id=mecatl-broker` names the broker requesting the token, not another agent or user. The token is for the same PR read at `https://mcp.example.com/payments/mcp` through `github-conn-42`; it does not itself establish that connection association. +Before reading the token: `sub=th-user-731` names Alice's assumed authorized subject in ToolHive's namespace, not `(https://login.example.com, alice-204)` from her company login. `act.sub` names the reviewer instance executing `R42` under delegation `D42`; `client_id=mecatl-broker` names the broker requesting the token, not another agent or user. The token is for the same PR read at `https://mcp.example.com/payments/mcp` through `github-conn-42`; it does not itself establish that connection association. The decoded header and payload below use fictional values. A real token is signed; its signature and serialized bearer value are omitted here. @@ -377,7 +367,7 @@ An approval wait or provider-enrollment pause leaves the current run unfinished. After restart, recovering credentials is not enough to continue safely. The system must recover the unfinished execution context and establish the association between the user, acting agent, and provider connection. If that association cannot be established, the tool call must fail closed. The harness determines whether execution resumes; the broker authorizes subsequent MCP calls. -Continuation must preserve the unfinished run's original authorization bounds. The broker checks subsequent MCP calls against those bounds and applicable current policy. Restrictions or withdrawal can constrain continuation; credential renewal or policy expansion must not automatically widen the recovered authority. +Continuation must preserve the unfinished run's original authorization bounds. In v1, the broker rechecks the fixed definition-derived ceiling and existing connection restrictions; the harness retains its run/delegation restrictions. Broker evaluation of dynamic delegated bounds is deferred. Restrictions or withdrawal can constrain continuation; credential renewal or policy expansion must not automatically widen the recovered authority. An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an applicable idempotency mechanism. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 96fffcc49b..a4f272cf50 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -26,7 +26,7 @@ The protected MCP endpoint validates the delegated token's issuer, audience, exp Whether the two presentation points use one multi-audience instance credential or separately issued credentials remains to be specified. The [walkthrough](credentials-and-lifecycle.md#interactive-execution) illustrates the former without selecting it as the deployment contract. -Verify issuance rejects a known definition without a trusted particular-instance registration, an unauthorized workload, mismatched ancestry, or a stale session incarnation. These are proposed checks; the registration protocol and input protection remain unspecified. Verify restoration preserves the instance identity, fixed definition selection, and original bounds. Separately, the existing broker recovery path permits a new process-local attachment binding for a stable broker session within the exact authenticated workload and deployment partition; a different workload cannot assume access. Knowing a noncredential broker session reference must not suffice. Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. +Verify issuance rejects a known definition without a trusted particular-instance registration, an unauthorized workload, mismatched ancestry, or a stale session incarnation. These are proposed checks under the [workload-authenticated mecak8s lifecycle registration](agent-definitions.md#registering-instances); the exact wire API, durable schema, and protection from same-process substitution remain unspecified. Verify restoration preserves the instance identity, fixed definition selection, and original bounds. Separately, the existing broker recovery path permits a new process-local attachment binding for a stable broker session within the exact authenticated workload and deployment partition; a different workload cannot assume access. Knowing a noncredential broker session reference must not suffice. Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. ## Workload-specific verification @@ -44,6 +44,18 @@ The broker validates the configured Kubernetes issuer, audience, and ServiceAcco Workload mTLS does not bind an OAuth access token to its certificate, prove user consent, or isolate agents sharing a harness process. +## Cedar authorization and audit verification + +Cedar is the selected engine for the v1 definition-derived MCP server/tool ceiling. Policy representation is an implementation choice; user, resource, argument, and dynamic delegation policies are deferred. Required focused checks are: + +- Resolve `Tools`, `DisallowedTools`, and `MCPServers` with existing defaults, selection, and exclusion semantics into registered server/tool pairs. Reject ambiguous or unresolved grants; the same tool name on another server must not inherit access. +- Prove deterministic generation from fixed admitted definition inputs, with retained source, definition revision, and generated policy revision. Prose and model output must not affect grants; source edits and restoration must not silently replace the admitted ceiling. +- Select the actor's policy through trusted registration. Reject caller substitution of actor, definition, revision, or policy. Require an explicit resolved main ceiling, without name-based privilege. +- Evaluate each call, including calls using cached credentials and retries after enrollment. Default deny; generation/loading failures, evaluation failures, and any evaluation diagnostic errors deny, even if the nominal Cedar result is allow. +- Check the exact dispatched server/tool pair and recheck changed requests. Prove the reviewer read passes its ceiling while excluded merge fails; PR 42 and PR 43 reads on the same pair have the same ceiling result. Do not claim this proves PR-specific delegation. +- Preserve existing local, user/connection, ToolHive AS, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. +- Verify audit provenance, definition and policy revision correlation, safe reasons and policy IDs, and distinct decision/dispatch/outcome evidence per operation and attempt. Diagnostic errors must yield an effective deny in the record. Redaction must exclude secrets and unrestricted payloads. Test unknown outcomes without automatic replay and durability across restart once storage, ordering, and failure handling are specified; no transactional ledger is selected here. + ## Parking and recovery: implementation status Protected MCP calls can park while awaiting provider reauthorization and resume with account, connection, catalogue, and permission checks. Offline fixtures cover credential and enrolled-connection recovery after host and broker replacement. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index a869848f70..8184eb7a07 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -2,17 +2,37 @@ *This chapter describes the intended architecture, not current implementation status.* +The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. V1 evaluates only a definition-derived MCP server/tool ceiling.** It supplements existing local permissions, user/connection checks, and ToolHive authorization; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. + +## Definition-derived ceiling + +Trusted definition admission resolves the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics against registered MCP servers and their tools into permitted server/tool pairs. Resolution must preserve existing selection and exclusion semantics, including defaults, rather than interpreting a raw tool list as grants. A tool name alone cannot identify an authorized pair across servers. Unresolved or ambiguous references cannot grant access. + +Cedar policies are generated deterministically from that resolved ceiling and tied to the fixed admitted definition source and revision. The same admitted inputs produce the same ceiling and generated policy revision. Generation uses structured definition facts, never an LLM or inferred intent from `Body`, descriptions, skills, or other prose. Retain the resolution inputs and generated policy revision so that the decision can be explained after the source changes. Representation, including the Cedar schema and entity/action/resource layout, is left to the implementer. + +After authenticating the instance, the broker obtains its definition and generated policy association from trusted registration records. Caller-supplied definition names, revisions, policy identifiers, or tool lists cannot select a more privileged ceiling. The main instance also needs an explicit resolved ceiling; `main`, `system`, or a root identity grants no name-based privilege. + +For every call, the broker evaluates the exact registered server/tool pair that will be dispatched. Default deny applies: no matching permit means denial. Policy generation or loading failures and evaluation failures deny; **any Cedar evaluation diagnostic error makes the effective decision deny, even if Cedar also returns a nominal allow.** An error-free permit satisfies only this ceiling check. A changed target or tool requires another evaluation before dispatch. + +## Authentication and execution context + +Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. + +The broker also requires a trustworthy binding between the workload, acting instance, initiating user, session, run, and any delegation. Merely supplying run `R42` or delegation `D42` as request fields is insufficient. The context-binding mechanism remains unselected; missing or invalid binding must fail closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. + ## Delegated authority A child's permitted MCP operations must stay within the authority delegated by its parent and the child's definition ceiling. Delegation can preserve or narrow that authority, not widen it. -Delegation history records how the acting instance came to act. An ancestor's permissions do not give the current actor additional rights. The broker authorizes the requested operation against the applicable user permissions, delegated limits, and definition ceiling. +Delegation history records how the acting instance came to act. An ancestor's permissions do not give the current actor additional rights. In v1, the broker evaluates the acting child's own definition ceiling; dynamic delegation-policy evaluation and proof that a child's policy is a subset are deferred. Existing harness delegation restrictions remain in force. -An instance JWT-SVID identifies the agent; it does not select the policy representation or prove that a child policy is a valid subset. Policy storage, token-carried restrictions, and subset evaluation remain separate design choices. +An instance JWT-SVID identifies the agent; it does not prove delegated authority. Enforcing task-specific delegated limits at the broker is a future requirement, not a property of the v1 Cedar check. ## Operations and resources -Authorization must distinguish the requested operation from the resource it acts on. Permission to call a tool does not imply permission to use it against every resource or with arbitrary arguments. +V1 distinguishes registered MCP servers and tools, but does not evaluate resource or argument policies. Permission to call a tool is not evidence of authorization for a particular repository or PR. User, resource, argument, and dynamic delegation policies are deferred; v1 does not provide comprehensive enterprise user-policy enforcement. + +For example, a reviewer whose resolved ceiling includes `pull_request_read` on the registered payments server can pass the ceiling check for a read, but cannot pass it for `pull_request_merge` if merge is excluded. Reading PR 42 and reading PR 43 with that same server/tool pair produce the same ceiling result. The broker's v1 Cedar check cannot establish that the reviewer stayed within Alice's PR-42-only request. Existing connection and downstream checks still apply to either call. Agent identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. @@ -20,8 +40,37 @@ Agent identity does not prevent a prompt-injected agent from misusing operations Mecak8s reports the initiating user from the verified session owner. The broker authenticates the Mecak8s workload and relies on its user report under an explicit trust relationship; it does not independently authenticate the human from that report. Model-generated arguments must not choose or override the reported user. -The broker authorizes the actual MCP server, tool, provider connection, and complete tool arguments that will be used. Permission to read `acme/payments` PR 42 does not grant permission to read a different repository or pull request merely because the tool name is unchanged. +The broker checks the actual MCP server/tool pair against the ceiling and retains existing checks on the resolved provider connection. The final request, including arguments, must remain bound to those checks and dispatch, but recording or binding arguments does not mean v1 Cedar authorizes their resource-specific meaning. + +Request transformations must occur before the applicable checks, or the changed request must be checked again. Dispatch must use the checked request and provider connection. + +## Two requests, two authorization decisions + +In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), Alice owns session `S42`. The main instance lists pull requests, then delegates review of PR 42 to a reviewer. The tool names and arguments are illustrative, not a selected MCP server contract. + +| Request | V1 definition-ceiling check | +| --- | --- | +| Main run `R41`: `pull_request_list(repository=acme/payments)` | The registered main's generated policy must permit the resolved server and list tool. Main has no implicit exemption. | +| Reviewer run `R42` via `D42`: `pull_request_read(repository=acme/payments, number=42)` | The registered reviewer's generated policy must permit the resolved server and read tool. `D42` records the delegated task but is not a v1 Cedar policy input. | + +Both requests also require valid credentials and trusted execution-context binding, and must pass existing local, connection, and ToolHive checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of delegated bounds will need trusted evidence of those bounds. + +For both calls, connection access includes authorization for Alice to use the ToolHive subject and provider account behind the connection. The walkthrough assumes this association for `github-conn-42`; successful OAuth or matching account names cannot establish it. The [association mechanism](credentials-and-lifecycle.md#provider-connection-selection) remains unresolved. + +## Authorization, credentials, and dispatch + +Local permissions and hooks apply before invocation. The broker then authenticates the caller and actor, verifies execution context, and evaluates the final operation and connection. Credential readiness determines whether an eligible request can proceed, not whether it is authorized. + +| Outcome | Dispatch behavior | +| --- | --- | +| Denial | Invalid credentials or context, or denied policy, dispatch no backend operation. | +| Enrollment required | Missing enrollment or required user reauthorization dispatches nothing. mecak8s pauses and presents the broker's enrollment link. After completion, the pending request retries through all checks; enrollment does not imply permission to execute. | +| Allow and credentials ready | The broker dispatches only the exact authorized request using the authorized connection. The allow covers this call, not later calls, spawning, or run execution. | + +Missing enrollment can leave authorization incomplete. Refreshable expiry is handled in credential custody where possible; otherwise the broker requires user reauthorization. Missing, invalid, stale, or revoked identity, connection, or authorization evidence must fail closed, without fallback to a service credential, another user, or a more privileged agent. + +When needed, the broker obtains a current ToolHive user access token and exchanges it with the acting instance's SVID. ToolHive's authorization server independently validates the user and actor tokens and broker client, and authorizes the requested target and access bounds. The protected vMCP independently validates the delegated token, applies operation policy, and resolves the authorized connection before using ToolHive's native provider credential. Neither downstream check replaces the broker's exact-operation decision. Credentials stay in their designated custody, outside model history; see [credential roles and custody](credentials-and-lifecycle.md#credential-custody). -Request transformations must occur before the authorization check, or the changed request must be checked again. Dispatch must use the request and provider connection that were authorized. +Cached credentials are not cached authorization. Every subsequent call repeats the definition-ceiling evaluation and existing connection and downstream checks; any delegated-token reuse must stay within the execution context for which it is permitted. -See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed issuance, enrollment, exchange, and dispatch sequence. +The authorization decision, dispatch, and observed outcome are separate audit facts. An allow does not prove dispatch, and dispatch does not prove provider success. Each requires durable evidence without recording credential values; persistence ordering and authenticated cross-service correlation remain unspecified. An unknown outcome must not be automatically replayed merely because credentials were recovered. See [dispatch and observed outcome](credentials-and-lifecycle.md#8-dispatch-and-observed-outcome) and [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart). From 418c652e759d1198f5d993bbfeab0418961e764f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 12 Oct 2026 19:33:25 +0200 Subject: [PATCH 09/50] docs: add MCP gateway modes chapter for AS chaining Describe one protocol for reaching a ToolHive vMCP gateway in embedded and external mode: the broker's grant issuer (AS-1) signs an identity-chaining grant that the gateway's authorization server (AS-2) redeems. Split the README diagram into broker and gateway, and list the pending changes to the credentials walkthrough. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 18 +- .../agent-identity/mcp-gateway-modes.md | 224 ++++++++++++++++++ 2 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 docs/drafts/agent-identity/mcp-gateway-modes.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 26ae5ae3b1..669a405075 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -50,14 +50,21 @@ flowchart LR U["Client"] -->|"Session creation and requests"| H["mecak8s"] F["Agent-definition sources"] --> R["Agent-definition loader (placement TBD)"] R -->|"Execution configuration"| H - subgraph B["Identity broker (combined deployment)"] + subgraph B["Identity broker"] I["Identity issuance and authorization"] - T["ToolHive AS, credential storage, and vMCP"] - I <-->|"Authorized operations"| T + G["Grant issuer (AS-1)"] + I --> G + end + subgraph W["vMCP gateway (embedded or external)"] + T["Gateway AS (AS-2) and credential storage"] + V["vMCP"] + T --- V end R -->|"Definition records and authority ceilings"| I H <-->|"User report, tool requests, and results"| I - T <-->|"Protected calls"| P["MCP backends / providers"] + G -->|"Chaining grant"| T + I <-->|"Authorized MCP calls"| V + V <-->|"Protected calls"| P["MCP backends / providers"] I -->|"Decisions and observed outcomes"| A["Audit records"] ``` @@ -65,7 +72,7 @@ The diagram shows the broker-backed remote architecture's MCP path. Local deploy Before the agent loop starts, the agent-definition loader supplies `mecak8s` with execution configuration and initializes the corresponding definition records and authority ceilings in the broker. The loader is outside the untrusted agent loop. Its process placement and how the broker authenticates its input remain to be specified. -The broker contains the agent trust issuer alongside ToolHive's authorization server, credential storage, and vMCP. The issuer signs agent identity credentials; the ToolHive components handle access to downstream tools. The tools themselves execute the operations. +The broker contains the agent trust issuer, the authorization and audit logic, and a grant issuer (AS-1). The vMCP gateway contains its own authorization server (AS-2), the provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or be operated separately, such as an enterprise connector gateway. In both cases AS-1 vouches for the user and acting agent with a short-lived grant that AS-2 redeems; see [MCP gateway modes](mcp-gateway-modes.md). The tools themselves execute the operations. The remaining trust question is how the broker knows that the reported user and agent definition are genuine. The agent loop must not be able to substitute either. Giving agents distinct credentials helps identify them, but does not isolate agents sharing one `mecak8s` process. @@ -88,6 +95,7 @@ The proposed chapters develop each part of the architecture. | [Audit](audit.md) | Define attribution, correlation, evidence, redaction, and durability requirements. | | [Agent definitions](agent-definitions.md) | Explain `AgentDef` and separate harness execution configuration from broker-enforced authority. | | [MCP authorization](mcp-authorization.md) | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | +| [MCP gateway modes](mcp-gateway-modes.md) | Chain the broker's grant issuer to the vMCP gateway's authorization server, so one protocol works with an embedded or an external gateway. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | | [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md new file mode 100644 index 0000000000..441534e31a --- /dev/null +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -0,0 +1,224 @@ +# MCP gateway modes + +*This chapter describes the intended architecture, not current implementation status.* + +Mecatl can reach protected MCP tools through a ToolHive vMCP gateway in two ways. In **embedded mode**, the vMCP and its authorization server run inside the Mecatl deployment. In **external mode**, Mecatl points at a gateway that someone else runs, such as an enterprise connector gateway. This chapter describes one protocol that works in both modes, so the broker's authorization and audit stay the same wherever the gateway runs. + +## Who holds what + +| | Embedded mode | External mode | +| --- | --- | --- | +| Broker authorization and audit | Mecatl | Mecatl | +| Agent-instance JWT-SVIDs | Mecatl (broker issuer) | Mecatl (broker issuer) | +| Grant issuer (AS-1) | Mecatl, inside the broker | Mecatl, inside the broker | +| Gateway authorization server (AS-2) | Mecatl, separate issuer and key | Gateway operator | +| Provider credentials (GitHub, Google) | Mecatl's AS-2 storage | Gateway operator | +| Trust in AS-1 | Local configuration | Gateway administrator's configuration | + +## Two authorization servers + +The vMCP accepts only access tokens issued by its own authorization server. It finds the user's provider credentials through that token, so whichever server holds Alice's GitHub token must also issue the token the vMCP accepts. That server is **AS-2**. + +AS-2 cannot authenticate Mecatl's users or agents by itself: it does not know which agent instance is acting or which session it belongs to. That knowledge lives in the broker. So the broker runs a second authorization server, **AS-1**, whose only job is to vouch for "this user, with this agent acting" in a form AS-2 can verify. + +The two servers do not share sessions or storage. AS-1 signs a short-lived grant addressed to AS-2, and AS-2 redeems it. This is [OAuth Identity and Authorization Chaining Across Domains](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/) (`draft-ietf-oauth-identity-chaining-17`): a token exchange at the first server produces a JWT authorization grant, and the client presents that grant to the second server with the RFC 7523 JWT-bearer grant. + +## Following one call + +Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, asks Mecatl to read PR 42 in `acme/payments`. The broker has already authorized the reviewer's `pull_request_read` call (see [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization)). The gateway in this example is external, at `https://gateway.example.com`. + +```mermaid +sequenceDiagram + participant B as Broker + participant A1 as AS-1 (broker) + participant A2 as AS-2 (gateway) + participant V as vMCP + participant P as GitHub + + B->>A1: (1) Token exchange: subject evidence for Alice + reviewer JWT-SVID + A1->>A1: Check the reviewer belongs to a run Alice owns + A1-->>B: (2) JWT authorization grant, aud = AS-2 + B->>A2: (3) JWT-bearer grant, authenticated as client mecatl + A2->>A2: Check issuer, audience, jti, client, subject, actor + A2-->>B: (4) Access token: Alice, reviewer acting, aud = vMCP + B->>V: (5) pull_request_read + access token + V->>V: Resolve Alice's GitHub credential + V->>P: Read PR 42 + P-->>V: Result + V-->>B: Result +``` + +Steps (1) to (4) run only when the broker has no valid cached access token for this user, instance, and vMCP. + +### (1) Token exchange at AS-1 + +The broker asks AS-1 for a grant addressed to AS-2. The decoded form parameters: + +```text +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +requested_token_type=urn:ietf:params:oauth:token-type:jwt +audience=https://gateway.example.com +subject_token= +subject_token_type= +actor_token= +actor_token_type=urn:ietf:params:oauth:token-type:jwt +``` + +`audience` names AS-2, not the vMCP: the chaining draft (§2.3.1) uses it to identify the second authorization server. The vMCP resource appears in step (3). + +Before issuing, AS-1 checks more than token validity. It confirms that the reviewer instance belongs to a run whose owner is the subject. A valid user token paired with an unrelated agent's credential is rejected (`actor_unauthorized` in `draft-mcguinness-oauth-actor-profile-01` §6.6.2). + +The broker issued the reviewer's JWT-SVID and presents it to its own AS-1. The actor in the grant therefore rests on the broker's records, not on independent evidence. + +### (2) The grant + +```json +{ + "iss": "https://broker.example.com/as", + "sub": "alice-204", + "sub_id": { + "format": "iss_sub", + "iss": "https://login.example.com", + "sub": "alice-204" + }, + "aud": "https://gateway.example.com", + "client_id": "mecatl", + "act": { + "iss": "spiffe://agents.example.com", + "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m" + }, + "jti": "grant-pr42-001", + "iat": 1800000000, + "exp": 1800000120 +} +``` + +The grant is a plain JWT, as in the chaining draft's own example. Neither the draft nor RFC 7523 defines a `typ` value for it. It cannot be confused with an access token: its issuer is AS-1 and its audience is AS-2, so the vMCP rejects it, and the vMCP accepts only `at+jwt` tokens (RFC 9068). + +- `sub_id` (RFC 9493) states the company identity as an issuer and subject pair. AS-2 resolves the user from it, never from `sub` alone. RFC 9493 allows these members to differ from the grant's own `iss` and `sub`, as long as both name the same user. +- `client_id` is Mecatl's client identifier at AS-2, which may differ from its identifier at the company IdP. The claim and its check come from ID-JAG (§3.1, §4.4.1): AS-2 must match it against the authenticated client. +- `act` (RFC 8693) names the reviewer instance. `act.iss` is the SPIFFE trust domain, because JWT-SVIDs carry no `iss` (actor-profile §6.4). +- The grant lives for two minutes and can be redeemed once. + +### (3) JWT-bearer grant at AS-2 + +```text +grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer +assertion= +resource=https://gateway.example.com/mcp +``` + +The broker authenticates to AS-2 as client `mecatl`, for example with `private_key_jwt`. AS-2 checks: + +- the grant's issuer is a trusted AS-1, with its key pinned or fetched from AS-1's configured `jwks_uri` +- `aud` is AS-2, and `exp - iat` is at most 300 seconds +- `jti` has not been seen before, using a replay store shared by all AS-2 replicas +- `client_id` matches the authenticated client +- `sub_id` resolves to an existing, active user, through the same directory lookup that the company SSO login uses +- `act.iss` and `act.sub` match the actor allowlist configured for AS-1 + +### (4) Access token + +```json +{ + "typ": "at+jwt", + "iss": "https://gateway.example.com", + "sub": "usr-alice-7c1d", + "aud": "https://gateway.example.com/mcp", + "client_id": "mecatl", + "act": { + "iss": "spiffe://agents.example.com", + "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m" + }, + "iat": 1800000005, + "exp": 1800000120 +} +``` + +`typ` is shown in the payload for readability; it belongs in the JOSE header. `sub` is AS-2's own identifier for Alice, the same one her enrollment produced. AS-2 issues no refresh token (chaining §5.4), and the access token expires no later than the grant. The broker caches it per user, instance, and vMCP. Where the vMCP verifies DPoP (RFC 9449), the token is bound to a broker key. + +### (5) Dispatch + +The broker sends `pull_request_read` with the access token. The vMCP validates it, looks up Alice's GitHub credential by user, and injects it into the backend request. GitHub sees Alice's account. The access token records which agent acted, and the broker's audit record holds the rest of the story. + +## Subject evidence + +AS-2 trusts AS-1's word about which user is acting. The evidence AS-1 requires determines what the gateway administrator is trusting. + +**Stepping stone (A2).** AS-1 trusts the session owner that mecak8s verified, presented as a JWT that the mecak8s workload signs. No company token is involved. A compromised Mecatl could act as any user that AS-2's policy allows. This phase exists only to bring the chain up before A1, and requires the AS-2 checks below. + +**Target (A1).** The broker holds Alice's company refresh token, uses it only at the company IdP, and presents one of the following to AS-1: + +- an access token from the company IdP with `aud` set to AS-1, where the IdP can issue one (preferred) +- a fresh ID token whose `aud` is Mecatl's client identifier (fallback) + +AS-1 validates it against the company IdP. A user deactivated at the IdP can no longer refresh, so AS-1 stops issuing grants for them. The broker never sends a company access token addressed to another audience, or the refresh token itself, to AS-1. + +## What the gateway administrator configures + +In external mode, the administrator decides how much to trust AS-1: + +- AS-1's issuer and `jwks_uri`, or pinned keys +- the Mecatl client registration and its authentication method +- the company issuers and users AS-1 may assert, and the actor prefix it may assert, such as `spiffe://agents.example.com/mecatl/*` +- the vMCP resources Mecatl may request +- rate limits per user and per actor +- a switch to revoke trust in AS-1 + +Every redemption is audited at AS-2 with `jti`, the resolved user, `act`, `client_id`, `resource`, and the decision. + +## Why identity chaining and not ID-JAG + +The [Identity Assertion JWT Authorization Grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/) (ID-JAG, "Cross App Access") has the same message shape, but `-04` requires three things AS-1 cannot provide: + +- the grant's issuer must be the IdP that AS-2 already trusts for SSO (§4.1) +- the `subject_token` must have been issued by that IdP (§4.3) +- `sub` must be in the issuer's namespace (§3.1) + +AS-2 logs users in through the company IdP, not through AS-1. The chaining draft has none of these constraints and allows the first server to add or transcribe claims (§2.3.2, §2.5), which covers `act` and `sub_id`. + +A company IdP that issues ID-JAGs can later serve as a second, parallel path for calls that only need the user's identity. Okta's documented implementation does not name the acting agent and limits ID-JAG volume per user, so it does not replace AS-1. + +## Sessions at the vMCP + +Each agent instance has its own vMCP session (`Mcp-Session-Id`). The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. + +- Sessions are never shared between instances. +- The vMCP binds each session to the user and the acting instance (`iss`, `sub`, `act.iss`, `act.sub`). A token naming another instance cannot use the session. +- The run is not part of the session or token. The run ID travels in trace headers. +- The broker stores each instance's session ID. After a restart it reattaches. When the vMCP reports the session gone (idle timeout or eviction), the broker creates a new one. A wedged session can be reset explicitly. +- An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. + +## Embedded mode + +Embedded mode runs AS-1, AS-2, and the vMCP in one deployment and uses the same messages over loopback. The two authorization servers keep separate issuer URLs, signing keys, and storage namespaces, and AS-2 trusts AS-1 through local configuration. + +This keeps one code path for both modes. It does not add isolation: anyone who compromises the process holds both keys. Distinct issuers and audiences are what keep a grant from being used as an access token. + +## Outside this chapter + +- **Missing provider connections.** In external mode, the gateway is the only party that knows Alice has not connected GitHub. How it reports that, and how the broker shows Alice a connection link, is a separate design. +- **Revocation** beyond short lifetimes and IdP refresh failure. +- **Gateway authorization on `act`**, such as Cedar policies that consider the acting agent. + +## Required ToolHive changes + +- AS-1: token exchange that issues a JWT grant for another authorization server, accepting a SPIFFE JWT-SVID as `actor_token`. +- AS-2: a per-issuer policy on the JWT-bearer grant path for chaining grants, with real client authentication, `client_id` binding, single-use `jti`, and `act` preserved and validated. +- AS-2: resolve grant subjects from `sub_id` through the same user directory as SSO login, replacing the `issuer#subject` form and the exact `subject_bindings` list. +- vMCP: find provider credentials by user, gated by grant issuer, client, resource, and provider, not by `sub` alone. Tokens that carry `act` must still be able to receive provider credentials. +- vMCP: include the acting instance in the session binding. +- Connector gateway: expose trusted-issuer and inbound-grant configuration. +- Two authorization servers in one process: separate storage prefixes and persistent keys. + +## Pending changes to credentials and lifecycle + +The [credentials and lifecycle](credentials-and-lifecycle.md) walkthrough still describes a single ToolHive authorization server, with the broker holding a ToolHive user grant. It needs these changes to match this chapter: + +- **Instance credential audience.** The intro and step (4) give the JWT-SVID two audiences, the broker and the ToolHive token endpoint. It needs only the broker's, because AS-1 runs inside the broker. +- **Sequence diagram and legend.** The single exchange at ToolHive AS becomes the two hops in [following one call](#following-one-call): a token exchange at AS-1, then a JWT-bearer grant at AS-2. Both run only when no cached access token is valid. +- **Step (5), enrollment.** Alice enrolls at AS-2 through company SSO. AS-2 links the grant to her by resolving `sub_id` through the same directory lookup, which settles the association that step (5) leaves open. The broker no longer holds a ToolHive user grant. +- **Step (7).** It becomes a short version of the two hops and the access token, which now carries `act.iss` and `client_id=mecatl`. Grant details link here rather than being repeated. +- **Step (8).** Only the example values for the OAuth client and token issuer change. +- **Credential roles, custody, and lifetimes.** The ToolHive user grant role and its refresh path go away. They are replaced by subject evidence (see [subject evidence](#subject-evidence)) and the chaining grant. AS-2 issues no refresh token; the broker requests a new grant instead. +- **Retained login tokens (open decision).** Step (1) and scheduled runs say human login tokens are not retained. Under A1, the broker keeps the company refresh token in custody and uses it only at the company IdP. That principle needs rewording, or A1 needs revisiting. From f75b378c648c75a57cb55c512b0406bbd8804e76 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 12 Oct 2026 19:35:19 +0200 Subject: [PATCH 10/50] docs: retire superseded agent identity drafts Remove the former identity model, authority design, and implementation notes, now superseded by the broker-centered design in docs/drafts/agent-identity/. Point the drafts index and companion drafts at the new design and mark the outbound draft as historical; the removed documents remain in Git history. Co-Authored-By: mecatl --- docs/agent-authority-implementation.md | 561 ---------- docs/agent-authority.md | 314 ------ docs/drafts/README.md | 4 +- docs/drafts/agent-identity-model.md | 1258 ----------------------- docs/drafts/agent-identity-outbound.md | 9 +- docs/drafts/agent-identity/README.md | 2 +- docs/drafts/cloud-native-harness-kit.md | 11 +- 7 files changed, 15 insertions(+), 2144 deletions(-) delete mode 100644 docs/agent-authority-implementation.md delete mode 100644 docs/agent-authority.md delete mode 100644 docs/drafts/agent-identity-model.md diff --git a/docs/agent-authority-implementation.md b/docs/agent-authority-implementation.md deleted file mode 100644 index c084f3d3df..0000000000 --- a/docs/agent-authority-implementation.md +++ /dev/null @@ -1,561 +0,0 @@ -# Agent authority: implementation evidence and remaining work - -Companion to the [agent-authority design](agent-authority.md). This document holds -branch pins, observed behavior, unresolved mechanisms and implementation work. The -main page states the intended requirements; none of the snapshots below establishes -the complete interactive flow. - -## Evidence baseline: separate branches, not one product - -These are inspected snapshots, excluding uncommitted changes and later commits. -Re-pin before implementation. Individual branch tests do not prove their combination. - -### Current investigation pins - -| Label | Source and pin | Observed scope and limits | -|---|---|---| -| **I** | Mecatl `acc/singleton-identity-mcp-integration` — `2ee96819939367e5f7c57fd48ead21fca828bd0a` | Harness-attested user evidence, logical-agent issuance and exact-call Execute admission. Issuer lacks human freshness; Execute checks it separately. | -| **B** | Mecatl `acc/singleton-mcp-broker-review-remediation-squash` — `06d1ecc2c12d6533e060d90809f4425c8d78294f` | External broker, ToolHive OAuth and guarded encrypted credential recovery; recovery does not preserve the original canonical human subject. | -| **B2** | Mecatl `broker-simple-take-2` — `0d9467664c7a496b8dda287183f89dedea3ec786` | Workload-authenticated broker sessions, browser OAuth, account continuity, encrypted custody and recovery. Initial caller-to-account authorization, instance-SVID exchange, and executing-run binding remain integration work. | -| **W** | ToolHive `spiffe-v2-14-docs` — `36f85e4b0f6b9e449fd2099ec164bb1204ded5cc` | SPIFFE WIP; distinct logical-agent actor validation and exchanged-token upstream linkage remain gaps. | -| **S** | Mecatl `broker-simple/04-toolhive-backend` — `e3ce6b374e74847c79f5ab9b69b987693d4540d6` | Newer committed-connection mapping with revision/provider checks; not B's storage implementation. | - -**I and B depend on ToolHive v0.50.0, not W.** These pins must not be combined into -one shipped implementation. **P**, below, is the historical richer issuer baseline; -its issuer-admission guarantees must not be attributed to I. - -### Historical evidence and branch relationships - -| Source and pin | What it supplied at that snapshot | What it did not establish | -|---|---|---| -| `origin/main` — `a711451616edf84f50e793c9bcbfb697ef400347` | Caller/owner checks, carried authority, exact placement, dispatch/event logging and configured in-process MCP broker paths | Completed external user/agent admission or exhaustive shell-effect evidence | -| `handover/agent-mcp-authority-restart` — `ed319f45cd972de3cb95031b39e57b8eec425789` | Issuer signing/verification, logical-agent credentials, adapter-neutral acting-as-user exchange contract | Production ToolHive exchange or provider credential lookup | -| Earlier broker squash — `53eab6b753869b33c317da49a67295575807ac61` | TLS/workload-OIDC remote broker, callback lifecycle, process-local Execute receipts, configured encrypted custody recovery | Logical-agent admission, fresh user proof, HA or durable operation outcomes | -| **P**: identity integration — `db5bcd8845f87f05fa0c0efecb2092fd6c5e497c` | Constrained issuer, live exact-call Cedar admission and ToolHive fixture tests | Independently verified human evidence, scheduled grants or durable broker audit completion | -| `workload-identity` — `b6dc951c091197bcb4c3ec444a80308d43dc9e5a` | Historical broker-owned SPIFFE custody/proxy experiment | Selected production transport or an integration dependency | - -At these historical pins, main was an ancestor of the squash, which superseded the -older review-remediation branch. Identity, squash and integration diverged at -`038b5739`; neither squash nor integration contained the other. P adapted the issuer -slice but had no `internal/actingaccess`: its broker-local admission was not simply -the donor exchange contract wired to ToolHive. These ancestry observations are not -an assertion about later I/B/S integration. - -## Observed implementation - -### Baseline and broker protections to preserve - -Pinned main authenticates callers, checks ownership, reattaches exact placement, -applies local permissions/hooks/authority and records effective tool results. -Child [derivation and resume checks][delegation] prevent a broader definition from -restoring authority absent from its parent. These are harness checks, not signed -external grants. Ownerless compatibility exists with enforcement disabled; a shared -static bearer does not establish individual users. - -The historical external broker authenticates workload OIDC over TLS, including -Kubernetes OIDC discovery/JWKS—not TokenReview. Workload/session-owned attachments -and receipts protect lifecycle, not the complete user/actor conjunction. Identical -calls within a receipt lease may join or return a recorded result; changed content -is rejected. Receipts do not survive process replacement. - -Configured encrypted custody can recover a **fresh attachment** using exact -session/incarnation, workload/owner partitions, profile/provider, recovery-reference -and deadline checks. An owner partition is an isolation guard, not human proof. -Credential recovery does not recover a callback, Execute receipt or effect. Sources -at the historical broker pin: [authentication][broker-auth], [custody RPCs][custody], -[protected storage][storage], [Execute receipts][receipts]. - -The alternate workload-identity proxy kept downstream workload credentials behind -the broker. Its [recorded spike results][track-b] include missing-subject, -path-containment and alternate-SVID bypass limitations. Those are historical findings, -not current-broker claims. Neither that experiment nor an SA-first checkpoint is a -substitute for verified logical-agent identity in the full design. - -### Historical P: live admission and profile findings - -P's [identity wrapper][wrapper] turns matching current user authentication into -harness-attested facts. Its workload-authenticated [issuer ingress][ingress] carries -resolved definition, tool authority and exact execution facts. Invocation verifies -workload and agent JWT-SVID, consumes matching presenter/owner/actor/expiry, -attachment/incarnation, call ID, argument digest and occurrence, then applies -registered-target checks and Cedar. Changing PR 42 to PR 43 cannot reuse admission; -a valid SVID cannot skip policy. See [one-use admission][admission] and -[Cedar gate][cedar]. - -P's [real-ToolHive fixtures][fixtures] include an admitted write and denied -credential/backend counters. Their presence is not a fresh test run or Kind proof. -Its broker-owned confidential OAuth client uses authorization code and refresh, -not an integrated user-subject/logical-agent-actor exchange. - -The [integration ADR][integration-adr] records P's trusted-attester relationship. -Earlier design handover sections 10/13 instead requested a distinct fresh broker/AS -user assertion from which the broker derives owner. The main design now explicitly -trusts harness-attested authentication and reuses ToolHive's existing subject token; -it does not select that older extra assertion layer. Audience restriction alone -never supplied independence. The remaining authorized account-association mechanism -is still unselected, and local permission provenance does not prove every -subject-authority/consent ceiling of the [acting-as-user conjunction][exchange-adr]. - -Two source-review findings at **P**, not fixes or live reproductions: - -- Ordinary Kubernetes SA tokens are classified as user grants, while issuance - requires a client-credentials grant. That blocks the ordinary-SA - [stage B](agent-authority.md#example-flow-serviceaccount-plus-agent-instance-jwt-svid) flow at - this pin, between [GrantTypeFromClaims](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/engine/session/principal.go) - and [IssueAttested](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/host.go). -- The [bundle-format gap](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/bundle.go) - prevents claiming standard SPIFFE bundle interoperability merely because the - local issuer/verifier agree. The reported fields are `sequence`/`refresh_hint` - rather than SPIFFE-prefixed fields, with a missing JWT-SVID JWK usage marker. - -Recheck these defects against I before claiming either persistence or resolution. -Stage A does not require the logical-agent issuer; C requires new workload support -as well as qualified issuer compatibility. - -P custody source navigation (not I's current issuer contract): -[identity client](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/identityissuer/client.go), -[trust configuration](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/cmd/mecak8s/mcp_identity.go), -[issuer ingress][ingress], [gRPC presentation/verification][admission], -[ToolHive target](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbroker/toolhive_process.go) -and [OAuth flow](https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbroker/auth.go). -The donor [issuer substrate][issuer-adr] and [logical-agent projection][agent-adr] -record the earlier identity slices, not completion of the intended exchange. - -### Current I: freshness is checked separately from issuance - -At I, the API creates request-local `AuthenticationEvidence`: owner issuer/subject, -`VerifiedAt` (API bearer-verification time, **not human login time**), `ExpiresAt` -(bearer expiry) and random `RequestID`. Verification requires a non-future age of -at most five minutes, expiry after now and a nonempty request ID; broker Execute -repeats these checks. The ID is **not a replay nonce**. The login bearer is neither -persisted nor forwarded. Workload-authenticated TLS/gRPC protects this trusted -harness attestation; it is not independent human proof against a compromised harness. -Sources: [API evidence creation][current-authn], [evidence contract][current-evidence]. - -Dispatch derives effective execution facts after local permissions and hooks. -I's issuer receives owner, resolved definition, tools and exact-instance hash, but -**not human freshness**: it cannot cap the SVID lifetime at human-token expiry. -Execute separately checks fresh evidence, owner, attachment, workload, SVID, exact -call and Cedar. Its [detached execution context][current-execute] drops incoming -evidence context; the target uses the attachment credential. There is **no integrated -user-subject/logical-agent-actor exchange**. HTTPS issuance and TLS-protected gRPC -invocation are distinct harness-to-broker paths; issuer and admission share -broker-owned admission state. - -Exact receipts and JTI tracking remain process-local. Restart must fail closed, -not imply durable single-use protection. Retrieving a started receipt is not a -new dispatch. The main's [interactive flow](agent-authority.md#5-interactive-execution-flow) -is a normative design, not an execution trace of I or W. - -### B, W and S: credential continuity is not subject continuity - -B already captures a verified `tsid` and preserves encrypted custody across -recovery. Storage encrypts under session/provider/field; it neither maps users nor -rewrites JWTs. B's recovered token uses `mecatl-recovered:v1:ownerPartition`, not the -original canonical ToolHive user subject. Recovering the right credentials therefore -does not establish the required user-and-agent token meaning. - -W's exchange omits the upstream link during session construction; the MCP loader -returns on a missing JWT `tsid` before consulting storage. A storage decorator alone -cannot fix a token that never reaches it. W also restricts the SPIFFE actor to the -presenter: distinct logical-agent support needs validation and an authorized -association, not simply removal of that check. S's committed-connection mapping -with revision/provider checks is separate-branch work, not B's behavior. - -The [credential-continuity brief](toolhive-credential-continuity-followup.md) owns -the detailed ToolHive task, code navigation, minimal issuance/storage handoff and -test matrix. It supersedes the [SPIFFE brief](toolhive-spiffe-followup.md)'s earlier -new-user-evidence exploration for subject reuse and linkage; the SPIFFE brief -still covers separate logical-agent actor support. Do not duplicate those adapter -details or introduce a new opaque user-evidence platform here. - -### B2: enrollment and account continuity to reuse - -B2 is the starting point for extending the broker's connection machinery. The -following findings come from source and test inspection at the pinned commit; -the tests were not run during this review. - -Mecatl records the initiating principal and checks Connect ownership when ownership -enforcement is enabled. Broker RPCs authenticate the workload instead. Production -`OWNERLESS` partitions attachments by workload and deployment; it does not mean -that ToolHive has no OAuth user, or that all sessions share one account. Each -broker session can establish its own connection baseline. See the -[host Connect path][b2-connect] and [broker partitioning][b2-partitions]. - -Enrollment binds OAuth state and the retained PKCE verifier to the broker's -logical session and authorization transaction. The callback rejects stale or -replayed state and unexpected selectors, then exchanges the code. Authenticated -observation performs ToolHive discovery before publishing the connection and -catalogue. The broker records an account digest over the configured providers' -verified user, upstream subject, and client identities. Later reauthorization or -recovery rejects a changed account. Explicit disconnect clears the baseline and -invalidates parked work before another connection is selected. Sources: -[callback][b2-callback], [connection publication][b2-publication], and -[account continuity][b2-account]. - -This establishes transaction correlation and account continuity, not the initial -caller's permission to use that account. If Alice owns the Mecatl session but Bob -completes its genuine first enrollment URL, Bob's authenticated account can become -the baseline while Alice remains the session owner. That outcome follows from the -inspected flow; no dedicated first-connection Alice/Bob test was found. It can be -legitimate account sharing, but the flow does not establish that Alice authorized -that selection. A later changed-account continuation is rejected. The -[production tests][b2-production-tests] cover that continuation check, not proof -that the first browser user equals the initiating Mecatl user. - -Preserve B2's references, catalogue checks, credential custody, and host-side -unknown-outcome fence. Its attempt identifier is not a durable idempotency key: -the [occurrence test][b2-occurrences] deliberately demonstrates repeated execution -of the same attempt. Session and account references also do not establish an -executing-run binding. Production has browser authorization-code and refresh -support, but no integrated logical-agent SVID actor exchange or admitted-run -lifecycle. Those are additions to this foundation, not reasons to replace OAuth -or invent another user-token lifecycle. - -## Current gaps and foundation work - -Complete the identity/broker foundation first. These are requirements to implement -or decisions to select, not claims already satisfied by the snapshots. - -### Reusable identity and run-scoped delegation - -The intended lifetime model now separates a reusable, short-lived agent credential, -run-scoped delegated access and per-call admission. This is not I's implemented -profile: I binds the agent SVID to an exact invocation and tracks its JTI as -single-use. Move occurrence/replay enforcement into call admission before allowing -identity-credential reuse; do not merely remove the existing checks. - -Extend B2's existing session/invocation machinery with an admitted executing-run -association. The first preference is broker-held run context, addressed by a -reference bound to authenticated requests alongside the attachment. Reuse existing -reference machinery where possible; this does not select new RPCs or a token -schema. Obtain the ToolHive user from the broker-held subject token and the actor -from the instance SVID. Preserve the authorized connection association described -below. Bind each actor's own run, retaining parent/root execution links separately -from the instance's creation ancestry. - -Authenticate the initiating human when admitting work. Ordinary expiry of that -login bearer does not end the admitted run; completion, cancellation, and configured -budgets do. Renewal must remain within that run's authority, and explicit policy -withdrawal can restrict subsequent calls. Dependent child access must close with -its parent. An unfinished run can recover only when durable state establishes its -binding; restoring a session or credential alone cannot revive ended work. - -Use short-lived delegated access tokens, renewable only within an active admitted -run. One run can reuse suitable tokens or obtain resource-specific replacements. -Define cache partitioning, actor-transition checks, lifecycle propagation, and restart -behavior without confusing token refresh with new authority. Recipients that do -not check live run status can still accept an issued bearer until expiry. Preserve -unknown-outcome handling for dispatched calls rather than claiming immediate -revocation or replay safety from the run reference. - -### Caller/account association and exchange on B2 - -Reuse B2's enrollment and verified account baseline. The missing association is -which authenticated Mecatl caller may use that connection for an admitted run. -The provider account need not represent the same natural person. State/PKCE and -account-continuity checks do not establish that permission on first enrollment. - -- Select how authenticated run admission establishes the caller's permission to - use the discovered ToolHive/provider account. Owner-bound completion, - authenticated account confirmation, and trusted entitlement mapping are - alternatives, not selected mechanisms. Equal subject strings or emails across - issuers are insufficient. Preserve the accepted association through recovery - without replaying or retaining the human's login bearer. -- Extend the workload-authenticated boundary to carry or resolve the trusted - admission association. B2's injectable owner-partition resolver is not a shipped - per-human production mode, and partitioning by owner alone would not authorize - the first browser account. Keep harness-reported authentication distinct from - facts independently checked by the broker. Reconcile I's per-call freshness - gate with admission-time authentication; do not make an already-admitted run - depend on the continued validity of the original login token. -- Integrate the instance SVID and token exchange into B2's production path. Reuse - the ToolHive auth-code access token as `subject_token`, never an owner label, - attachment handle, or `tsid`; use the instance credential as `actor_token` and - authenticate the broker OAuth client separately. B2's - [client registration][b2-client] currently enables authorization code and refresh. - Configure the exchange grant, eligible resources/scopes, actor validation, and - authorized presenter association. An SVID accepted for broker admission is not - automatically valid at the ToolHive authorization server. -- Preserve or authoritatively resolve canonical ToolHive subject identity across - credential recovery. B2's [recovered bearer][b2-recovered] uses an owner-partition - subject and verified token-session linkage; recovering the connection does not - prove the required delegated token has the original human `sub`. Do not treat - the account digest as a substitute for canonical identity. -- Resolve the authorized provider credential link from validated subject and - connection state. User, tenant, provider, connection/revision, and target must - agree; never accept a caller-selected storage key or external `tsid`. Reuse the - [credential-continuity brief](toolhive-credential-continuity-followup.md) for - ToolHive-specific exchange/storage integration rather than designing a second - OAuth subsystem here. - -Qualify the additions through the real host-to-broker path: admission for the -correct caller/account; first enrollment completed by another account; unauthorized -association and changed-account refusal; cross-instance/run reference substitution; -continued admitted work after login-token expiry; completion/cancellation; and -recovery without account substitution or replay of uncertain effects. An intentional -account-sharing case must be distinguishable from an unauthorized association under -the selected mechanism. Retain B2's existing callback and continuity regressions; -their presence is not a test run of the future combination. - -Detailed association and authorization mechanisms remain separate design work. -These integration requirements do not select a new user-evidence credential, -consent UI, or mission/mandate system. - -### Session-scoped definitions and uniform identity - -PR [#2003](https://github.com/stacklok/mecatl/pull/2003) introduces -`CreateSessionRequest.agent_definition_name`, allowing a main session to use a -named `AgentDef`. PR [#2055](https://github.com/stacklok/mecatl/pull/2055) adds the -mecatui command and picker. The architecture review inspected backend commit -`595be9fd0e139e856a3a18632d25cf7f9985815e` and TUI commit -`cda6eee7374c495f71eda5d9cec1875e5f5dddd6`; these are separate from the -identity-integration snapshot. - -The backend shares definition resolution while retaining separate root and child -engine construction. Preserve that distinction: a definition-bound root must -retain main-session permission asks, user-context handling and guardrails. Reusing -a definition must not make the root behave like a delegated child. - -The inspected implementation persists the selected definition name and authority -ceiling, but does not establish a complete resolved credential identity. Unsupported -restart reconstruction fails closed. It also deliberately excludes definition-bound -roots from the configured broker attachment path; enabling that path requires an -explicit integration contract. - -### Required identity integration - -**Carry one resolved identity through execution.** Trusted definition resolution -should preserve category, namespace, and exact name, with the session incarnation -identifying the instance. Bind these to the session and pass them into execution -context rather than reconstructing them from role strings or authority provenance. -Keep the built-in `system` definitions `main` and `explorer` distinct; their -namespace assignment remains to be specified. - -**Keep configuration, identity and authority separate.** `AgentDef` supplies -configuration; the resolved identity identifies the agent; `Session.Authority` -supplies the effective permissions and ceiling. Do not copy permissions into the -identity value or use the ceiling in place of the invocation’s effective authority. -Identity and authority should be bound through an aggregate-controlled operation -that rejects inconsistent state and rebinding. - -**Use delegation state for root-versus-child checks.** Audit existing role and -`Session.Kind` checks before changing them. Where behavior depends only on -delegation, use the authoritative delegation relationship/depth, with zero -representing the root. Do not introduce a second depth counter. Fork and clear -ancestry must not count as delegation. Retain execution-specific distinctions where -team coordination, parallel execution, scheduling or debugging actually require them. - -**Preserve definition association across lifecycle operations.** A persisted name -alone does not establish which namespace and source supplied it. Resume/recovery -retains the instance incarnation and pinned definition revision. Peer fork/clear -retains the definition identity but creates a new incarnation and resolves the -current revision. Detailed revision/dependency retention is separate design work; -unsupported recovery must fail explicitly rather than select another definition -or silently update the existing instance. - -Choose a recoverable, issuer-verified source identifier for the instance and each -creation-ancestry hop; the readable category/name is insufficient. Source adapters -provide provenance without requiring the engine to understand Git, Kubernetes or -database revision formats. Decide whether associated state or a versioned application -claim carries what a verifier needs. A source-native revision can miss dirty local -content, while a digest cannot reconstruct it; specify coverage and snapshot -retention before relying on either. Keep private repository locations and filenames -in guarded records rather than readable JWTs. Missing optional claims imply nothing, -and claims repeating path fields must agree with them. Identifier, transport, claim -fields and source-specific mapping remain open. - -**Integrate named roots with the broker without widening authority.** Define which -broker tools a selected definition may expose, intersect discovery with its bound -ceiling, and support the required attachment and continuation paths. Do not bypass -the PR’s broker exclusion merely by attaching the deployment’s default catalog. - -**Keep credential projection at the adapter boundary.** The engine carries the -resolved identity and checked execution facts. The issuer adapter converts these -into its credential representation; it must not assume that every root is -`system/main` or parse diagnostic role strings to discover an agent’s identity. - -Verify that the same definition used as a main agent and delegated specialist -retains the same definition reference while producing distinct instance subjects -and run/call bindings. Cover built-in definitions, source/namespace collisions, -fork/clear behavior, definition changes on restore, child narrowing, broker discovery -beyond the ceiling, and preservation of root permission asks and guardrails. Unsupported -execution paths must remain rejected rather than gaining credential issuance -implicitly. The concrete core representation and migration remain to be selected; -these requirements do not mandate constructing every root through an `AgentDef` -or replacing the existing root/child engine builders. - -### Integration, deployment and A–D qualification - -Reconcile identity integration and broker squash without silently merging their -claims. Resolve/retest credential-profile defects and qualify allow/deny, -target/argument substitution and narrowed-child authority through the real remote -path. A malicious reviewer cannot gain merge permission by spawning a broader -specialist; a separately authorized top-level deployer can act only within all its -ceilings. Coverage is registered protected MCP, not every agent action. Issuer -enablement is tracked by #478. - -| Design stage | Delivery position and outstanding work | -|---|---| -| A: workload-only SA | Selected first broker implementation/checkpoint. Narrower than logical-agent admission; never silently substitute SA identity for an actor credential. | -| B: SA + logical agent | Substantially implemented on the identity-integration branch, but needs profile correction/revalidation and deployment qualification. Historical P findings are not proof of current I behavior. | -| C: SPIRE JWT-SVID | Planned, not wired. Implement Workload API acquisition, workload allowlists, credential renewal and platform trust-bundle refresh separately from broker logical-agent keys. | -| D: SPIRE X.509-SVID | Possible later move, not a promised selectable alternative. Design TLS termination, identity propagation, certificate/bundle rotation and connection renewal together, with no silent bearer fallback. | - -Whether earlier authentication methods remain supported is unselected. C retains -B's bearer request pattern; stolen bootstrap JWT-SVIDs remain replayable until -expiry even if a subsequently issued access token uses DPoP. D does not itself -sender-bind an OAuth token; the DPoP task below remains separate. - -Select the ToolHive release/exchange capabilities and direct-client/proxy topology; -prove intended key/credential isolation and absence of alternate bypasses. The -direct client holds a broker-audience workload token; only a selected proxy would -hide it from the harness. The initial combined broker is one replica with `Recreate`, -process-local active state and no HA claim. A later narrow Unix-socket client proxy -is an open option, not a second policy engine or generic HTTP proxy. Splitting issuer -and invocation into separate deployments requires its own authentication and key -distribution design. Signing algorithm, key-storage backend and any further -structured resource constraints belong in the implementation specification. - -### Durable broker evidence and replacement - -Make identity provenance, exact operation, policy decision, dispatch and observed -completion/unknown outcome inspectable independently of the harness log and durable -across restart. Define linkage to harness session, run and tool call; existing IDs -alone are not assumed sufficient, and no new correlation identifier is selected. -Credential recovery restores access, not completion evidence; restart interrupts -active work. - -```text -known not dispatched -> eligible for a carefully authorized retry -known completed -> return the durable outcome -possibly dispatched -> unknown/ambiguous; never retry automatically -``` - -Qualify this distinction across replacement, including a provider effect completed -before its response was recorded. Reconciliation may use provider idempotency/status -evidence, not a general exactly-once claim. Durable audit retention can precede -replication. Replicated continuity separately needs durable callback claims, -refresh/disconnect fences, routing generations and operation state; adding Redis -or replicas is not that contract and is not required for a useful singleton checkpoint. - -## Security follow-on: workload-level DPoP - -DPoP is selected **after the foundation**, not implemented by the identity credential. -Implement sender binding for the worker's broker-facing OAuth credential while -retaining logical-agent assertion and exact-call checks. Define issuance, key -custody, rotation/restart, nonce handling and replay-state ownership. Prove rejection -of missing, wrong-key, stale and replayed proofs with no bearer fallback. - -[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) can use one key per worker; subagents -need not be workloads. Verify the actor's association with the authenticated -presenter separately. DPoP covers HTTP method and URI, not MCP arguments/body, and -does not change the logical-agent JWT-SVID profile. Its initial claim is stolen -access-token replay protection, not compromised-harness or per-agent isolation. - -Keep keys/tokens/proofs out of model tools, results, logs and environment variables; -expose no general-purpose signer. A process-lifetime key is a minimal custody option, -with fresh token issuance after restart and no cached-token reuse under a new key. -Custody/lifecycle still need an implementation plan. A stolen projected bootstrap -bearer may let an attacker obtain a new token bound to their own key. - -**Stronger runtime isolation is deferred.** A shell able to reach worker memory, -files or signer can defeat this protection. A keystore/HSM can prevent extraction, -but a compromised authorized client may still request signatures. Design an enforced -boundary between hostile tool execution and protected broker client; an accessible -sidecar signing socket is insufficient. Independently proving which agent made a -call is a further requirement, not provided by DPoP, a separate signer or separate -IDs/keys held by one compromised process. - -## Separate future consent: schedules - -The [generic scheduler][scheduler] restores owner and placement, not fresh user -authentication or permission to spend a provider account unattended. The live -identity wrapper does not supply the scheduled journey. Issue #373 covers a separate, -still undesigned authorization, provenance, freshness and revocation contract. - -Permission must bind to schedule revision, operation, cadence and limits. A -model-created Schedule call attributed to Alice does not prove she approved those -facts. Changes must not widen the authorized job. Before each manual or timer fire, -revalidate the applicable authority's expiry and revocation, schedule revision, -current policy, agent/integration revisions and operation eligibility before -obtaining fresh execution credentials. Neither path may fall back to a service -identity. No signed schedule grant, reference store or approval mechanism is selected. -Provider offline credentials remain in -broker custody, not as user proof, and saved login bearers are not a solution. - -The follow-up handover sketched authenticate owner → resolve agent/operations → -obtain unattended consent → establish provider offline access → store bounded -schedule authority/reference. That is a discussion sketch, not a contract. Offline -consent does not require a second broker OAuth self-client: the confidential -ToolHive client already exists, while unattended permission is an authorization -question. This work does not block the interactive foundation. - -## Exploratory extension: task mandates - -No task-mandate model is selected. A logical-agent credential establishes actor and -tool ceiling; a mandate could express authority for this task, resources and -conditions; the broker would still enforce its own current policy on the exact call. -For example, a mandate could limit a reviewer to PR 42 even if standing policy -permits other PRs. Neither identity nor the enforcement point decides who may grant -that permission or proves Alice's consent. - -One candidate is a signed token carrying Cedar policy in RAR-shaped -`authorization_details`, not merely operation data for broker-local policies. The -broker would verify grant/actor binding and require both carried and local policy -to allow the operation, with neither expanding the other. The credential stays -outside model content. This is an option, not a logical-agent profile change. - -Before selecting a model, decide: - -- Issuers, authorization/consent for issuance and user/actor/presenter binding. -- Operation/resource semantics and mapping from registered calls, including Cedar - schema and trusted entity/context inputs. -- Task versus one-operation scope, separately from reuse/single-use semantics; - narrow policy alone does not make a grant single-use. -- Expiry, revocation and delegation narrowing, including issuance ceilings. - -Do not add speculative token fields or interfaces now. Existing acting-as-user, -registered-target, tool-ceiling and exact-call checks remain. Mandates are not a -prerequisite for useful attribution/enforcement, and selecting their format would -not close consent or durable-audit gaps. - -## Delivery references - -Issue numbers are planning references, not runtime evidence. For shipped behavior, -use [architecture.md](architecture.md); for alternatives, [agent-identity-model.md](drafts/agent-identity-model.md) -and [agent-identity-outbound.md](drafts/agent-identity-outbound.md). Delivery proofs belong -in acceptance plans, decisions in ADRs and shipped/deferred status in -[PRODUCTION-READINESS.md](design/PRODUCTION-READINESS.md). Historical handovers are -conversation snapshots, not the evolving contract. - -## Pinned references - -[b2-connect]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/server/session_broker.go#L127-L144 -[b2-partitions]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbrokerserver/session_api.go#L15-L64 -[b2-callback]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/auth.go#L584-L749 -[b2-publication]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/session_api.go#L662-L712 -[b2-account]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/toolhive_process.go#L120-L153 -[b2-production-tests]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/server/session_broker_production_test.go#L659-L738 -[b2-occurrences]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/session_api_occurrence_test.go#L53-L105 -[b2-client]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/toolhive_process.go#L678-L717 -[b2-recovered]: https://github.com/stacklok/mecatl/blob/0d9467664c7a496b8dda287183f89dedea3ec786/internal/adapter/mcpbroker/toolhive_recovered_credential.go#L30-L94 -[current-authn]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/internal/adapter/server/authn.go#L463-L495 -[current-evidence]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/engine/port/authentication_evidence.go#L12-L40 -[current-execute]: https://github.com/stacklok/mecatl/blob/2ee96819939367e5f7c57fd48ead21fca828bd0a/internal/adapter/mcpbrokergrpc/server_execute.go#L216-L229 -[issuer-adr]: https://github.com/stacklok/mecatl/blob/ed319f45cd972de3cb95031b39e57b8eec425789/docs/adr/0303-identity-issuer-substrate.md -[agent-adr]: https://github.com/stacklok/mecatl/blob/ed319f45cd972de3cb95031b39e57b8eec425789/docs/adr/0304-logical-agent-identity-projection.md -[exchange-adr]: https://github.com/stacklok/mecatl/blob/ed319f45cd972de3cb95031b39e57b8eec425789/docs/adr/0305-acting-as-user-exchange.md -[delegation]: https://github.com/stacklok/mecatl/blob/a711451616edf84f50e793c9bcbfb697ef400347/engine/agent/authority_delegation.go -[scheduler]: https://github.com/stacklok/mecatl/blob/a711451616edf84f50e793c9bcbfb697ef400347/internal/app/scheduler_fire.go -[broker-auth]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbrokerserver/authentication.go -[custody]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbrokergrpc/continuity_rpc.go -[storage]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbroker/toolhive_protected_storage.go -[receipts]: https://github.com/stacklok/mecatl/blob/53eab6b753869b33c317da49a67295575807ac61/internal/adapter/mcpbrokergrpc/server_execute.go -[track-b]: https://github.com/stacklok/mecatl/tree/b6dc951c091197bcb4c3ec444a80308d43dc9e5a/docs -[integration-adr]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/docs/adr/0306-singleton-identity-mcp-admission.md -[wrapper]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/app/mcp_identity_attester.go -[ingress]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbrokerserver/server.go -[admission]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbrokergrpc/broker.go -[cedar]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/mcpbrokergrpc/admission.go -[fixtures]: https://github.com/stacklok/mecatl/blob/db5bcd8845f87f05fa0c0efecb2092fd6c5e497c/internal/adapter/server/mcp_broker_live_admission_toolhive_test.go diff --git a/docs/agent-authority.md b/docs/agent-authority.md deleted file mode 100644 index b0ada3d107..0000000000 --- a/docs/agent-authority.md +++ /dev/null @@ -1,314 +0,0 @@ -# Agent authority: user journey and trust boundaries - -This page records the intended design of the agent identity work in mecatl, how credentials flow in the system and what the trust model is. - -## 1. User journey - -Alice asks `code-reviewer` to read PR 42 in `acme/payments`. Mecatl validates her login token and reports the verified user and acting reviewer instance to the workload-authenticated broker. The broker checks the instance credential and applies its own policy to the run and exact operation before dispatch. It trusts Mecatl’s report that Alice authenticated; it does not revalidate her login token or infer that she approved this particular action. - -Binding the user, agent instance, run, and operation provides the substrate for broker authorization decisions and future audit records. Reading PR 42 does not authorize merging PR 43; an audit record must distinguish the broker’s checks from Mecatl’s assertions and capture the observed outcome. - -## 2. Identity and trust model - -### User identity and persisted ownership - -Mecatl identifies Alice by the issuer and subject of her validated login token, represented by `session.Principal`. Mecatl saves that principal as the session owner; children and forks inherit it. - -For each new human-initiated request, Mecatl validates the caller's login token signature, trusted issuer, intended audience and expiry, then checks that its issuer and subject match the saved session owner. It reports the verified user to the workload-authenticated broker for run admission; the broker checks that the report came from trusted Mecatl and matches the owner. Once a run is admitted, expiry of Alice's original login token does not end it. - -### Agent definitions and execution lifetimes - -- **Agent definition:** `code-reviewer` is a selected `AgentDef`; this design also gives the default main and unnamed child the built-in identities `system/main` and `system/explorer`. Broker policy would distinguish the verified source supplying `code-reviewer`, not rely on `project/code-reviewer` alone. Changing its content does not change that source identity; a task prompt cannot create a new definition. -- **Session:** The [domain model](architecture/domain-model.md) defines the `Session` aggregate. In this design, Alice and Bob have separate conversations and owners even if both select `system/main`. -- **Session incarnation:** the durable identity this design uses to distinguish their agent instances, even if an internal caller later reuses a session ID. Restoration keeps the incarnation. -- **Agent instance:** the proposed logical identity of a selected definition in one session incarnation, not a process or a run. Alice can select `code-reviewer` for her main instance or invoke it as a distinct child; a peer fork/clear creates another instance. -- **Run:** Alice's PR 42 task is one run of the reviewer instance. Continuing it after an approval pause keeps its run identity; giving the same instance a new task starts another run. The existing `RunID` correlates execution but does not authorize it: broker admission for each run is part of this design. -- **Workload:** the Mecatl service authenticating to the broker (`mecak8s` in this flow). Its identity names neither Alice nor one of the agent instances it hosts. - -### Instance subject and creation ancestry - -In this proposed design, a short-lived JWT-SVID carries the agent instance's SPIFFE ID. Each creation hop names a source category (`system`, `explicit`, `project`, `user` or `driver`), definition name and persisted session incarnation: - -```text -spiffe:///mecatl/agent///inst/[/child///inst/ ...] -``` - -These illustrative subjects show Alice's main instance, its reviewer child, and a peer fork or clear of the main: - -```text -# Alice's main instance -spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u - -# Reviewer child created by that main instance -spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m - -# Alice forks or clears her main instance -spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_45bvtrrrpkekb6336hwe6bvape -``` - -`project/code-reviewer` illustrates a wider problem: a category and name do not necessarily identify the source that supplied a definition. The payments and website projects, for example, could each supply `code-reviewer`. Before applying a definition-specific policy, the broker needs a verified record of which source supplied this instance's definition. How the issuer verifies and preserves that record, including across repository moves or renames, remains to be designed. If the record is unavailable, the broker must not guess from the path. - -The reviewer subject adds `/child/project/code-reviewer/inst/` to Alice's main subject: her main created that instance. The forked main ends in `/system/main/inst/inc_45bvtrrrpkekb6336hwe6bvape` without `/child/`: fork or clear creates another root instance. A team member started directly through the API is also a root. - -### Credential format and source provenance - -Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs; a Mecatl cap and compatible name/depth bounds remain undecided. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. - -Resume requires the instance's original definition source and revision; if either is unavailable, it cannot silently select another. Fork or clear starts a new instance using the current revision from the same source. Keep private source locations out of readable JWTs. How source records, revision snapshots, and optional claims are represented remains open. - -### What signatures and identity do not prove - -A signed agent JWT-SVID proves its issuer signed an instance identity, not which agent inside a shared Mecatl process used it. If that process is compromised, it can present `deployer`'s credential on `code-reviewer`'s call when it has access to that credential. Different SPIFFE names or keys held in the same process do not isolate the agents. The [interactive flow](#5-interactive-execution-flow) covers the broker's separate run and call checks. - -## 3. Credential roles and lifetimes - -An instance JWT-SVID can be renewed or reused across that instance's runs; the broker admits each run separately. - -Delegated access belongs to the acting instance's admitted run, not its conversation session or a parent run. One run may need several access tokens for different resources or after expiry, but each stays within its authorization. A specialist uses its own run and identity, never a token naming its parent. - -An admitted parent can start a child without Alice logging in again. The child's separate run derives its user and authority from the active parent and the child's definition ceiling; a saved owner or instance credential alone cannot open it. If Alice's run `R42` starts reviewer run `C1`, `R42` is `C1`'s parent and root run, distinct from instance ancestry in the SPIFFE ID. `C1` cannot outlive the applicable parent and root authority. - -A token minted at run start cannot authorize another run. Completing or cancelling a run stops new admissions and delegated-access issuance or renewal for it and dependent children, although an operation already dispatched may finish. A recipient without live run-status checks may still accept an issued bearer until expiry. Renewing agent, ToolHive or provider credentials neither extends run authority nor authenticates another initiating request. - -An approval wait or provider-enrollment pause leaves the run unfinished; continuation rechecks its original authorization and pending operation. Client disconnect follows the run's actual lifecycle, not automatic cancellation or indefinite authority. After restart, only a durable record of the unfinished run and its original authorization permits continuation; a saved owner, instance credential, connection or attachment alone cannot revive it. An ended child invoked again keeps its instance but needs a new run. Never blindly replay an operation whose outcome is unknown. - -A run may have narrower resource/action limits than its definition ceiling. These broker limits govern protected external calls, not local file access or direct writes to a parent workspace; Mecatl's permission and environment controls still govern those. Pinning a definition revision across resume does not pin old operator grants. A model-proposed goal or broad definition tool list is not a trusted task grant; mission-bound authorization is a possible later approach, not a selected approval or policy-generation mechanism. Broker policy changes must not automatically widen an already admitted run. Explicit restrictions or withdrawal can constrain subsequent operations, while an expansion requires an authorized change. Policy lookup, token-carried limits, online evaluation and subset algorithms remain separate authorization-design choices; a signed SVID does not decide among them. - -## 4. Identity progression - -At every stage, `mecak8s` is a client of one broker deployment; the model neither requests credentials nor authenticates. A is a workload-only checkpoint. B adds agent-instance identity, while C and D change how the same harness authenticates its workload. These are alternatives, not a required sequence; agent JWT-SVIDs do not require SPIRE. The separate broker-to-ToolHive OAuth flow appears in [the interactive sequence](#5-interactive-execution-flow). - -| Stage | Workload authentication to the broker | Agent-instance credential | -|---|---|---| -| **A** | Projected Kubernetes ServiceAccount (SA) bearer over TLS | None | -| **B** | Projected SA bearer over TLS | Broker-issued instance JWT-SVID | -| **C** | SPIRE workload JWT-SVID from the Workload API over TLS | Broker-issued instance JWT-SVID | -| **D** | SPIRE workload X.509-SVID and key from the Workload API over mTLS | Broker-issued instance JWT-SVID | - -In A, the broker validates the configured Kubernetes issuer, audience and SA allowlist, then invokes ToolHive with credentials it holds. `code-reviewer` and `deployer` in one harness have the same workload identity; a self-declared agent name cannot distinguish them for policy. - -### Example flow: ServiceAccount plus agent-instance JWT-SVID - -B adds a short-lived credential for the acting instance. The broker authenticates the SA; its issuer must check the resolved definition and creation ancestry against trusted inputs rather than sign a model-supplied name. How it obtains those trusted inputs remains open. The diagram separates obtaining that credential, admitting a run and admitting one exact call: - -```mermaid -sequenceDiagram - participant K as Kubernetes - participant H as mecak8s - box Combined broker - participant I as Agent-instance issuer - participant R as Run and call admission - participant T as Embedded ToolHive - end - K->>H: 1. Project short-lived SA token for broker audience - opt No suitable instance JWT-SVID - H->>I: 2. SA bearer + resolved instance, definition and ancestry - I-->>H: Signed instance JWT-SVID - end - H->>R: 3. SA bearer + instance JWT-SVID + initiating user and run - R->>R: Verify workload, instance, user and run limits - opt Run admitted - R-->>H: Admitted run reference - H->>R: 4. SA bearer + instance JWT-SVID + run reference + exact call - R->>R: Verify workload, instance, active run, target, arguments and policy - opt Exact call admitted - R->>T: Invoke admitted operation - end - end -``` - -Operator configuration fixes accepted Kubernetes issuers, audiences and SAs, issuance limits and trusted public signing keys. Verification endpoints, CA trust and server names are never selected by a token. A stolen SA token or workload JWT-SVID remains a bearer credential until expiry; workload proof does not replace per-run and per-call admission. - -In C, `mecak8s` obtains a platform JWT-SVID for the broker's audience through the SPIFFE Workload API and presents it on issuance and invocation requests. The broker checks signature, audience, expiry and allowed workload SPIFFE ID. Platform credential renewal and trust-bundle refresh are separate from the broker issuer's key rotation; platform identity neither replaces agent-instance issuance nor creates federation. - -In D, `mecak8s` obtains an X.509-SVID and key through the Workload API and proves possession during mTLS to both broker endpoints. The broker checks platform trust and permitted workload IDs. A TLS-terminating ingress needs explicitly trusted identity propagation; a forwarded header alone is insufficient. Rotation, bundle refresh and connection renewal must preserve mTLS checks without bearer fallback. Workload mTLS does not bind an OAuth access token to its certificate or prove user consent or isolation among agents in one harness. - -## 5. Interactive execution flow - -The flow separates occasional provider enrollment and run admission from independently checked calls in an admitted run. It uses an OAuth-protected upstream requiring its native provider credential, rather than a backend that directly accepts the delegated token. Operation and scope names are illustrative, not wire names. - -The broker starts as a **modulith**: a constrained interface for issuing agent-instance JWT-SVIDs, ToolHive authserver and vMCP contracts, and credential-custody interfaces sit behind Mecatl-specific operations for run admission and exact-call dispatch. Those operations compose reusable interfaces while preserving authorization and custody checks; they expose neither raw signing nor credential retrieval. Mecatl-specific session, definition-source and engine adaptation stays at the integration edge. The modules are not separate security boundaries. - -A broker attachment refers to broker-owned session and connection state under the authenticated workload; it is neither an agent instance nor a run grant. The preferred design holds admitted run context alongside it and checks both on authenticated calls. Existing reference machinery may be reused, but no API, persistence format or signed run assertion is selected. - -Before invoking a connected provider account, the broker must establish that Alice may use it. Her Mecatl and GitHub identifiers need not match; the browser flow's state/PKCE and account-continuity checks do not establish that initial permission. How to authorize the caller to use the connected ToolHive user and provider account remains open, including separately governed account sharing. - -Enrollment uses two OAuth clients and two authorization codes. ToolHive redeems the provider code and stores the native grant; the broker separately redeems a ToolHive AS code with its confidential-client authentication and PKCE. The broker consumes its callback state, while ToolHive AS checks the registered client, PKCE and eligible grant. Instances need no separate OAuth client registration. - -The broker retains the ToolHive user access/refresh grant. A continuity record can link to ToolHive's token session but is not the provider-token store. The inspected [B2 foundation](agent-authority-implementation.md#b2-enrollment-and-account-continuity-to-reuse) has these OAuth/custody boundaries; the instance actor exchange, caller/account permission and admitted run remain intended integrations. - -### Execution sequence - -The sequence shows Alice starting reviewer run `R42` and one PR 42 read. The run label is illustrative, not a specified broker-reference format. Agent credential issuance, enrollment and exchange occur only when needed; the operation check applies on every invocation. - -```mermaid -sequenceDiagram - actor U as Alice / human client - participant H as Mecatl trusted API and dispatch - box Combined broker - participant I as Logical-agent issuer - participant B as Run and exact-call admission / broker OAuth client - participant A as ToolHive AS / upstream OAuth client - participant C as ToolHive upstream token storage / reader - participant V as Protected vMCP - end - participant P as GitHub provider - - Note over U,P: Admit Alice's reviewer run R42 - U->>H: Start R42: read acme/payments PR 42, login bearer - H->>H: Verify login and owner, then resolve reviewer instance and authority - opt No suitable valid instance JWT-SVID - H->>I: Workload authentication + instance, definition, ancestry, issuance limits - I-->>H: Broker-issued, short-lived instance JWT-SVID - H->>H: Verify issuer, audience, expiry, instance and restrictions - end - H->>B: Authenticated workload + attested initiating user + attachment + instance SVID + R42 - B->>B: Verify owner, presenter, run limits and connection if present - B-->>H: Admitted run binding for R42 (format to be designed) - - opt Provider connection needed - B-->>H: R42 waits for authorized connection - H-->>U: Broker enrollment link - U->>A: Browser authorize broker OAuth client (broker state/PKCE) - A-->>U: Redirect to provider authorization (ToolHive client) - U->>P: Approve provider connection - P-->>U: Provider redirect with upstream code - U->>A: ToolHive upstream callback with provider state and code - A->>A: Verify upstream OAuth transaction - A->>P: Redeem provider code as ToolHive upstream client - P-->>A: Native provider access/refresh tokens - A->>C: Store native grant in upstream-token storage - A-->>U: ToolHive authorization code redirect - U->>B: Broker callback with ToolHive code and broker state - B->>B: Claim one-time state and retrieve retained PKCE verifier - B->>A: Redeem ToolHive code with client authentication + verifier - A->>A: Validate broker client and PKCE challenge - A-->>B: ToolHive user access/refresh grant into broker logical-session custody - B->>B: Verify caller/account association and R42 before continuing - end - - Note over U,P: Independently admit one exact operation within R42 - H->>H: Apply local permissions/hooks to registered PR 42 read - H->>B: Workload + instance SVID + R42 binding + call ID/occurrence + PR 42 arguments - B->>B: Verify active R42, instance, target, arguments and policy - alt Denied - B-->>H: Denial with no invocation credential or dispatch - H-->>U: Refusal - else Admitted exact call - opt No suitable valid delegated token for R42 and resource - B->>B: Obtain current ToolHive user subject_token from custody/refresh - B->>A: subject_token + instance actor_token + broker OAuth client authentication - A->>A: Validate user, actor issuer/audience, presenter and bounded access - A-->>B: Delegated token: sub=user, act.sub=instance, client_id=broker client - B->>B: Bind delegated-token use to R42 - end - B->>V: Delegated token + independently admitted PR 42 read - V->>V: Validate token and operation policy, then resolve authorized connection - V->>C: Resolve valid upstream grant for authorized token session and target - C-->>V: Native provider credential via ToolHive token reader - V->>P: Read PR 42 with upstream-injected provider credential - P-->>V: PR 42 result - V-->>B: Result - B-->>H: Sanitized result and observed outcome - H-->>U: Answer without credentials in model history - end - Note over H,B: Another R42 call repeats exact-call admission. Login expiry alone does not end R42 -``` - -The sequence shows one run. For a child run, authenticated Mecatl reports the child instance and parent/root-run links without resending Alice's login bearer. The broker verifies those links and checks the active parent's initiating user and applicable authority against the child's definition ceiling before admitting the child's own run. - -For each call, the broker authenticates the workload and validates the instance JWT-SVID's signature, issuer, audience, expiry and permitted presenter. It checks that the attachment matches the admitted user, authenticated workload and current Mecatl session incarnation, and that the SVID names that incarnation. The attachment's separate broker incarnation prevents an old reference from selecting new broker state. Neither an attachment nor a run reference grants authority by possession. - -The broker resolves the issuer-verified definition source before selecting policy; missing or stale records fail closed. It checks the caller's connection permission and applies the definition ceiling and active run's limits to the registered target, resource, scopes or authorization details, and exact arguments. Admission binds one occurrence to the attachment, broker incarnation, run, call ID, argument digest and expiry. A credential's tool list is only a restriction, not the whole policy; ToolHive's Cedar checks remain a reuse candidate, not a selected policy platform. - -After enrollment, continuation of the pending run rechecks the PR read before dispatch; connecting an account does not approve that operation. - -At exchange, the broker uses its existing ToolHive authorization-code access token as RFC 8693 `subject_token`, the instance JWT-SVID as `actor_token`, and its own OAuth client authentication. ToolHive AS validates the user token, actor issuer, signature, audience, expiry and permitted presenter. A credential valid for broker admission needs an explicit ToolHive AS audience; no additional user-evidence credential is introduced. - -ToolHive issues a delegated token with the user in `sub`, the acting instance in `act.sub`, the broker OAuth client in `client_id`, and the protected MCP endpoint as audience, not GitHub. These claims identify parties and recipient, not permission for particular tool arguments; the broker still admits the exact call. - -The protected resource validates issuer, audience, expiry and bounded access, -applies operation policy and resolves only the authorized user/tenant/target's provider connection. `tsid` is a ToolHive-local credential link, outside SPIFFE's identity responsibility. It must come from validated subject and authoritative connection state, never an arbitrary external claim or caller-selected storage key. - -## 6. Credential custody and safety boundaries - -- **Human login bearers terminate at Mecatl's edge.** They are transient, never saved for downstream or scheduled use. Harness-attested authentication at run admission is not the bearer, a stored owner, an attachment or an authorization grant for arbitrary operations. -- **Agent credentials enter trusted Mecatl memory, not model content.** Client code verifies and attaches them without exposing them in arguments, results, conversation history, logs or environment variables. Model-hidden is not process-safe and does not guarantee secure memory erasure. -- **Signing authority stays with the broker issuer.** Mecatl receives signed credentials and public verification keys, never the private signing key. Issuance and invocation may be modules in one process: separate boxes do not protect against broker, host or key-storage compromise. Key rotation and verifier cache expiry bound trust in old keys. -- **Provider credentials stay in broker/ToolHive custody.** ToolHive retains, - refreshes and injects native access credentials for the authorized connection; - the broker holds its own ToolHive access/refresh tokens. Neither Mecatl nor - the model receives the native provider grant. Results contain neither - secrets nor reusable signed requests. This does not relocate all harness - credentials, including LLM-provider credentials. The broker runs no model-driven - agent loop or general-purpose shell. -- **Refresh is not a new run grant.** A provider connection can outlive a login token without authorizing another request. Credential recovery alone does not establish canonical-user identity or the authorized caller/connection association. After restart, revalidate connection continuity and durable unfinished-run binding before continuing an admitted run; missing or indeterminate bindings fail closed. An ended run needs new admission. Expiry of the original login bearer alone does not end unfinished, recoverable work. Self-contained bearers retain a residual lifetime unless recipients check live run status or introspect. -- **Targets and credentials are server-resolved.** Model-chosen URLs, headers, - issuers, audiences, scopes or credential selectors cannot redirect authority. - Missing, malformed, stale, revoked or indeterminate evidence or authorization - fails closed—never to an ownerless, service, broader-agent or alternate-user - credential. Denied calls select no invocation credential and dispatch no backend - operation; enrollment/callback traffic is accounted separately. -- **Children only narrow authority.** A malicious PR comment cannot obtain merge - authority by requesting `deployer` beneath a read-only reviewer. A separately - authorized top-level deployer may merge if every ceiling permits. A signature - over a model-supplied name is not enforcement. Native Kubernetes execution - separates Shell commands into executor Pods while the agent loop remains in - the harness. Its configured execution and network controls must prevent - protected-route bypasses; local worktrees and environment scrubbing alone do - not provide the same boundary. Shell logs are not exhaustive effect records. - -Unattended execution requires independent authority; restoring a schedule's owner -or keeping provider offline credentials is insufficient. Interactive authentication -does not silently authorize future timer or manual fires. - -A possibly dispatched operation with no recorded completion has an **unknown -outcome**, not proof that nothing happened. Never retry it automatically. Credential -recovery or a signed pre-dispatch record cannot establish whether a merge reached -the provider. Provider idempotency/status evidence can support reconciliation, but -arbitrary effects have no general exactly-once guarantee. Broker audit inputs must join the initiating user and authorized provider connection to the authenticated workload, acting instance's readable category/name and issuer-verified definition-source association and selected revision, executing run and parent/root-run links, exact tool invocation, decision, dispatch and observed outcome, without credentials. Creation ancestry in the SPIFFE path is distinct from the run and call identifiers. Reuse ToolHive/broker audit facilities where applicable; field mapping, delivery and retention belong to that integration work, not a new audit format here. The record must survive broker restart and distinguish broker-verified checks, harness assertions and provider-reported results. Knowing a correlation identifier grants no authority. - -## 7. Future work required to establish the separation - -The architecture separates the harness from signing, credential custody, and -external authorization. The following work must establish those boundaries in -implementation; SPIFFE identities alone do not complete it. Detailed mechanisms -and qualification belong in the [implementation companion](agent-authority-implementation.md). - -1. **Qualify protected-route mediation in the execution environment.** Build on - the existing native Kubernetes execution boundary, rather than invent another - sandbox. Verify executor Pod mounts, ServiceAccount configuration, runtime - restrictions, and effective network policy in the selected deployment. Test - alternate MCP, native CLI, and direct provider routes for usable credentials - or privileged interfaces that could bypass broker checks. Record the different - guarantees of local execution; worktrees and environment scrubbing alone do - not provide the Kubernetes execution boundary. State which protected operations - the deployment actually mediates. This is integration and qualification work, - not a claim that Kubernetes execution is absent or that every cluster is safe. - -2. **Constrain issuance using trusted inputs.** Define how the issuer - verifies each resolved definition's admitted source, its instance and - creation ancestry, and which issuance limits a harness may request. The - broker must validate those bindings rather than sign arbitrary - harness-supplied names or ceilings. Qualify rejection of substituted - definitions and unauthorized parent/child associations. Admission of a - definition from a source must not automatically grant external access; - exact grant and policy mechanisms belong to the separate authorization design. - -3. **Bound shared-harness credential substitution.** Determine which instance - credentials and run references a compromised harness can obtain or reuse, and - qualify the limits enforced outside that process. Separate identifiers or keys - in one process do not independently identify the executing agent. Document the - residual substitution risk; if stronger per-agent guarantees are required, - design and qualify a stronger execution boundary rather than claiming that a - signed SVID supplies it. No per-agent workload topology is selected here. - -4. **Integrate authorized caller/account and executing-run associations.** Build - on the broker's enrollment and account-continuity machinery to establish which - initiating caller may use a connection for a particular agent instance's run. - Preserve and validate that association through credential renewal and supported - recovery, including parent/root execution links and run termination. Neither - successful enrollment nor possession of an SVID establishes that association - alone. The [B2 integration work](agent-authority-implementation.md#calleraccount-association-and-exchange-on-b2) - records the inspected foundation and remaining checks without selecting a new - user-evidence credential or account-linking platform. diff --git a/docs/drafts/README.md b/docs/drafts/README.md index 81c03a51e9..f3e9fed4cf 100644 --- a/docs/drafts/README.md +++ b/docs/drafts/README.md @@ -5,8 +5,8 @@ rely on them for how Mecatl works. | File | Author | Summary | Decide by (keep, move to an issue, or delete) | |---|---|---|---| -| [agent-identity-model.md](agent-identity-model.md) | Juan Antonio Osorio | Strawman for agent identity: Mecatl as its own SPIFFE trust domain with JWT delegation chains. | **2026-11-06** | -| [agent-identity-outbound.md](agent-identity-outbound.md) | Juan Antonio Osorio | Part 2 of the identity model: how the harness reaches tools and gateways on the outbound hop. | **2026-11-06** | +| [agent-identity/README.md](agent-identity/README.md) | Juan Antonio Osorio | Proposed broker-centered agent identity, authority, audit, and credential lifecycle. | **2026-11-06** | +| [agent-identity-outbound.md](agent-identity-outbound.md) | Juan Antonio Osorio | Historical companion to the former identity model: how the harness reaches tools and gateways on the outbound hop. | **2026-11-06** | | [cloud-native-harness-kit.md](cloud-native-harness-kit.md) | Juan Antonio Osorio | Positioning definition of the cloud-native harness kit and where its responsibility ends. | **2026-11-06** | | [cloud-native-harness-systems.md](cloud-native-harness-systems.md) | Joe Beda | Speculative scoping of open questions for cloud-native harness systems. | **2026-11-06** | | [scoped-resource-grants.md](scoped-resource-grants.md) | Joe Beda | Strawman for short-lived, attenuable grants and direct tool-to-service data paths. | **2026-11-06** | diff --git a/docs/drafts/agent-identity-model.md b/docs/drafts/agent-identity-model.md deleted file mode 100644 index 6de663d524..0000000000 --- a/docs/drafts/agent-identity-model.md +++ /dev/null @@ -1,1258 +0,0 @@ -# Agent identity: mecatl as its own SPIFFE trust domain - -**Status: superseded by [Agent identity and authority](agent-identity/README.md).** -The replacement chapters consolidate this model with the newer authority design -and the design-review decisions. This document remains as historical source -material; its harness-owned issuer, credential layout, and delivery phases are -not the selected architecture. - -Supersession does not mean every design question is resolved. The `may_act` -question remains pending, and the authority and implementation donors still need -a final completeness review. Follow the replacement chapters for the proposed -architecture and its explicit open questions. - -This doc proposes how mecatl identifies **who is acting** in a multi-user, -multi-session, autoscaled deployment: a user spawns an agent, the agent spawns -subagents, every hop holds a strict subset of its parent's authority, and the -whole delegation tree leaves a durable, verifiable audit trail. It is written -to be concrete enough to argue with. Nothing here is implemented. - -**mecatl becomes its own SPIFFE trust domain issuer.** Not a consumer of SPIRE -per-workload identities, not a client of the (experimental) SPIFFE Broker API, -but an issuer in its own right, with its own signing keys, its own bundle endpoint, -and a claim vocabulary the SPIFFE JWT-SVID spec explicitly leaves open to us. -SPIRE (or cloud IAM) only ever attests the *infrastructure*: the mecatl pods -themselves. Everything above the pod (users, agent definitions, sessions, -subagents) is mecatl's own identity domain. - -## Why this matters - -A cloud-native agent harness is a **multi-tenant delegation machine**, and -today it runs on trust-me semantics. One mecatl process serves many users' -sessions at once; each session's agent runs tools, calls forges and MCP -servers, and spawns subagents on the user's behalf. Nothing cryptographic -distinguishes any of them. Concretely, what that means today: - -- **The audit trail is the harness's word.** The event log records what - happened, but it is self-attested. An operator, auditor, or downstream - service cannot *verify* that a given action traces back through a specific - subagent, agent, and user. The chain exists only as the harness's own - say-so. -- **Outbound calls are unattributable.** When a subagent's work results in - an API call to a forge or an MCP server, the callee sees the harness (or - worse, the user's ambient credentials), not *which* agent, acting for - *which* user, with *what* delegated authority. There is no way to answer - "who actually did this?" from the outside. -- **Delegation has no teeth.** mecatl already narrows what subagents may do - (its permission evaluator, its posture ladder), but the narrowing is - internal convention. Nothing makes "a subagent holds a strict subset of - its parent's authority" a property a third party can check. Nor does - anything force the system itself to uphold it at the boundary. -- **Multi-user is a promise, not a proof.** Sessions are isolated by - software discipline. There is no per-user, per-agent identity a reviewer - can point to and say "this action belonged to that delegation." - -An identity layer changes each of these: actions become **attributable** -(the delegation chain is signed, not asserted), delegation becomes -**enforceable** (attenuation is an issuer invariant, not a convention), -outbound calls become **verifiable offline** (any party with the bundle can -check who acted, for whom, with what scope), and multi-tenancy becomes -**provable** (each user/agent/subagent is a distinct principal in the chain). -One scoping note on that last word: "distinct principal" holds at the -*internal* tiers that mint identities. At the outbound hop the credential is -definition-scoped and shared across sibling subagents, so subagents are -distinct in the signed chain but not each a distinct cryptographic principal -at the gateway; the gateway enforces per-definition. This is the difference -between "trust our logs" and "verify the chain yourself." - -## What breaks today (and why that is the point) - -The design is not abstract: mecatl's current multi-user posture already -leaks, and the identity layer is what closes it. Honest inventory: - -- **`ListSessions` returns every stored session with a title derived from - that session's first user prompt** (clamped to 120 runes), inventory and - prompt content, no id needed. On Redis it takes the slow path (loads every - session per request). -- **`GET /v1/sessions/{id}/events` relays `EvUserPrompt` and - `EvCompactionArchive` verbatim**: every prompt plus entire pre-compaction - conversations. Right for a single-user harness; exactly the assumption - that breaks on a shared store. -- **Fair framing:** today mecatl has no tenants (one shared operator token - means one principal), so this is cross-*session* context isolation, not a - cross-tenant vulnerability, and a missing session owner is the - harness-tier norm (Claude Code, Codex, LangGraph all list without a - principal). But ADR 0048's shared Redis moved mecatl into the - multi-tenant-server category while the code stayed single-user. Google - ADK shows the fix: its primary key is `(app_name, user_id, session_id)` - and `list_sessions` can't be called without a `user_id`: **put the - principal in the signature**, so enumeration is scoped by construction. -- **A scheduled fire has no user by construction.** `makeFireFunc` creates a - session in-process from a leader-elected goroutine. It never crosses any - edge interceptor, so an edge-only principal binding misses it. The owner - record must be primary and the edge one writer of it; a cron fire's owner - is `client_credentials` (the OAuth grant: "the schedule did this"), not a - fake user. - -Worth stealing from Entra: it hard-blocks specific permissions from ever -being granted to an agent identity, even by an admin who wants to. That is a -ceiling independent of any issuer's correctness, stronger than narrowing at -the issuer and trusting the issuer. - -## Why SPIRE alone is not enough - -SPIRE is excellent at what it is for, and this design **keeps it** for -infrastructure. The decisive reason it cannot do the job above is about -*what can be attested*, not about features: - -- **The attestation unit is a process; our principals are goroutines.** - SPIRE establishes workload identity by introspecting the *calling process*: - the Workload Endpoint identifies callers via kernel socket state (§5), - and workload attestors select on process attributes (uid/gid/path from - `/proc/`). A mecatl pod is *one* process serving many users, - sessions, and subagents. No SPIFFE mechanism, and no SPIRE attestor, can - attest an *in-process* principal. Even the Broker API's custom reference - types must resolve to something the server can independently verify through - `/proc` or the container runtime, which a goroutine is not. **Only the - harness itself can attest its own sub-principals.** That is the portable, - spec-level reason mecatl must be its own issuer. -- **The on-behalf-of that exists doesn't reach us.** SPIFFE *does* have a - delegation mechanism: the Broker API lets a trusted component act "on - behalf of" workloads it references (PID or Kubernetes object). But it - vends *identity-only* SVIDs (`sub`/`aud`/`exp` and nothing else: no - chain, no user principal, no attenuation), it is `Incubating`, and its - references resolve to processes and k8s objects, not goroutines. It - answers "give this referenced workload an identity," not "prove this - chain of narrowing delegation happened." -- **The implementation levers are admin-gated and non-portable.** SPIRE - *can* mint arbitrary SPIFFE IDs (`MintJWTSVID`, no registration-entry - lookup) and *can* inject private claims (`CredentialComposer` server - plugins). Both are SPIRE implementation details, not SPIFFE spec. Both - are gated by an `allow_admin` boolean over the *entire* trust domain, not - scoped to a path prefix. Building on them means asking operators for a - credential strictly more powerful than the one we are trying to build. - And it works only in SPIRE's world, not with cert-manager's - csi-driver-spiffe, Istio's istiod, or a managed SPIFFE platform, none of - which share that plugin surface. -- **The conclusion is portability, not capability.** We adopt the SPIFFE - *envelope* wholesale (the ID format, the JWT-SVID shape, bundle - distribution, federation), all spec-level surfaces every implementation - speaks. We build the delegation layer (chain, attenuation, user - principal, parkable lifecycle) ourselves, in our own trust domain, - because no spec carries it and no issuer can attest our principals for - us. SPIRE stays for exactly one thing: attesting the pod. - -## Prior art, honestly assessed - -A research pass over the standards landscape (2024–2026) established three -things. First, **the primitives are mature**: SPIFFE/SPIRE is CNCF-graduated, -RFC 8693 (OAuth token exchange, `act`/`may_act` delegation chains) is a -standard, and the IETF WIMSE working group is actively standardizing workload -identity for exactly this deployment shape. Second, **the industry converges on -disaggregating durable identity from runtime state** (a definition/instance -distinction): Microsoft's Entra Agent ID (blueprint / agent identity), -OpenAI's Assistants API (assistant / thread / run), A2A (AgentCard / task), -and WIMSE (workload / workload instance) all separate the durable "what it is" -from the ephemeral "it is running." The run/transaction tier is this doc's own -elaboration, matching OpenAI's run but not present in every analog. -Third, **the attenuation picture is more nuanced than "nobody has solved -it."** No *ratified* standard mandates attenuation: RFC 8693 only *suggests* -scope narrowing as an abuse mitigation (§5), `draft-ietf-oauth-identity-chaining` -expects non-escalation in non-normative prose and leaves claim representation -undefined, and ID-JAG (the Identity Assertion JWT Authorization Grant -draft) makes narrowing a policy MAY (§4.3.3). But several -individual drafts *do* mandate it, and they are worth citing rather than -pretending the space is empty: - -- **`draft-mcguinness-oauth-actor-profile-00`** (the closest overlap; an - OAuth "actor-profile" for delegated agents) mandates - non-escalation on every path, specifies a chain construction and validation - algorithm with append-only as a MUST, enforces a depth limit, and has - `sub_profile: "ai_agent"` with a "user → orchestrator → agent → tool" - reference architecture. Its §3.2 also states the invariant RFC 8693 never - does: "`sub` is the authorizing principal." -- **`draft-mcguinness-oauth-ai-agent-instance-00`** (same author) makes this - doc's definition/instance split in near-identical words: "A platform - registers a single `client_id` and then runs many concurrent agent - instances under it," with a REQUIRED `agent_instance_id` and provenance - claims. This is the closest analog for the tier model itself. -- **`draft-liu-agent-operation-authorization-02`** and - **`draft-liu-oauth-chain-delegation-00`** both name a `delegation_chain` - claim and enforce strict-narrower server-side, the second with a depth cap - of 5. That is prior art for this doc's chain name and `max_depth`. -- **Macaroons** (Birgisson et al., 2014) and **Biscuit** are the actual - origin of *holder-side* attenuation: the holder appends a narrowing block - offline and the verifier rejects any block that widens. Biscuit gives a - cryptographic guarantee rather than a runtime policy. This doc's model is - the inverse (issuer-side enforcement at the mint seam), which is the - correct call for a harness that IS the issuer (a compromised child runtime - must not be able to decline to attenuate); the comparison is drawn in - "Build vs buy." - -All four are individual submissions with Standards-Track intent, none -WG-adopted; only `draft-ietf-oauth-identity-chaining` and ID-JAG carry that. -**What survives as genuinely unspecified everywhere:** every attenuation MUST -in actor-profile cites "[RFC8693], Section 4" for the *method*, and §4 is -the claims registry, where §4.2 defines `scope` as a space-separated string. -No reduction algorithm anywhere. So even the draft with the hardest MUSTs -mandates the *requirement* and points at a section with no *method*. -Containment over a structured authority model (tool names, a posture ladder, -delegation rights, workspace resources), and who is obliged to refuse a -widening, is the genuinely unclaimed ground, and it is where this doc's -`authorization_details` subset computation sits. - -WIMSE's architecture draft describes user→AI-agent→AI-agent→workload chains -(§3.4.11, "AI and ML-Based Intermediaries") as *requirements*: no protocol, -no claims, no implementation. RFC 8693 gives the `act` chain as an audit -artifact but normatively declares nested actors "informational only". There -is no standard agent capability vocabulary and no credential lifecycle for an -interruptible, human-in-the-loop workload. Those gaps are called out -per-section below as **innovation ground**. - -The five questions the rest of this doc adjudicates, with their verdicts: - -- **Q1**: Is issuer HA solved for this deployment shape? **Converged**: - yes, stateless replicas, KMS-backed signing (see "The issuer in a - Kubernetes deployment"). -- **Q2**: How does the credential lifecycle work when sessions park for - hours and resume on different pods? **Innovation ground** (see "The - parkable credential lifecycle"). -- **Q3**: Is the SVID the *source* of authority or a *projection* of it? - **Converged**: projection (see "Projection, not authority"). -- **Q4**: What scope vocabulary should an agent capability credential - carry? **Innovation ground** (see "The scope vocabulary"). -- **Q5**: How should a multi-hop agent run be correlated for audit? - **Converged**: dual-ID, one immutable tree ID plus per-hop parent links - (see "Audit"). - -## The three-tier model - -Identity is segmented exactly where the industry draws the lines, and where -mecatl's existing types already draw them. The durable specialist definition -is the `AgentDef` value object (`engine/tool/agentsource.go`), the stateful -conversation is the `Session` aggregate (`engine/session/session.go`), and -the ephemeral turn loop is `Engine.Run`: - -``` -TIER 1 DEFINITION durable identity: what the agent IS - (mecatl's AgentDef; the generic main engine is the - built-in "main" definition) -TIER 2 INSTANCE one living session of a definition: the credential tier - (mecatl's Session aggregate; short-TTL SVIDs minted here) -TIER 3 RUN one execution: the transaction tier - (mecatl's Engine.Run; per-run txn correlation) -``` - -SPIFFE IDs in mecatl's own trust domain (name is a config knob): - -``` -spiffe:///user/ -spiffe:///agent/ -spiffe:///agent//inst/ -spiffe:///agent//inst//child/ -``` - -**The path names a principal; the delegation topology lives in the claims, -not the path.** SPIFFE-ID §2.2 leaves path semantics to the administrator and -explicitly sanctions opaque paths ("the most general case"); §4.1.1 warns -that only assertions stable for the SVID's lifetime belong in the credential, -and delegation topology is runtime-emergent (children park, resume, and -outlive runs). mecatl already carries the authoritative topology twice, in the -signed `delegation_chain` claim and the event log's `ParentCallID`, so -embedding it again in the path is speculative generality. Child identities -therefore embed the **verbatim runtime child session id** under one flat -`child/` segment: `subagent-`, `parallel--`, -`team--` (minted by `engine/agent/teamsupervisor.go` -(`MemberSessionID`)). The identity graph and the runtime delegation graph are -the same string, so they cannot drift, and audit correlation needs no scheme -knowledge. Family and team structure stay claims-and-log data. An issuer-side -invariant keeps team and call ids dash-free hex (already true), so the packed -`team--` form stays prefix-queryable for audit. Grandchildren -recurse the same `child/` append, bounded by `max_depth` and the -2048-byte SPIFFE-ID §2.3 ceiling (~40 bytes/level, so tens of levels, a -non-issue, stated so the budget is explicit). Depth is enforced by the issuer -at every hop. - -The one place structure *is* enforced is the durable, user-controlled, -policy-targeted **definition name**: it is admitted only if it already -conforms to the §2.2 segment charset `[a-zA-Z0-9.-_]` (round-trip reject at -def load). Accepting a lossy sanitize would let two distinct definitions -collapse to one SPIFFE id, silently breaking attribution, the property the -whole model exists to provide. (Session ids are already hex; provider call -ids are issuer-minted and stay inside the family prefixes by construction.) - -- **Definition identities** (`agent/`) are the durable "who", the anchor - for operator policy ("what may a `tdd-worker` ever do?"), exactly the - ServiceAccount/blueprint role in the platform analogs. They are never - minted as credentials; they exist as path structure and as policy targets. - Honest gap: today `CreateSessionRequest` has no agent field, so `` - would resolve to a constant for every default session. The fix is an - optional `agent` field plus a synthetic default-definition id (never the - telemetry `roleFamily` bucket: that label is deliberately lossy and would - collapse every definition into six buckets, destroying the policy anchor). - Second gap: `AgentDef.Origin` carries a trust tier (project/user), but it - is carried, not enforced into identity. A project-tier def read from a - mutable workspace can take the name of an operator-managed one (project - precedence over user), so two legitimately-valid same-name definitions - from different-trust sources collide. That is a definition-spoofing path - through repository content, separate from the sanitizer collision, and it - needs a trust-tier distinction in the path or in policy. -- **Instance identities** (`inst/`) are where SVIDs live. A session - (including a reopened, recovered, or pod-migrated one) is one instance; - rehydration re-mints the *same* instance identity. N concurrent sessions of - one definition are N instance SVIDs under one definition identity: audit - can group by definition ("what did code-reviewers do this week?") or by - instance ("what did *this* delegation do?"). -- **Child identities** extend the parent's *instance* path with - `child/` (the verbatim runtime id, per the block above). - Within one instance the identity graph and the - runtime delegation graph cannot drift; across instances the child id is - not globally unique (provider call ids are per-conversation), which is - exactly why the instance tier must qualify them. -- **Runs are not identities.** A run carries the instance credential plus a - per-run `txn` claim; resumption re-proves the instance, it does not create - one. - -Why not session-as-identity (an earlier draft's mistake): a session is -*state*, not agency. Identity anchored to it inherits the wrong lifetime -(sessions reopen across runs and migrate across pods) and the wrong semantics -(users grant authority to *kinds* of agents, not to conversations). The -definition/instance/run split fixes both while mapping onto types mecatl -already has. - -## The credential: a JWT-SVID with a delegation vocabulary - -Instance SVIDs are SPIFFE JWT-SVIDs with private claims, spec-shaped (`sub`, -`aud`, `exp`, bundle-published signing keys). JWT-SVID §3 permits this in so -many words ("Registered claims not described in this document, in addition to -private claims, MAY be used as implementers see fit"), then warns that -reliance on them may impact interoperability. Accepted; this vocabulary is -ours to carry. Private claims use collision-resistant names -(`https://mecatl.dev/claims/…`, the JWT private-claim convention); the table -uses short names for readability: - -| Claim | Meaning | -|---|---| -| `sub` | the instance SPIFFE ID (spec) | -| `aud` | intended verifier(s) (spec; single-audience strongly recommended per JWT-SVID §7.2, to bound replay) | -| `exp`/`iat`/`jti` | short TTL, unique per mint (spec) | -| `delegation_chain` | ordered `{id, def, authorization_details}` entries, user → … → parent. Append-only, signed at mint. 8693 `act`-nesting semantics (below), explicit and carrying a per-hop scope snapshot. The name mirrors `draft-liu-agent-operation-authorization` / `draft-liu-oauth-chain-delegation` | -| `authorization_details` | the effective authority of this instance, RFC 9396's registered claim for structured authorization (see vocabulary below). Issuer-enforced invariant: **strict subset of the parent's** | -| `depth` / `max_depth` | how much further this identity may delegate (the RFC 3820 `pCPathLenConstraint` idea, in JWT form; absorbed into `authorization_details.constraints` if 9396 is adopted wholesale) | -| `txn` | immutable correlation ID for this run, **registered in RFC 8417 §2.2** and borrowed by the Txn-Token draft; parent's `txn` is recorded in the chain, preserving the tree without one giant ID | -| `cnf` | proof-of-possession binding for outbound presentation: RFC 9449 `cnf` with a `jkt` thumbprint, not a new claim (Phase 3) | - -### The delegation semantics (8693-derived, stated in our vocabulary) - -The semantics are RFC 8693's, but the **evaluation rule must be stated in -this token's own terms**: quoting 8693 §4.1 verbatim would be wrong here, -because this token's shape differs from 8693's in the one place that rule -keys on. In canonical 8693, `sub` is the *delegator* and the outermost -`act.sub` is the *current actor*; §4.1 keys access control to "the token's -top-level claims and the party identified as the current actor," with prior -(nested) actors "informational only." This token has no top-level `act`, and -its `sub` *is* the current actor (JWT-SVID §3.1 requires `sub` = the holder's -own SPIFFE ID). So the rule, restated for this shape: - -1. **Authorization keys off `sub` + `authorization_details`.** `sub` is the - acting instance (the credential holder); `authorization_details` is the - authority the issuer bound to *this* instance at mint. A verifier grants - only what the issuer bound, never a right re-derived from the chain. -2. **The chain is signed provenance, never an authz input.** Prior actors in - `delegation_chain` are audit only, the same posture 8693 §4.1 and - actor-profile §3.2 both take toward nested actors. The issuer's mint-time - subset invariant is what makes `authorization_details` trustworthy; the - chain is the verifiable *proof that the narrowing happened*, not a source - of rights. -3. **The current actor IS an authority input, and here it is `sub`.** - actor-profile-00 §14.5 warns that evaluating *only* the subject when a - delegation is present is a confused-deputy risk, and §3.2 makes the - current actor part of the decision. This token honors that: the acting - instance (`sub`) plus its bound authority (`authorization_details`) *are* - the evaluated pair. The confused-deputy reading doesn't bite because the - verifier trusts the issuer's narrowing, not the bare identity. -4. **Delegation, never impersonation.** 8693 §1.1 separates the two: - impersonation makes the issued token's `sub` the delegator (the actor - vanishes); delegation names the actor. The model is strictly delegation: - the user is always at the root of `delegation_chain`, the acting instance - always named. (The industry counterexample is GitHub Copilot's coding - agent: commits attributed to the user, no visible actor, impersonation - with no audit trail. We refuse that shape.) - -**Why a private claim instead of RFC 8693's `act` itself.** The two specs -assign `sub` incompatible meanings: JWT-SVID §3.1 requires `sub` to be the -credential holder's own SPIFFE ID (the acting instance), while 8693 -delegation (§4.1, Appendix A.2.5) puts the *delegator* in `sub` with the -actor in `act`. (8693 states this as "typically" plus an illustrative -appendix rather than a single normative MUST, but §4.1 does define `act` as -the party *to whom* authority was delegated and keys access control to it, -which is the load-bearing text.) A single token cannot be simultaneously a -conformant JWT-SVID and a conformant 8693 delegation token. So the chain -rides a private claim with 8693-derived semantics; `act` is honored as the -semantic contract rather than reused as the on-the-wire name. A secondary -reason: 8693 §4.1 confines `act`-subobject claims to identity ("claims -within the act claim pertain only to the identity of the actor"), while our -chain entries carry a per-hop **scope snapshot** (the basis of attenuation -audit and of the resume invariant), which could not ride `act` conformantly -either. Note also that 8693's nested `act` already *is* a history trail in -the token; the honest difference here is not "explicit vs emergent" but that -`delegation_chain` is **issuer-enforced** (not AS-discretionary) and carries -the per-hop scope snapshot `act` forbids. - -**Revisit triggers**: conditions under which this choice flips to a -standard-`act` shape, recorded so a future reader knows when to reopen it: -(a) WIMSE ratifies an agent credential profile whose `act` semantics are -compatible with JWT-SVID's `sub` = holder convention; (b) `go-spiffe/v2` -grows an alternative identity field (e.g. an actor from `act.sub`), making -library identity correct regardless of `sub` semantics; (c) a widely -deployed JWT audit tool emerges that deep-walks multi-hop `act` chains -(canonical semantics would then be the documented expectation); (d) the -Phase-3 external verifier ecosystem (`scoped-resource-grants.md` -consumers, federated domains) demands `act`-named claims for interop; -(e) SPIFFE issues guidance letting delegation-carrying tokens set `sub` -to the delegator. One is already live: actor-profile-00 §3.2 ratifies -"`sub` is the authorizing principal", the 8693 side, so if that profile -is adopted, the collision resolves *against* this doc's current `sub` = -acting-instance choice; flag it, don't hide it. Until one fires, -`delegation_chain` stands: an 8693-trained reader misreads an inverted -`act` *confidently*, and confident misreading in an audit context is worse -than a private claim. - -**`may_act` (8693 §4.4)**, the claim that pre-authorizes which actors may -**act for** a subject (it covers impersonation as well as delegation, and it -is near-dead in deployment: Keycloak gates it behind an experimental flag -and doesn't support `actor_token` at all), is played by **definition-tier -policy**, not a token claim: "user U may spawn definition D", "D may spawn -children of definitions {…}". Those are the three nested envelopes (user -grant ⊇ definition policy ⊇ instance scope ⊇ child scope) from the tier -model. Same consent-hook semantics as `may_act`, expressed as issuer policy -because our exchange is in-process (below) rather than an STS the user -consents through, and because `may_act` itself is barely deployed. - -**Where 8693 ends and the novel part begins.** 8693 *permits* scope -narrowing at exchange; nothing structurally enforces it (a misconfigured -STS can mint wider). Our issuer **refuses** to mint a child whose -`authorization_details` is not a strict subset: attenuation as an -invariant, not a policy option. And the exchange itself is in-process: a -child mint happens at the delegation seams (`buildChildSession`, the team -member factory), so 8693's wire protocol (`grant_type=token-exchange`, -`subject_token` / `actor_token`) never runs. It only enters if the issuer -is later exposed as a network STS for external consumers, at which point -these claims slot into 8693's envelope unchanged (an instance JWT-SVID is -already a valid `actor_token` of type -`urn:ietf:params:oauth:token-type:jwt`). `depth`/`max_depth` has no 8693 -analog; it comes from RFC 3820. A JWT is immutable once signed, so a -holder cannot attenuate its own token. Narrowing always means the issuer -mints a new one; that is cheap here because minting is a local signing -call (see the lifecycle section), not a network round-trip. - -### The parkable credential lifecycle (innovation ground: Q2) - -No standard or published deployment models a credential lifecycle for a -workload that parks mid-operation for an indeterminate human-in-the-loop -wait and resumes on a different host. The converged TTL floor: SPIRE -defaults (X.509 1h, JWT 5m; JWT-SVIDs are minted fresh per request, -so there is no rotation schedule to inherit, and mint-per-request *is* the -"credential ephemeral" half of the contract below), and WIMSE WIT "hours, -PoP minutes, never bearer". A mecatl session can **park for hours awaiting a -human approval and resume on a different pod**, and no standard models -that. WIMSE's practices draft says tokens "SHOULD be invalidated when the -workload *pauses*" and explicitly leaves the mechanism out of scope. Two -live IETF schools are adjacent: vault/broker re-attestation, and Zhu's -async-delegated refresh tokens (short TTL + re-derive from durable state, -monotonic scope, absolute max lifetime). This design **converges with** Zhu -on the re-derivation; what is genuinely new is the -parked-and-resumed-on-another-pod lifecycle, not the re-derivation itself. - -The design: **chain durable, credential ephemeral.** - -- Instance SVID TTL is minutes; the harness re-mints on demand (it is the - issuer, and minting is a local signing call over the in-memory intermediate - key, not a network dependency; see the issuer section). -- A parked session's SVID simply expires. Nothing is revoked because nothing - needs to be: TTL ≪ any useful attack window. TTL-only is a *considered and - rejected* alternative to a revocation list. Sweeney's delegation draft has - the only fully worked revocation design in this space, and it is heavier - than the risk window a minutes-long TTL leaves. -- Resume (the existing `rehydrateSession` seam) re-mints the **same instance - identity with the same `delegation_chain` and an - `authorization_details` no wider than pre-park**. - This enforces monotonic attenuation across resume, issuer-side, against the - persisted chain. Two refinements, from review: (a) where a *live caller* - exists (an approval click, a parent turn resuming a child), authority is - derived from the live caller and the persisted chain is only the *bound* - it is checked against. The record is a cache; the live binding is the - authority. (b) Where the chain itself must be authoritative (the - scheduled fire, the one case with no live caller), the issuer signs the - chain at mint and verifies it before re-mint. That way the row proves - its own integrity rather than being trusted for being in the database. - This state machine (active/parked, re-attest-on-resume, attenuation - preserved across pod boundaries) is genuinely novel; the doc treats it - as a named contribution, not an implementation detail. -- Session GC (PrunableStore) plus short TTL bounds the - deleted-session-but-live-token window without a revocation list. Internal - verifiers may additionally check `jti`↔session-liveness against Redis - (cheap in-cluster: a liveness hint, not revocation, since the harness - re-mints on demand); external verifiers stay offline/TTL-only. - -### The scope vocabulary (innovation ground: Q4) - -Nobody has standardized what an agent may *do* in a token: MCP scopes are -server-defined strings, A2A says "agent-defined", Entra reuses Graph resource -scopes, Cedar/OPA are app-defined. The claim itself is **not** a private -invention: RFC 8693 §4.2 registers `scope` as a space-separated *string* -(RFC 6749 §3.3), which cannot carry structured authority. So the structured -payload rides **RFC 9396 `authorization_details`**, the registered, -production-proven (FAPI) claim for exactly this. A v1 entry: - -```json -{"type": "mecatl_agent", - "operations": ["Read", "Grep", "Shell"], - "resources": ["/workspace/repo"], - "constraints": {"posture_ceiling": "auto", "max_depth": 3}} -``` - -- **`operations`**: the catalog tool names the permission evaluator already - resolves (`engine/governance`, the deny-dominant, scope-ordered rule - evaluator; its `Audience` enum already distinguishes main- vs - subagent-bound rules, the closest existing thing to an attenuation - channel); -- **`resources`**: the workspace roots this instance may touch, with prefix - containment after canonicalisation. This axis is *required*, not optional: - a model-created session (a scheduled fire) can name an arbitrary workspace - root today (`validateScheduleSpec` checks non-emptiness only), so a child - narrowed on tools and posture could otherwise read a strictly *larger* - filesystem than its parent. Construction is exactly what a scoped - principal must not be able to widen. -- **`constraints`**: the posture ceiling (the `strict < trusted < auto < - yolo` ladder from `internal/app/posture.go`; a child's ceiling ≤ its - parent's) and delegation rights (`max_depth`, which definition identities - it may spawn). - -Providers/models and egress are candidates for a later vocabulary revision. -The subset computation over this structure, and who is obliged to refuse a -widening, is the genuinely unspecified part (see "Prior art"): the issuer -computes containment over `operations` (set), `resources` (canonicalised -prefix), and `constraints` (posture rank, depth integer) and refuses to mint -on any widening. - -### Projection, not authority (converged pattern: Q3) - -The SVID is a **projection** of the session's authority for outbound calls, -never the source of it. Internally, authority remains exactly where it is -today: the session aggregate plus the deny-dominant governance evaluator -described above. This mirrors the SPIFFE Workload Endpoint model (the local -agent is the authority; the SVID exists to leave the node) and WIMSE's -Identity Proxy / egress generalization pattern (token exchange at the -boundary, internal context stays local). The in-process generalization (the -*session aggregate* minting projection tokens for its children rather than -delegating to an external STS) is a pattern many systems use implicitly. -Nobody has articulated it as a named primitive. It is called out here because -the definition/instance/run mapping makes it explicit and testable. - -Two projection rules follow. **Single-audience is required, not merely -preferred.** A downstream that re-presents a child SVID to a sibling backend -is a confused deputy; requiring single-audience (JWT-SVID §7.2 already -strongly recommends it, for replay) closes that. And **the SVID's scope -claims must only ever narrow on re-mint** (the projection must never be the -path by which authority grows). - -A consequence that simplifies everything: **subagents are not network -entities**. A subagent is a goroutine, not a pod; it never presents its own -SVID. When the harness makes an outbound call attributable to a subagent -(a forge API call, an MCP call), the *harness* presents the subagent's SVID -plus its own proof-of-possession. Only the pod tier holds keys; everything -below is signed claims. No per-subagent key material, no PoP problem below -the pod. - -## Threat model and trust boundaries - -Before the worked examples, the boundaries this design draws and what it -does and does not defend. The point of the identity layer is not to make the -harness trustworthy. It is to make its delegations *verifiable* and its -attenuation *enforceable* by parties who do not have to take its word. - -### Trust boundaries - -``` - ┌─────────────────────────────────────────────┐ - │ UNTRUSTED / PUBLIC │ - │ (the internet, a forge's other tenants, │ - │ a downstream service's other callers) │ - └───────────────▲─────────────────────────────┘ - │ outbound call + SVID + PoP - ═══════════════ BOUNDARY 3: the trust-domain edge ═══════════════ - │ (verifiable by bundle) │ -┌───────────────────────┴───────────────────────────────────────────┐ -│ TRUST DOMAIN: the mecatl deployment (one issuer, one bundle) │ -│ │ -│ ┌──────────────┐ gRPC/HTTP + OIDC bearer ┌───────────────┐ │ -│ │ the user │ ──────────────────────────► │ edge auth │ │ -│ │ (browser / │ BOUNDARY 1: the user edge │ interceptor │ │ -│ │ CLI client)│ └───────┬───────┘ │ -│ └──────────────┘ │ binds principal -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ a mecatl pod (any autoscaled replica) │ │ -│ │ │ │ -│ │ issuer (KMS root ──► in-memory intermediate) │ │ -│ │ │ mints │ │ -│ │ ▼ │ │ -│ │ session instance SVID ──► subagent SVID ──► grandchild │ │ -│ │ (attenuation enforced HERE, at the mint seam) │ │ -│ │ │ │ -│ │ session aggregate + governance evaluator = authority │ │ -│ └──────────────┬───────────────────────────────┬───────────┘ │ -│ │ BOUNDARY 2a: state │ BOUNDARY 2b: │ -│ ▼ ▼ downstream │ -│ ┌───────────────────┐ ┌──────────────────────┐ │ -│ │ Redis (sessions, │ │ ToolHive vMCP / a │ │ -│ │ event log) — the │ │ backend MCP server │ │ -│ │ root of trust on │ │ (separate trust │ │ -│ │ resume │ │ domain, verifies │ │ -│ └───────────────────┘ │ the SVID) │ │ -│ └──────────────────────┘ │ -└───────────────────────────────────────────────────────────────────┘ -``` - -- **Boundary 1: the user edge.** The user authenticates to the edge - interceptor (OIDC bearer / mTLS); the edge binds the session to - `user/`. Everything downstream trusts that binding. This is the one - place a human's identity enters, so it must be right: a mis-issued - principal here poisons every chain rooted at it. (Scheduled fires bypass - it by construction, see "What breaks today", so the owner record, not - the edge, is primary.) -- **Boundary 2a: state.** Redis holds sessions and the event log; on - resume *all* authority is re-derived from it. It is the root of trust for - the whole model, which is why its current unauthenticated, unencrypted, - un-MAC'd state is a prerequisite fix (B5), and why the chain carries - issuer-signed integrity rather than trusting the row (the resume - invariant). -- **Boundary 2b: downstream.** A backend service (ToolHive vMCP, a forge, - a memory/filesystem service) is a *separate* trust domain. It does not - trust the harness's word; it verifies the presented SVID against the - bundle, offline. This is the boundary the whole design exists to serve. -- **Boundary 3: the trust-domain edge.** The line between "inside, where - the issuer is believed" and "outside, where only the signature speaks." - Inside, the session aggregate is the authority; outside, the SVID is a - verifiable projection of it. - -### What the adversary can and cannot do - -The adversary model has three actors the design speaks to directly, and -several more it must name honestly. First the three: - -- **A compromised subagent runtime (prompt injection reaching a child).** - Cannot mint a wider credential: it holds no key, only the harness does, - and the issuer refuses to mint outside the strict-subset invariant. It - *can* abuse the authority its own SVID already carries, which is exactly - why that authority is a narrow subset, and why the ceiling is Entra-style - hard-blockable independent of the issuer. Contained by attenuation, not - by trusting the child. **Honesty note:** "the issuer refuses to mint - wider" is the Phase-2 mechanism. Today nothing is minted; a child's - authority is scoped by the catalog composition builds for it plus the - audience-pinned, deny-dominant evaluator (`engine/governance`), real and - tested, but a convention, not a cryptographic containment. The - containment claim is only as strong as that distinction, and the doc's - phasing carries it. -- **A compromised pod (can request signatures, can write Redis).** This is - the honest worst case, and the design does not pretend otherwise: **the - pod that can sign is the pod that can impersonate any session** (see - "Honest costs and risks"). Mitigations are about blast radius and - detection (KMS-rooted short-lived intermediates, KMS-layer signing - audit, short TTLs, Redis auth/TLS), not about making the pod trustworthy. -- **An external party holding a leaked SVID.** Bounded by TTL (minutes), and - a parked session's token expires before it can be replayed. Until Phase 3 - adds the PoP binding (`cnf`/`jkt`), every instance SVID is a *bearer* - token, so "TTL only, until Phase 3" is the accurate statement; PoP is - future protection, not present. Cross-checking the SVID against the forge's or KMS's own audit - log is what turns "trust our logs" into "verify the chain." - -Five more the model must name, or it is incomplete for a multi-tenant AI -harness: - -- **Prompt injection at the *main* agent (P1).** Strictly more dangerous - than at a child: the main agent holds the full tool set, spawns children, - sets their mode and prompts. Cryptographic identity is *mostly* not the - defense here; the attacker is driving the harness's own prompt, inside the - pod, so the containment is the harness's existing defenses (guardrails, the - deny-dominant fold, the plan-mode gate), which live at this boundary. The - one identity-carried lever is `constraints.posture_ceiling`: a ceiling on - what an injected main agent can grant a child, which the Entra hard-block - pattern (a ceiling independent of issuer correctness) makes stronger than - trusting the issuer. Note the defenses P1 relies on are themselves - posture-conditional (guardrails drop to advisory at the top of the - ladder). -- **A malicious tenant (P2).** A user with legitimate authority is the - relevant multi-tenant threat: valid credentials, can create sessions and - run agents. The identity layer makes their actions *attributable* but - does not *confine* them. Tenant isolation (User A reading User B's - session) needs tenant-scoped `ListSessions`, tenant-scoped event-log - reads, and a tenant-aware edge, i.e. policy, not identity. "What breaks - today" describes the symptom; the adversary is named here. -- **A mis-binding edge (P3).** A compromised or misconfigured edge - interceptor could bind a session to `user/alice` when the OIDC token was - Bob's, and every SVID minted under the wrong principal inherits the - error, undetectably downstream. The naive mitigation (the edge writes a - signed binding, the store records it, audit compares the two) is - **circular**: a compromised edge holds the signing key, writes Bob-as-Alice - in both places, and the comparison agrees with itself. What actually works - is anchoring to something the edge cannot forge: keep the issuer and - identifier of the *original* assertion from the identity provider, so an - auditor re-checks the binding against the provider, not against the - component under suspicion. -- **A compromised downstream credential store (P4).** Scenario B has - ToolHive storing Alice's upstream credentials. A compromised ToolHive - leaks the credential itself. The identity layer limits *attribution* (you - learn which delegation used it) but does not protect the credential. - The store is its own trust domain, and the boundary must be named so - operators know where credential protection ends. -- **Event-log replay / exfiltration (P5).** The log persists unauthenticated - JSON (prompts, pre-compaction conversations, verdicts). An attacker with - Redis read access reconstructs every delegation tree. The log carries no - MAC; Rekor-style transparency anchoring (Phase 3) is a future integrity - measure, not a current defense. Two refinements: the anchoring must be - over *digests only* (anchoring prompt content to a public log is an - exfiltration channel, not a defense), and Redis auth/TLS covers the - transport-confidentiality half. - -Three more the architecture implies, which the first pass missed: - -- **A delegated token that carries the credential-store reference.** A - narrowed child then reaches the user's *whole* stored credential set at - the gateway, so attenuation over tools and resources is bypassed one layer - down. This is not P4 (the store being compromised); it is ordinary - delegation reaching too far by construction. Closing it is the user-keyed, - target-scoped credential read (a refused call reads no credential) from - the scenarios. -- **A store writer who cannot sign.** A leaked database credential against - an unauthenticated store is likelier than pod compromise, and this - adversary *cannot* forge a signed chain, which is exactly what - chain-integrity (issuer signs at mint, verifies before re-mint) defeats. - Naming it separately from "the pod that can sign" keeps that mitigation - from looking less valuable than it is. -- **A substituted trust bundle.** The whole value of offline verification - collapses if the JWKS endpoint serves a poisoned bundle: every external - verifier then accepts forged chains. The costs section raises that - endpoint's *availability*; its *integrity* (authenticated, tamper-evident - bundle distribution) is the sharper requirement and is named here. - -What the design does **not** defend: a malicious or confused *user* acting -within legitimately granted authority (that is policy, not identity); the -harness lying about what a user asked (the signature adds tamper-evidence, -not truth; the user's consent is out-of-band); and anything below the pod -it cannot attest (the goroutine boundary is why it is its own issuer, not a -defense). - -## Two end-to-end scenarios (ToolHive as the testing ground) - -These walk the model through a real downstream: **ToolHive's vMCP**, which -has already implemented most of the delegation/identity constructs this -design must interface with: an embedded authorization server that mints -nested-`act` delegation tokens, an outgoing-strategy registry, an -XAA/ID-JAG strategy, and a Cedar authorization layer that evaluates the -actor claim (`context.claim_act.sub`, today as a `like`-glob with the user -as principal). ToolHive is the example **not** because it is the -only way. The same shape applies to any downstream that supports these -constructs, such as a memory service or the filesystem abstraction from -`docs/scoped-resource-grants.md`. It is the example because it is a live, -code-complete testing ground where the integration points already exist. - -**A note on what is fixed and what is ours to change.** The scenarios below -reference current ToolHive behavior: vMCP validates inbound tokens with a -self-issued validator only (the multi-issuer validator is unwired), the -credential lookup is keyed on a `tsid` login-session claim, and Cedar -matches the actor as a string glob. **These are not constraints to design -around.** The mecatl team owns ToolHive and its Cedar layer, so each is an -enhancement with a tracker, not a limitation: the multi-issuer validator -(#5989), `cnf`-bound agent tokens (#5815), a provisionable confidential -client (#6082), user-keyed credential reads with an ownership recheck, and -structured SPIFFE-aware Cedar evaluation. The scenarios describe the model -as it should work once those land; where current code falls short, the text -says so rather than pretending otherwise. - -The cast for both: **Alice** (user) → **her mecatl agent** (session -instance `agent/main/inst/`) → **a code-reviewer subagent** -(`.../child/subagent-`) → **ToolHive vMCP** → a backend MCP server. -The two scenarios use different backends on purpose, because they exercise -different grants: Scenario A needs an authorization server that accepts -ID-JAG (an *enterprise* service federated with the org's IdP; public SaaS -like GitHub does not), Scenario B uses the GitHub MCP server (which the -plain credential-store path genuinely serves). - -### Scenario A: the XAA / ID-JAG path - -Here the backend's authorization server does not trust mecatl's trust -domain directly, so the delegation crosses domains via the Identity -Assertion JWT Authorization Grant. mecatl's chain rides the token the -whole way. **The backend must be an enterprise service whose AS implements -the ID-JAG target grant**: the canonical case is an internal corporate API -or an Okta-federated enterprise app (ID-JAG, -`draft-ietf-oauth-identity-assertion-authz-grant`, is an Okta-authored -enterprise-IdP→enterprise-app grant). A public SaaS is *not* a valid -example: GitHub's authorization server supports only `authorization_code` -and `device_code`, accepting no token-exchange and no ID-JAG, so this -flow would stop at step 5b. That is why Scenario A uses a corporate -code-review service, and GitHub appears only in Scenario B. - -**How mecatl's agent identity reaches the gateway is the part current code -does not yet do, and it is the trust-placement fork this doc leaves open.** -vMCP today validates inbound tokens with a self-issued validator and derives -the actor from the authenticated client, so a mecatl-self-signed SVID is not -accepted as an outbound actor. Two ways through, both ours to build: (a) -**two-leg** (the shape jhrozek's outbound doc, `agent-identity-outbound.md`, -proposes): the pod `client_credentials`-authenticates with its SVID for an -AS-minted agent token, then RFC 8693 exchanges Alice's token with that agent -token as actor, so the gateway's AS asserts the agent and mecatl signs -nothing that crosses the boundary; or (b) **single-mint**: teach vMCP to -validate mecatl's SVID directly (the multi-issuer validator, #5989, plus a -`cnf` binding, #5815), so mecatl asserts the agent as its own issuer. The -diagram below shows the two-leg shape because it works against the AS vMCP -already has; the fork between them is a real decision (who asserts the -agent), not a settled one. - -``` - Alice mecatl (issuer) ToolHive vMCP enterprise AS + MCP - │ │ │ │ - │ 1. OIDC login │ │ │ - │───────────────────────>│ │ │ - │ │ 2. mint instance SVID │ │ - │ │ sub=agent/main/inst/ │ - │ │ delegation_chain=[{user/alice}] │ - │ │ 3. spawn subagent, mint child SVID │ - │ │ authorization_details ⊂ parent's │ - │ │ delegation_chain=[alice, agent] │ - │ │ │ │ - │ │ 4. subagent's work needs a code-review call │ - │ │──────────────────────>│ │ - │ │ child SVID + harness PoP (cnf/jkt) │ - │ │ │ │ - │ │ │ 5. XAA strategy: │ - │ │ │ a) IdP exchange │ - │ │ │ (RFC 8693): │ - │ │ │ identity. │ - │ │ │ UpstreamIDTokens │ - │ │ │ [alice] ────────>│ - │ │ │ → ID-JAG (aud= │ - │ │ │ backend AS) │ - │ │ │ b) target grant │ - │ │ │ (RFC 7523): │ - │ │ │ ID-JAG ─────────>│ - │ │ │ → backend-scoped │ - │ │ │ access token │ - │ │ │ │ - │ │ │ 6. call backend MCP │ - │ │ │─────────────────────>│ - │ │ │ Authorization: │ - │ │ │ Bearer │ -``` - -What to notice: - -- **The user never disappears.** Alice authenticates once at the mecatl - edge; her identity is the `sub` of every downstream token, and the - mecatl chain (`delegation_chain=[alice, agent]`) is the verifiable record - of who narrowed what on the way. The backend sees `sub=alice` with the - agent in the actor position: delegation, never impersonation. -- **Crossing domains is XAA's job, not mecatl's.** mecatl does not ask the - backend's AS to trust its trust domain; the ID-JAG is the standard - bridge. mecatl's SVID is what the vMCP validates to know *which* agent - instance is calling; the XAA strategy then does what it already does for - any caller. -- **The attenuation is verifiable by the backend; the credential scope is - enforced at the gateway.** The child SVID's `authorization_details` is a - strict subset of the parent's, signed at the mint seam, and RFC 8693 §4.1 - carries the `act` chain *in* the output token, so any consumer (gateway or - backend) can verify the delegation. But the *credential* the backend acts - on is enforced at the gateway: the backend receives only the provider - credential the user granted at connect time, so the only hop that can - refuse a call against the actual authority is the gateway. The backend - verifies the chain; the gateway enforces the scope. (Cedar evaluates the - agent's SPIFFE ID as a `like`-glob on `context.claim_act.sub` with the - user as principal, not as trust-domain-aware matching; see the intro note - and the work list.) - -### Scenario B: ToolHive with a credential store keyed off the user - -Here there is no cross-domain grant; the backend accepts the user's own -upstream credential, which ToolHive stores keyed off the originating user. -mecatl's job is to make sure the *right* user's credential is used, and -that the delegation that led there is auditable. **GitHub is a realistic -backend here** precisely because it needs no special grant: the user does a -standard 3LO OAuth consent, ToolHive stores the resulting token, and the -GitHub MCP server accepts an ordinary Bearer access token, the path XAA -cannot take. - -``` - Alice mecatl (issuer) ToolHive vMCP upstream IdP + MCP - │ │ │ │ - │ 1. OIDC login │ │ │ - │───────────────────────>│ │ │ - │ │ 2. mint SVIDs as in A (attenuated child) │ - │ │ │ │ - │ (earlier: Alice did a 3LO consent; ToolHive stored her upstream │ - │ credential under her token-session id `tsid`) │ - │ │ │ │ - │ │ 3. subagent needs a GitHub MCP call │ - │ │──────────────────────>│ │ - │ │ child SVID + harness PoP │ - │ │ │ │ - │ │ │ 4. validate inbound │ - │ │ │ token, extract │ - │ │ │ `tsid` claim │ - │ │ │ 5. loadUpstreamTokens│ - │ │ │ GetAllUpstreamCreds │ - │ │ │ (tsid) ────────────>│ - │ │ │ → identity. │ - │ │ │ UpstreamTokens │ - │ │ │ ["github"] │ - │ │ │ │ - │ │ │ 6. upstream_inject │ - │ │ │ strategy reads │ - │ │ │ UpstreamTokens │ - │ │ │ ["github"] │ - │ │ │ │ - │ │ │ 7. call backend MCP │ - │ │ │─────────────────────>│ - │ │ │ Authorization: │ - │ │ │ Bearer │ -``` - -What to notice: - -- **The credential is keyed off the originating user, not the agent, and not - a login-session claim.** The mechanism matters here: today ToolHive keys - the read on a `tsid` claim minted only in a browser authorization-code - flow, which an agent never walks, and the token-exchange handler drops any - inherited one. So a delegated (agent) token carries no `tsid`, and the - `tsid`-keyed lookup returns an empty map with no error. **That is the gap, - and it is ours to close** (it is the current implementation, not a - protocol law): the read should key on the *user* (`sub`/`UserID`), the - primitive for which already exists (`GetLatestUpstreamTokensForUser`), and - re-check the inbound `sub` against the stored `UserID` at the read seam - (`ErrInvalidBinding` is declared but never returned on this path). The - diagram shows the target shape; the `tsid`-keyed load is what exists - today. -- **Under this design's own tier model the inbound `sub` is the acting - instance, not Alice** (she rides `delegation_chain`). So "the right user's - credential" rests on the read resolving Alice from the chain's root, not - from `sub`. That is exactly the user-keyed read above: the gateway reads - Alice's stored credential because the delegation is rooted at her, not - because a login session says so. The agent never holds Alice's GitHub - token; it only triggers its use, against a lookup keyed on the user the - chain proves. -- **The audit trail closes the loop.** mecatl's event log records the - chain (Alice → agent → subagent) with `txn`; ToolHive's audit captures - the delegation chain from the inbound token. The `txn` correlation id is - the join key that lets an auditor answer "which delegation used Alice's - GitHub credential, and was it narrowed all the way down?" - -### Why ToolHive and not only ToolHive - -Both scenarios lean on constructs ToolHive already ships: the embedded AS's -nested-`act` delegation minting, the outgoing-strategy registry (XAA, -upstream_inject, token_exchange), the user-keyed upstream credential store, -and Cedar's SPIFFE-aware actor evaluation. That makes it the shortest path -to a working proof. But nothing in the design is ToolHive-specific: any -downstream that (a) validates a JWT-SVID against the bundle, (b) carries a -delegation chain, and (c) enforces an authorization decision from the actor -plus a structured scope can play the same role: a memory service, the -filesystem grant substrate, or another gateway. The identity model is the -portable part; ToolHive is just where we plug it in first. - -## Build vs buy - -So how much of an issuer do we actually have to write? Less than it sounds: -becoming an issuer does not mean writing subtle security code from scratch. -The principle: build on proven libraries for everything cryptographic; -build only the parts that are genuinely ours (the claim vocabulary, the -attenuation invariant, the lifecycle). The stack: - -- **go-spiffe/v2**: SPIFFE ID and trust-domain parsing, bundle sources, - federation, the Workload API client, and JWT-SVID *verification*. Note: - its `jwtsvid` package is parse-and-validate only. There is no minting - helper, so the issuing side is genuinely ours to write (a small signer). -- **go-jose/v4**: signing and JWKS serialization. This is what SPIRE - itself uses, and it is already in mecatl's module graph (indirect today; - make it direct). -- **sigstore/sigstore `pkg/signature/kms`**: key custody. Its - `SignerVerifier` exposes a `crypto.Signer` over AWS, Azure, GCP, and - Vault behind go-cloud-style URIs, so KMS choice becomes config rather - than four integrations. Apache-2.0, OpenSSF, and cosign runs on it. - (Net-new dependency, as is `go-spiffe/v2`; only `go-jose` is already in - the graph.) -- **Housekeeping**: mecatl already pulls several JWT/JOSE libraries - transitively (go-jose v3+v4, golang-jwt v5, lestrrat-go/jwx v3, - cristalhq/jwt v4). Pick one, make it direct, drop the rest. - -The one area with real existing art worth a spike before building: -**Biscuit** (public-key, offline, holder-side attenuation via Datalog -blocks). Its model is the inverse of ours: the *holder* appends narrowing -blocks, whereas our invariant is enforced *issuer-side* at the mint seam. -Issuer-side is the correct model for a harness that IS the issuer (a -compromised subagent runtime must not be able to decline to attenuate, and -only the harness holds the signing key). But the Go implementation is small -(~89 stars): a timeboxed spike to read its subset-checking before -hand-rolling our own, not a dependency decision. - -## The issuer in a Kubernetes deployment - -mecak8s (ADR 0048, the storage-free, Redis-backed, -k8s-native agent deployment) already fixed the topology the issuer must fit: -storage-free agent pods, autoscaled, state in Redis, single-writer via k8s -leases, any pod rehydrates any session. The industry pattern for issuer HA in -exactly this shape is **converged** (Q1): stateless replicas, signing keys -that never leave a signing service. - -- **Key custody**: one logical issuer. The root key lives in a KMS/Vault - Transit-style signing service. **Each pod signs locally with a - short-lived intermediate key the KMS signs at startup and rotation**, - the SPIRE upstream-authority pattern (KMS as root, in-memory intermediate - for workload signing). This resolves an apparent contradiction: - KMS-per-mint would make every delegation a 10–50ms network call, and bare - software keys would put the root in pod memory. The intermediate gives - KMS-rooted trust with local-signing performance, and its blast radius is - bounded by the rotation window. (SPIRE's KMS-backed signing lives in its - *key manager* plugins, `aws_kms`/`azure_key_vault`/`gcp_kms`/ - `hashicorp_vault`, with the upstream-authority plugins as the CA side; - cert-manager's external-issuer pattern is the same shape.) **Honesty note - on reachability:** local-signing buys audit/integrity and availability, - not process isolation. The Shell tool runs in the same filesystem namespace - as the process holding the intermediate key, with no OS-level isolation, - and `envscrub` is irrelevant to an in-memory `crypto.Signer` (it scrubs - secret-shaped env *names*, not a resident key). So a prompt-injected agent - could read a key on disk or drive signing through the parent. SPIRE avoids - this by splitting agent and server so the signer is never co-located with - the attested workload, but that split does not map onto goroutines (SPIRE - attests a process; our subagents are not processes). The honest statement: - local-signing defends the *internal* chain's integrity; defense against the - agent process abusing signing comes from the external-AS model on the - outbound hop, or from a policy-enforcing signing sidecar (a separate - process holding the key, `SO_PEERCRED` over a unix socket, enforcing its - own policy), named here as a later-phase option rather than a v1 claim. -- **Residual risk, named**: a compromised pod cannot exfiltrate the root - key but can request signatures while it runs, and can *write* sessions - too. There is no "the identity must reference an existing session" - backstop: the session store is attacker-writable in the same compromise. - The honest statement is the one in the risks section: **the pod that can - sign is the pod that can impersonate any session.** Defenses: KMS-layer - audit logging of every sign request, short pod lifetimes, short - intermediate TTLs, and Redis auth/TLS (below, a prerequisite, since on - resume all authority comes out of Redis, making it the root of trust for - the model). -- **Bundle distribution**: the public JWKS is served by any pod (they are - stateless) and cached in Redis / a ConfigMap; in-cluster verifiers fetch - once. Cross-cluster is SPIFFE federation, and it is **not free**. A bundle - endpoint needs either the `https_web` profile (a public-CA cert) or - `https_spiffe` (the endpoint presents its own X.509-SVID, a real addition - in a JWT-only design, and this doc picks no profile yet). Federation is - bilateral registration of each domain's bundle endpoint, not discovery. - The JWKS endpoint's own SLO is an open operator question, named here - rather than deferred. -- **Composition**: the issuer is a sibling of the existing stores in - `internal/app` (`Build`). Child minting happens in - **composition-supplied factory closures** on the delegation seams, the - same idiom as `WithSubagentEngineFactory` and the team member factory, - with `engine/agent` carrying only an opaque identity string on - `parentCaps` (the `forkHistory` precedent). The engine loop stays - identity-agnostic; the EventLog analogy (loop emits, relay persists) does - *not* hold for spawn, because the child is constructed and driven inside - dispatch, and there is no downstream seam. The closer precedent for the - issuer's *lifetime* is `Deps.ChildAskReviewer`: a Build-scoped optional - interface declared in `engine/agent`, implemented in composition, consumed - per-run, and needing no `engine/port` type (which is the first place a - reader greps after "sibling of `port.EventLog`"). Session gains an inert - `Principal` label, same pattern as the existing - `Profile`/`ProviderID`/`ModelID` snapshot labels - (`engine/session/session.go`), persisted so rehydration re-derives the - same identity, and propagated to children (reject-empty: an empty - principal must not silently mean "single-user deployment"). - `port.SessionLease.Owner` is *not* overloaded with the issuer identity: - exclusion needs per-Build distinctness while attribution needs sharing, - so an additive field carries the principal instead. - -## The edge: where users come from - -The one piece with no existing seam. mecated/mecak8s have -TLS/auth/rate-limit but no *user* concept. The design needs an **edge auth -interceptor** (gRPC/HTTP): the caller authenticates with an OIDC bearer from -the corporate IdP (or mTLS client cert), and `CreateSession` binds the -session to that user principal, the `user/` at the root of every -`delegation_chain`. That requires a `principal` field on `CreateSessionRequest` -(the one wire-level addition; the session-store driver RPCs already carry -`session_id`, and a principal on the *snapshot* needs no proto change since -`sessnap` is additive opaque JSON; the event-log `principal` annotation is -the other additive change). But the edge is **one writer, not the only -source**: the durable owner record on the session is primary, and work that -starts itself (a scheduled fire) writes it directly rather than finding -nothing (see "What breaks today"). - -## Audit: the delegation tree, durably - -Converged pattern (Q5): **dual-ID**, one immutable tree ID for audit (the -`txn` claim; Txn-Token and W3C trace_id both won this argument) plus per-hop -IDs with parent links for topology. mecatl already has this shape: the -session ID is the root, `ParentCallID` on the delegation event families -(`subagent.*`/`team.*`/`parallel.*`) is the topology, and the durable -Redis-backed `port.EventLog` is the substrate. The deltas: - -- events gain a **principal annotation** (metadata-only: the existing - contract that no child-authored content ever crosses into a parent event - stream is preserved, identity, never child content); -- `txn` correlates a run across the delegation tree; the tree is - reconstructable offline from Redis alone. Note `txn` is mecatl's *internal* - join key: the credential that crosses to vMCP is minted by the gateway, so - nothing mecatl-issued can be inside it, and a separate outbound correlation - value joins mecatl's log to the gateway's. Two join keys, two boundaries; - neither spans both. -- optional Rekor-style transparency anchoring of EventLog commits is a later - consumer, not a dependency. - -Three audit problems are beyond the current frontier, flagged rather than -solved: **cross-trust-domain correlation** (Txn-Tokens stop at the domain -boundary), **child→parent result attestation** (every standard flows -parent→child; nothing lets a child return signed proof of what it did), and -**background children that outlive the parent run** (OTel assumes children -end first; mecatl's background subagents don't). - -## Phasing - -Each step is independently useful; nothing is big-bang. Phase 1 is -deliberately **unsigned-and-honest**: it ships the audit trail as a plain -`principal` annotation on the event log, a durable record of which -principal did what, on a seam (`Service.appendEvent`) that already exists, -with no signature inviting trust it hasn't earned. The alternative -(signed-and-enforcing immediately) would sign claims like -"`authorization_details` is a strict subset" while nothing enforces them: -a signed claim that isn't true yet, which is worse than an unsigned honest -one. The one-way commitments (the KMS dependency, the JWKS endpoint's SLO, -the trust-domain name) move behind the first enforcing phase, so they land -after something has been learned about whether the novel parts work. - -1. **Audit trail (unsigned)**: `Principal` on the aggregate, propagated to - children (reject-empty); the `principal` event annotation; the edge - interceptor binding users on `CreateSession`; the owner record on - `ScheduleSpec`. Delivers durable multi-user attribution. -2. **Issuer + attenuation engine (signed, enforcing)**: trust domain config, - KMS-backed intermediates, JWKS endpoint, per-session instance SVIDs with - `delegation_chain`/`authorization_details`/`txn`; the subset invariant - enforced at the child-minting seams; `depth` limits; - attenuation-preserved re-mint across resume. Delivers the delegation - model, the first phase whose signatures mean something. -3. **External surface**: outbound presentation (subagent-attributed SVID + - harness PoP via `cnf`/`jkt`, DPoP/WPT-shaped), verification middleware - recipe for downstream consumers (the `scoped-resource-grants.md` - integration point), optional Rekor anchoring. - -## Honest costs and risks - -- **We become an issuer operator**: key rotation, bundle lifecycle, KMS - dependency, signing-request audit. This is the price of the model; the - research says it is a well-trodden price (SPIRE/cert-manager/step-ca all - pay it the same way). Operational specifics this doc does not yet answer: - whether session creation degrades gracefully when the KMS is unreachable - (likely yes, since minting rides the session-open/delegation path, not the - per-turn path, so a brief outage delays new sessions without killing - running ones); whether the JWKS endpoint needs its own SLO; and whether - trust-domain renaming has a migration story or is a one-time choice best - locked early. A rename orphans every SPIFFE ID in the historical audit log. - Standard issuer-operator concerns; none novel. -- **Claim-name drift**: `delegation_chain`/`depth` are pre-standard (URI-named - to mark that); `txn` is *not*, it is registered in RFC 8417 §2.2. - `delegation_chain` is structurally RFC 8693 `act`-shaped by design and the - scope/audience semantics are 8693's, so ratification of Txn-Token / WIMSE - agent work, or interop with a plain 8693 STS, is a rename + envelope - adapter, not a rebuild. -- **Multi-tenant trust reduces to "the issuer doesn't lie."** This is the - same trust a token-exchange STS already carries, but it must be stated: - the pod that can sign is the pod that can impersonate any session. The - signature adds tamper-evidence and offline verifiability, not truth; a - compromised issuer can sign anything. What it buys is that a forge's or - KMS's audit log and mecatl's event log can be cross-checked, and an - auditor with the bundle can verify a chain without trusting the harness's - word. -- **Redis is the root of trust on resume.** All authority comes out of the - session store at rehydration; the store today dials with no auth, no TLS, - no keyspace scoping, and `sessnap` (the session snapshot format) is plain - JSON with no MAC. Redis auth/TLS is a prerequisite for any of this - meaning anything, and the chain-integrity mitigation (issuer signs the - chain at mint, verifies before re-mint) is what stops "monotonic - attenuation across resume is one `HSET`" (one Redis write command). -- **Deployment scale honesty.** This layer is over-engineered for a - single-user or single-org deployment and table stakes for a multi-org - platform. It should be **optional** (`--identity-domain`, empty = off), - like session leasing, so the operational cost is paid only by deployments - that need verifiable identity. -- **JWT size budget.** `delegation_chain` grows one entry per hop, and JWTs - ride HTTP headers (commonly capped at 4–8KB). Deep delegation trees could - exceed this. Mitigation: bounded `max_depth`, scope snapshots kept small, - and, if needed, truncating the chain to the last N hops with earlier - hops referenced by `txn`. State the budget explicitly when the vocabulary - lands. -- **Key-compromise recovery** is unwritten today: rotate the root, publish a - new JWKS, let old SVIDs expire within their (minutes-long) TTL, re-mint on - next session operation. The procedure exists but must be documented and - rehearsed. -- **Definition drift across resume.** A project-tier `AgentDef` is read from - a mutable workspace, and instance identity is deliberately stable across - reopen and migration, so a resumed instance can run a different catalog - than its credential was minted for. `draft-goswami-agentic-jwt-01` - derives identity from a hash of prompt+tools+config and forces - re-registration on change (caveats: TOCTOU under template substitution; - patent-pending). Deferred, named. -- **The scope vocabulary is ours to get right.** It is the highest-leverage - design surface in the doc and the least guided by prior art. v1 is - minimal for that reason. -- **Entra/Okta/A2A/MCP could converge on a vocabulary that isn't ours.** - Mitigation: the claims are private by design; interop is a mapping layer - later, and the doc avoids claiming our vocabulary as a standard. - -## References - -- SPIFFE specs: `github.com/spiffe/spiffe` standards: `SPIFFE-ID.md` (§2.2 - path semantics + segment charset, §2.3 length, §4.1.1 temporal accuracy), - `JWT-SVID.md` (§3.1 `sub` = holder, §3 private-claims MAY, §7.2 - single-audience), `X509-SVID.md`, `SPIFFE_Workload_Endpoint.md` (§5: no - client auth; local agent is the authority), `SPIFFE_Broker_API.md` - (on-behalf-of for referenced workloads) -- RFC 8693 (OAuth 2.0 Token Exchange: §1.1 delegation vs impersonation, §4.1 - `act` + nested-actor informational-only rule, §4.4 `may_act`, §5 scope - suggestion, A.2.5 delegation example) -- RFC 8417 (Secure Event Token: `txn` claim, §2.2), RFC 9396 (Rich - Authorization Requests: `authorization_details`), RFC 9449 (DPoP: `cnf` + - `jkt`), RFC 3820 (X.509 proxy certificates: `pCPathLenConstraint` §3.8.1 + - rights-intersection §3.8.2, prior art for `depth`/`max_depth` and for the - attenuation itself) -- IETF drafts: `draft-ietf-wimse-arch` (§3.4.11 AI intermediaries; Identity - Proxy; egress generalization), `draft-ietf-wimse-wpt`, - `draft-ietf-oauth-transaction-tokens` (`txn`, MUST-narrow replacement - §13.14 in -09), `draft-ietf-wimse-workload-identity-practices` (§5.4–5.5 - pause language), `draft-ietf-oauth-identity-chaining` (non-escalation - expectation), `draft-ietf-oauth-identity-assertion-authz-grant` (ID-JAG, - narrowing as policy MAY §4.3.3), `draft-zhu-oauth-async-delegation` - (monotonic scope across rotation), `draft-hartman-credential-broker-4-agents` - (CB4A, the Credential Broker for Agents draft: per-session agent SVIDs, - non-renewable re-attestation) -- Individual drafts (attenuation prior art): - `draft-mcguinness-oauth-actor-profile-00` (non-escalation MUSTs, - append-only chain, `sub` = authorizing principal §3.2, confused-deputy - §14.5), `draft-mcguinness-oauth-ai-agent-instance-00` (definition/instance - split, `agent_instance_id`), `draft-liu-agent-operation-authorization-02` + - `draft-liu-oauth-chain-delegation-00` (`delegation_chain`, depth cap 5), - `draft-sweeney-wimse-credential-delegation-00` (revocation design), - `draft-goswami-agentic-jwt-01` (def-drift hash) -- Holder-side attenuation: Macaroons (Birgisson et al., 2014), Biscuit - (`eclipse-biscuit/biscuit`) -- Microsoft Entra Agent ID docs (blueprint / identity / user account); - OpenAI Assistants API (assistant/thread/run); A2A spec (AgentCard/task) -- SPIRE docs: key-manager plugins (`aws_kms`/`azure_key_vault`/`gcp_kms`/ - `hashicorp_vault`) + upstream-authority plugins, HA guide, defaults - (`default_jwt_svid_ttl=5m`, JWT-SVIDs minted fresh per request, so no - rotation schedule), `MintJWTSVID` + `CredentialComposer` (implementation - details, `allow_admin`-gated) -- Repo: ADR 0027 (disposable-process arc, event - log, leasing), ADR 0048 (k8s-native storage-free - topology), [`docs/scoped-resource-grants.md`](scoped-resource-grants.md) - (the downstream grant-consumer strawman this feeds) \ No newline at end of file diff --git a/docs/drafts/agent-identity-outbound.md b/docs/drafts/agent-identity-outbound.md index 2a28cb9b16..ab3325ee70 100644 --- a/docs/drafts/agent-identity-outbound.md +++ b/docs/drafts/agent-identity-outbound.md @@ -1,8 +1,11 @@ # Agent identity, part 2: the outbound hop -Companion to [`docs/agent-identity-model.md`](agent-identity-model.md), which this -takes as its premise and does not restate. Same tier as -[`docs/scoped-resource-grants.md`](scoped-resource-grants.md). +This historical proposal takes the former identity-model draft (available in Git +history at `docs/drafts/agent-identity-model.md`) as its premise. It is related to +[scoped resource grants](scoped-resource-grants.md). The current proposed +[agent identity and authority design](agent-identity/README.md), especially +[MCP authorization](agent-identity/mcp-authorization.md), supersedes that premise; +it does not endorse the outbound mechanisms described here. ## What this decides diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 669a405075..77b690c761 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -99,4 +99,4 @@ The proposed chapters develop each part of the architecture. | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | | [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | -The [implementation and verification companion](implementation.md) records integration requirements and checks. Earlier material remains available in the [identity model](../agent-identity-model.md), [authority design](../../agent-authority.md), and [implementation notes](../../agent-authority-implementation.md). The old model's harness-owned signing and audit responsibilities are replaced here by the broker-centered architecture. +The [implementation and verification companion](implementation.md) records integration requirements and checks. This broker-centered architecture supersedes the earlier identity model's harness-owned signing and audit responsibilities; the donor documents are retained in Git history. diff --git a/docs/drafts/cloud-native-harness-kit.md b/docs/drafts/cloud-native-harness-kit.md index 733a7c59c5..1c789b115b 100644 --- a/docs/drafts/cloud-native-harness-kit.md +++ b/docs/drafts/cloud-native-harness-kit.md @@ -191,8 +191,9 @@ consumer's policy, not a structural property the kit bakes in. never become one. - [`scoped-resource-grants.md`](scoped-resource-grants.md): a strawman for the tool/filesystem substrate — scoped, leased, identity-bound service grants with a direct data path. -- [`agent-identity-model.md`](agent-identity-model.md): a strawman for agent identity — - mecatl as its own SPIFFE trust domain issuer, definition/instance/run identity tiers, - attenuating delegation, and the parkable credential lifecycle. -- [`agent-identity-outbound.md`](agent-identity-outbound.md): part 2 of the above — works out - the outbound boundary, where mecatl's SVID meets its registered-OAuth-client identity. +- [Agent identity and authority](agent-identity/README.md): the proposed broker-centered + architecture for agent definitions, instances and runs, delegated authority, audit, + and the credential lifecycle. +- [`agent-identity-outbound.md`](agent-identity-outbound.md): a historical companion to + the former identity model, exploring the outbound boundary where mecatl's SVID meets + its registered-OAuth-client identity; not the current broker-centered proposal. From c649be4b77cec471a26f7062b1dfdbccd91a5b81 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 12 Oct 2026 19:56:20 +0200 Subject: [PATCH 11/50] docs: consolidate agent identity chapter responsibilities Clarify lifecycle placement and identity terminology, align the overview with the two-call walkthrough, and replace repeated authorization and audit details with links to their owning chapters. Co-authored-by: Mecatl --- docs/drafts/agent-identity/README.md | 8 ++++---- .../agent-identity/agent-definitions.md | 16 ++++++++++++--- docs/drafts/agent-identity/audit.md | 2 +- .../credentials-and-lifecycle.md | 20 +++++++++---------- docs/drafts/agent-identity/implementation.md | 10 +++++++--- .../agent-identity/mcp-authorization.md | 2 +- 6 files changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 77b690c761..71331bbe4a 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -48,7 +48,7 @@ This design scopes broker authorization to MCP tools. Extending that boundary to ```mermaid flowchart LR U["Client"] -->|"Session creation and requests"| H["mecak8s"] - F["Agent-definition sources"] --> R["Agent-definition loader (placement TBD)"] + F["Agent-definition sources"] --> R["Agent-definition loader (mecak8s lifecycle)"] R -->|"Execution configuration"| H subgraph B["Identity broker"] I["Identity issuance and authorization"] @@ -60,7 +60,7 @@ flowchart LR V["vMCP"] T --- V end - R -->|"Definition records and authority ceilings"| I + R -->|"Workload-authenticated definition and instance registration"| I H <-->|"User report, tool requests, and results"| I G -->|"Chaining grant"| T I <-->|"Authorized MCP calls"| V @@ -70,7 +70,7 @@ flowchart LR The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. -Before the agent loop starts, the agent-definition loader supplies `mecak8s` with execution configuration and initializes the corresponding definition records and authority ceilings in the broker. The loader is outside the untrusted agent loop. Its process placement and how the broker authenticates its input remain to be specified. +Before the agent loop starts, mecak8s's trusted session lifecycle code [loads and persists the fixed definition selection](agent-definitions.md#loading-definitions-for-a-session), supplies execution configuration, and [registers definitions and the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication. This loader responsibility is outside the untrusted loop, not a separate registrar or an isolation boundary. The registration API, durable schema, execution-context binding, and protection from same-process substitution remain open. The broker contains the agent trust issuer, the authorization and audit logic, and a grant issuer (AS-1). The vMCP gateway contains its own authorization server (AS-2), the provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or be operated separately, such as an enterprise connector gateway. In both cases AS-1 vouches for the user and acting agent with a short-lived grant that AS-2 redeems; see [MCP gateway modes](mcp-gateway-modes.md). The tools themselves execute the operations. @@ -78,7 +78,7 @@ The remaining trust question is how the broker knows that the reported user and ## Following an action through the architecture -Alice asks her main agent to review PR 42. Once the session's definitions are loaded, the main agent delegates to `code-reviewer`. The reviewer requests an MCP tool call to read the pull request. +Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. The broker checks the registered reviewer's definition-derived server/tool ceiling with Cedar and retains existing user/connection checks. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index a1e82c6303..936d15e808 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -4,7 +4,17 @@ ## Definitions and executing instances -An agent definition describes how an agent should perform work. An executing instance uses that definition within a session and can perform multiple runs. Mecatl represents the definition with `AgentDef`, a value containing metadata and instructions, rather than an executing loop. +An agent definition describes how an agent should perform work. Mecatl represents it with `AgentDef`, a value containing metadata and instructions, rather than an executing loop. This chapter uses the following vocabulary: + +| Term | Meaning | +| --- | --- | +| Definition | A kind of agent, identified with its source and admitted revision, not just its name. | +| Session | The harness's persisted context for work, identified by a host session ID such as `S42`. | +| Agent instance | One use of a selected definition in a session, with its own identity and any creation-parent association. A delegated child has its own instance. | +| Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | +| Agent-instance incarnation | The durable identity allocated by mecak8s for that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | + +The existing implementation persists a [session incarnation](../../../engine/session/incarnation.go) on each session aggregate, including child sessions. The proposal uses that durable incarnation for agent-instance identity; it is not the host session ID or a run ID. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). ## What AgentDef contains @@ -38,7 +48,7 @@ In the proposed lifecycle, mecak8s's trusted session lifecycle code resolves the For Alice's session `S42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. -Before the main loop starts, mecak8s allocates and persists the main's durable incarnation, then authenticates to the broker with its workload ServiceAccount to register the selected definitions and main instance with no parent. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. +Before the main loop starts, mecak8s allocates and persists the main's durable agent-instance incarnation, then authenticates to the broker with its workload ServiceAccount to register the selected definitions and main instance with no parent. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. ## Registering instances @@ -64,7 +74,7 @@ An instance can perform several runs without changing identity. Its fixed defini Continuation preserves the unfinished run's original bounds. Current restrictions or withdrawal can constrain it; credential renewal or policy expansion must not automatically widen recovered authority. If the original identity or selection cannot be recovered, restoration must fail closed rather than silently substitute another instance or definition. -Identity continuity is separate from broker attachment recovery. A stable broker session reference can have a replaceable process-local attachment binding within the exact authenticated workload and deployment partition; another Pod with the same workload identity does not grant access to a different workload. See [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart) for credential recovery and unknown operation outcomes. +Identity continuity is separate from broker attachment recovery. See [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart) for attachment checks, credential and execution recovery, and unknown operation outcomes. ## Source records and policy representation diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 041b3fd6bc..8212f876a9 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -25,7 +25,7 @@ The following readable fields illustrate a proposed broker authorization event f | Field group | Illustrative values or required evidence | | --- | --- | | Event and call references | Event `authz-42`, operation `op-pr42-001`, attempt `attempt-1`, recording component, and observation time. Distinguish another attempt from another event for the same attempt. | -| Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; session `S42` and its incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; API-reported Alice with issuer/subject and request reference. Preserve the provenance above. | +| Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; host session `S42`; reviewer's agent-instance incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; API-reported Alice with issuer/subject and request reference. Preserve the provenance above. | | Definition and policy | Registered `project/code-reviewer`, source reference, admitted revision `review-def-r17`, resolved ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | | Target | Registered payments MCP server reference, exact `pull_request_read` tool, authorized connection `github-conn-42`. | | Evaluation | Cedar nominal decision `allow`, evaluation status `completed-without-errors`, relevant policy IDs, and generated policy revision. | diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index ef1c2d0891..97de0f4339 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -24,9 +24,7 @@ Run IDs, session attachments, and broker session references are separate from cr Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, owns harness session `S42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` first calls `pull_request_list`. After the list returns, it delegates review of PR 42 through `D42` to reviewer run `R42`, which calls `pull_request_read`. These tool names and arguments are illustrative, not a selected MCP server contract. The main instance has incarnation `inc_cejrv7re4uy3ubxyzj2gfxfp2u`; its reviewer child has incarnation `inc_gh56irfcpnupjqgfdw7dhzzw4m`. Runs identify execution, not credentials. In the diagram, `main` and `reviewer` abbreviate the exact SPIFFE subjects in [example instance identities](#example-instance-identities). The reviewer uses definition `project/code-reviewer`. Creation ancestry and delegation `D42` are separate facts, even though the main both creates this reviewer and delegates this task to it. -This proposed protocol uses mecak8s's trusted session lifecycle code and the existing broker/agent-instance issuer roles. Before execution starts, mecak8s resolves and persists the session's fixed definition selection, source, revision, and ceiling. It allocates and persists a durable session incarnation for the main instance. An incarnation is the durable identity mecak8s allocates for an instance. mecak8s then registers the main definition and instance with no parent while authenticated as its workload ServiceAccount. For a child, mecak8s supplies the persisted child incarnation, registered definition, and registered main-instance reference. The broker validates those records and constructs the canonical subject. It does not allocate incarnations. This protocol is not implemented behavior, and today's broker contract does not supply all of these registration inputs. - -This trusts mecak8s lifecycle assertions. A ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same mecak8s process, so authenticated same-process protection remains unresolved. Registration establishes identity records only. It does not authorize spawning, run execution, or a tool call. +The diagram starts from the [fixed definition selection and workload-authenticated registration](agent-definitions.md#loading-definitions-for-a-session) supplied by mecak8s's trusted lifecycle code. The main is registered before execution; the reviewer is registered when created, using the selected reviewer definition and its own persisted agent-instance incarnation. Section (3) identifies the inputs for each. Registration records identity, not permission to spawn, run, or call tools. It trusts mecak8s lifecycle assertions and does not establish same-process isolation; the [registration contract and open questions](agent-definitions.md#registering-instances) remain proposed, not implemented behavior. Operation `op-list-prs-001` identifies the main's list call. Operation `op-pr42-001` identifies the reviewer's PR read; the detailed credential and audit examples below follow this second call. Times are Unix seconds around `1800000000`, matching the delegated token. Examples are fictional decoded payloads or credential references, never usable bearer values. Audit examples contain only selected metadata; they are not copies of credentials or implemented cross-service events. The common operation ID denotes desired correlation, not an established authenticated propagation mechanism. Audit placement does not specify durable-write ordering. @@ -91,7 +89,7 @@ sequenceDiagram | [(3) Broker session and instance registration](#3-broker-session-and-instance-registration) | Before returning the registered reference and before (4), the issuer validates the workload and persisted registration records, then constructs the canonical subject. Registration remains identity recording only; it authorizes neither spawning, run execution, nor a tool call. | | [(4) Agent-instance JWT-SVID](#4-agent-instance-jwt-svid) | After the SVID is returned and before an invocation, mecak8s verifies its signature using the configured agent trust bundle and checks the registered subject, allowed algorithm, audience, and expiry. | | [(5) Enrollment](#5-enrollment-and-authorized-connection) | If an invocation reveals missing enrollment or a grant requiring user reauthorization, the broker returns an enrollment-required response without dispatch. mecak8s pauses, presents the broker link, and retries after completion through all checks. Enrollment is not permission to execute. Refreshable expiry is handled in custody where possible. | -| [(6) Per-operation authorization](#6-per-operation-authorization) | Before each invocation, mecak8s applies local permissions and hooks. The broker authenticates workload and actor credentials and verifies execution-context binding, then evaluates the exact registered server/tool pair against the actor's fixed definition-derived Cedar policy and checks connection access. `D42` is context, not a v1 dynamic delegation policy. Invalid credentials/context, denied policy, or any evaluation diagnostic error fail closed without dispatch. The broker records the effective decision and dispatches only when all required checks pass and credentials are ready. | +| [(6) Per-operation authorization](#6-per-operation-authorization) | Main's list and reviewer's read each pass local permissions and hooks, broker credential/context checks, the actor's own definition ceiling, and connection checks before dispatch. `D42` is context, not v1 dynamic delegation policy. The [ceiling rules](mcp-authorization.md#definition-derived-ceiling) fail closed, including on any Cedar diagnostic error. | | [(7) Token exchange and delegated MCP access token](#7-token-exchange-and-delegated-mcp-access-token) | Before requesting exchange, the broker obtains a current user access token from custody or refreshes it when needed. Before returning the delegated token, ToolHive AS validates the user token, actor credential, presenter, and bounded access. The broker restricts any delegated token to the applicable execution context; reuse never exceeds those bounds. | | [(8) Dispatch and observed outcome](#8-dispatch-and-observed-outcome) | Before resolving the upstream grant or calling the provider, vMCP validates the delegated token and operation policy and resolves the authorized connection. It performs each GitHub operation using the native provider credential held by ToolHive, never an SVID as the provider credential. After the provider result and before returning the sanitized result, the broker records the observed outcome and provider correlation when available. | @@ -146,9 +144,9 @@ The broker checks the signature using trusted cluster keys, the issuer, allowed The broker session reference identifies broker-owned state; knowing it grants no access. The broker checks the calling service's authenticated identity and deployment. This associates the session with mecak8s, not with Alice, and is separate from harness session `S42`. See [pauses and restart](#pauses-and-restart) for attachment and recovery details. -In the proposed bootstrap, mecak8s's trusted session lifecycle code persists the selected definition and durable main incarnation, then registers the main instance with no parent under workload ServiceAccount authentication. The broker validates the workload and records, constructs the canonical main subject, and issues its instance credential. For the reviewer child in this example, mecak8s supplies its persisted incarnation, registered definition, and registered main-instance reference, not a main token; the broker validates these records and constructs the child subject. The broker does not allocate either incarnation. The exact registration wire API and the durable record schema remain unspecified. +For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The broker constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). -Alice's identity is a separate API report, not established by the workload credential. A ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same mecak8s process, so protecting registration and user reports from same-process substitution remains unresolved. Registration does not authorize spawning, run execution, or tool calls. The issuance example in (4) distinguishes these sources. +Alice's identity remains a separate API report, not established by the workload credential. The issuance example in (4) preserves that distinction. See [source records and policy representation](agent-definitions.md#source-records-and-policy-representation) for the open API, schema, context-binding, and same-process protection questions. ### (4) Agent-instance JWT-SVID @@ -209,9 +207,9 @@ Local permissions and hooks run before each call. For the second call, trusted h } ``` -The broker validates both credentials and requires trusted attribution of the actor, initiating user, and run; the context-binding mechanism remains unresolved. It checks authorization to use the ToolHive subject and resolved provider connection, then evaluates the exact registered server/tool pair against the generated Cedar policy associated with the actor's fixed definition revision in trusted registration. Default deny applies, and any evaluation diagnostic error forces denial even with a nominal allow. A transformed request needs the applicable checks again. A denial dispatches nothing; an allow covers this MCP call, not a run or spawn. The ceiling result would be the same for PR 43 on this pair; v1 does not verify PR-specific bounds from `D42`. +The broker checks main's list against the main's registered ceiling and this read against the reviewer's, following the [definition-derived ceiling rules](mcp-authorization.md#definition-derived-ceiling). Both calls require valid workload and actor credentials, trusted execution-context binding, and authorization to use the ToolHive subject and provider connection; the context-binding mechanism remains unresolved. The reviewer ceiling gives the same result for PR 43 on this server/tool pair: v1 does not verify PR-specific bounds from `D42`. Existing connection and downstream checks still apply; see [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). -**Illustrative broker evidence:** `op-pr42-001` passed the definition-ceiling check under generated policy revision `review-ceiling-r17-g1` without evaluation errors, and the broker allowed the call after the required connection and context checks. Evidence links session `S42`, reviewer run `R42`, reported delegation `D42` from main run `R41`, verified workload and reviewer instance (the exact `sub` in (4)), API-reported Alice, definition `project/code-reviewer` at revision `review-def-r17`, the resolved server/tool pair, and authorized connection `github-conn-42`. Only allowlisted, redacted request metadata is retained. A denial records a safe reason instead; neither decision proves dispatch or success. See the [illustrative authorization evidence](audit.md#illustrative-authorization-evidence) for event, attempt, and policy references. +**Illustrative broker evidence:** `op-pr42-001` passed policy revision `review-ceiling-r17-g1` without evaluation errors and was allowed after the required connection and context checks. The workload and reviewer credential were verified; Alice remains API-reported and `D42` reported execution context. This decision proves neither dispatch nor success. The [audit evidence shape](audit.md#illustrative-authorization-evidence) owns the full attribution, request, attempt, policy, and redaction requirements. Recovered authority remains subject to the original bounds described in [pauses and restart](#pauses-and-restart). @@ -359,7 +357,7 @@ Ending a run in the harness does not automatically invalidate tokens already iss ## Pauses and restart -The broker's session reference and incarnation are separate from harness and agent incarnations. An attachment binding identifies the exact logical attachment incarnation; separate recovery references and continuity checks govern stored-credential recovery. An existing stable broker session can recover a new process-local attachment and binding through those checks. Expired custody withdraws connected authority without necessarily destroying the broker session. Those references do not combine user, agent, definition, and authority into one record. +The broker's session reference and broker session incarnation are separate from the host session ID and [agent-instance incarnation](agent-definitions.md#definitions-and-executing-instances). An attachment binding identifies the exact logical attachment incarnation; separate recovery references and continuity checks govern stored-credential recovery. An existing stable broker session can recover a new process-local attachment and binding through those checks. Expired custody withdraws connected authority without necessarily destroying the broker session. Those references do not combine user, agent, definition, and authority into one record. Workload-only session access checks the exact authenticated workload and configured deployment, rejecting caller-supplied owner assertions. A replacement Pod with the same workload identity can serve that partition; a different identity cannot assume access. @@ -367,7 +365,7 @@ An approval wait or provider-enrollment pause leaves the current run unfinished. After restart, recovering credentials is not enough to continue safely. The system must recover the unfinished execution context and establish the association between the user, acting agent, and provider connection. If that association cannot be established, the tool call must fail closed. The harness determines whether execution resumes; the broker authorizes subsequent MCP calls. -Continuation must preserve the unfinished run's original authorization bounds. In v1, the broker rechecks the fixed definition-derived ceiling and existing connection restrictions; the harness retains its run/delegation restrictions. Broker evaluation of dynamic delegated bounds is deferred. Restrictions or withdrawal can constrain continuation; credential renewal or policy expansion must not automatically widen the recovered authority. +Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. In v1, the broker rechecks the fixed definition-derived ceiling and existing connection restrictions; the harness retains its run/delegation restrictions. Broker evaluation of dynamic delegated bounds is deferred. Credential renewal does not widen recovered authority. An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an applicable idempotency mechanism. @@ -425,7 +423,7 @@ If the required identity, connection, or authorization evidence is missing, inva ## Instance identity and creation ancestry -The instance JWT-SVID identifies the acting agent instance through its SPIFFE ID. The proposed identity path records the instance's definition category and name, its session incarnation, and the ancestry of instances that created it. +The instance JWT-SVID identifies the acting agent instance through its SPIFFE ID. The proposed identity path records the instance's definition category and name, its agent-instance incarnation, and the ancestry of instances that created it. Creation ancestry is distinct from execution delegation. The same instance can participate in multiple runs, so its SPIFFE ID alone does not identify which parent run delegated the current task. Run and call records supply that context. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index a4f272cf50..118ffeb8d3 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -10,7 +10,7 @@ Verify that scheduled invocations preserve owner attribution without treating sa ## SPIFFE identifier construction -Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. +Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Agent-instance incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs; a Mecatl cap and compatible name/depth bounds remain undecided. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. @@ -18,7 +18,7 @@ Apply any issuance limit to the **whole URI**, including child hops, without tru Operator configuration fixes accepted workload issuers, audiences and identities, issuance limits, and trusted public signing keys. Tokens must not select verification endpoints, CA trust, or server names. -For each tool call, the broker authenticates the workload and validates the agent-instance JWT-SVID's signature against the configured agent trust-domain bundle, required `sub`/`aud`/`exp` claims, expected audience, expiry, and permitted presenter. JWT-SVID does not require `iss` or `jti`. The broker checks that the credential identifies the expected registered instance and session incarnation. Credential validation does not replace authorization of the requested operation. +For each tool call, the broker authenticates the workload and validates the agent-instance JWT-SVID's signature against the configured agent trust-domain bundle, required `sub`/`aud`/`exp` claims, expected audience, expiry, and permitted presenter. JWT-SVID does not require `iss` or `jti`. The broker checks that the credential identifies the expected registered instance and agent-instance incarnation. Credential validation does not replace authorization of the requested operation. At token exchange, ToolHive's authorization server validates the user token and the actor credential's trusted bundle association, signature, required claims, audience, expiry, and permitted presenter. The instance JWT-SVID needs an explicit ToolHive-AS audience; validity for presentation to the broker alone is insufficient. @@ -26,7 +26,11 @@ The protected MCP endpoint validates the delegated token's issuer, audience, exp Whether the two presentation points use one multi-audience instance credential or separately issued credentials remains to be specified. The [walkthrough](credentials-and-lifecycle.md#interactive-execution) illustrates the former without selecting it as the deployment contract. -Verify issuance rejects a known definition without a trusted particular-instance registration, an unauthorized workload, mismatched ancestry, or a stale session incarnation. These are proposed checks under the [workload-authenticated mecak8s lifecycle registration](agent-definitions.md#registering-instances); the exact wire API, durable schema, and protection from same-process substitution remain unspecified. Verify restoration preserves the instance identity, fixed definition selection, and original bounds. Separately, the existing broker recovery path permits a new process-local attachment binding for a stable broker session within the exact authenticated workload and deployment partition; a different workload cannot assume access. Knowing a noncredential broker session reference must not suffice. Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. +Verify issuance rejects a known definition without a trusted particular-instance registration, an unauthorized workload, mismatched ancestry, or a stale agent-instance incarnation. These are proposed checks under the [workload-authenticated mecak8s lifecycle registration](agent-definitions.md#registering-instances); the exact wire API, durable schema, and protection from same-process substitution remain unspecified. + +Verify [identity continuity](agent-definitions.md#identity-continuity-across-runs-and-restarts) preserves the instance identity, fixed definition selection, and original bounds. Test [attachment recovery](credentials-and-lifecycle.md#pauses-and-restart) with a replacement process in the same authenticated workload and deployment partition; reject a different workload or possession of a noncredential broker session reference alone. + +Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. ## Workload-specific verification diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 8184eb7a07..fddf6e08e6 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -73,4 +73,4 @@ When needed, the broker obtains a current ToolHive user access token and exchang Cached credentials are not cached authorization. Every subsequent call repeats the definition-ceiling evaluation and existing connection and downstream checks; any delegated-token reuse must stay within the execution context for which it is permitted. -The authorization decision, dispatch, and observed outcome are separate audit facts. An allow does not prove dispatch, and dispatch does not prove provider success. Each requires durable evidence without recording credential values; persistence ordering and authenticated cross-service correlation remain unspecified. An unknown outcome must not be automatically replayed merely because credentials were recovered. See [dispatch and observed outcome](credentials-and-lifecycle.md#8-dispatch-and-observed-outcome) and [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart). +An allow does not prove dispatch or provider success. The [audit contract](audit.md#decision-dispatch-and-observed-outcome) owns durable evidence and correlation requirements. An unknown outcome must not be automatically replayed merely because credentials were recovered. From fbf9e8d08d8e465e62f549835b01820324c6e0be Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 13 Oct 2026 13:56:40 +0200 Subject: [PATCH 12/50] docs: address agent identity review feedback Narrow each child at the broker: registration stores an effective server/tool ceiling, the definition ceiling intersected with the parent's, and Cedar evaluates it. A deployer spawned under a read-only reviewer can no longer merge, and renewal and restoration reload the stored set instead of recomputing it. Define execution context: workload, instance, and session are verified; user, run, and delegation are mecak8s-reported and not v1 Cedar inputs. This resolves the walkthrough contradicting the binding requirement. Add definition provenance (versioned profiles, source and accepted digests, verified versus asserted facts) with Mecatl and broker work items. Document the built-in system/main and system/explorer definitions for sessions without an AgentDef, state that mecak8s is a trusted rather than independently verified attestor, add Workload and Execution context to the glossary, qualify bare "agent identity", and fix the root README link to the retired identity model draft. Co-Authored-By: mecatl --- README.md | 2 +- docs/drafts/agent-identity/README.md | 6 ++-- .../agent-identity/agent-definitions.md | 31 +++++++++++++++---- docs/drafts/agent-identity/audit.md | 12 +++---- .../credentials-and-lifecycle.md | 20 ++++++------ docs/drafts/agent-identity/implementation.md | 8 ++++- .../agent-identity/mcp-authorization.md | 28 +++++++++-------- 7 files changed, 67 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 9ed2a8e8cd..4118438fee 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Caller identity and audit records are useful building blocks, not a complete tenant-isolation boundary. Cross-process cryptographic proof and authority attenuation remain active design work. See the [agent identity tracker](https://github.com/stacklok/mecatl/issues/377) and the -[working identity model](./docs/drafts/agent-identity-model.md). +[agent identity and authority design](./docs/drafts/agent-identity/README.md). ## Try it locally diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 71331bbe4a..90cd0f9276 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -2,7 +2,7 @@ This document proposes how Mecatl identifies **who is acting** in a multi-user, multi-session deployment: a user starts an agent, the agent delegates to subagents, each delegation stays within its parent's authority, and the resulting actions leave a durable audit trail. The goal is to let an operator trace an action back to the agent and user behind it, with authorization enforced outside the agent loop. -**Workload identity alone cannot answer these questions.** It identifies the Mecatl service, but one service can host many users, agent definitions, and running instances. This architecture gives those agents identities of their own and connects them to the users they act for. It follows an agent from the trusted definition loaded at session creation, through its execution and delegation, to the tool calls made on its behalf, which may use a human user's credentials to access an external service. +**Workload identity alone cannot answer these questions.** It identifies the Mecatl service, but one service can host many users, agent definitions, and running instances. This architecture gives those agents identities of their own and connects them to the users they act for. It follows an agent from the trusted definition loaded at session creation, through its runs and delegated child instances, to the tool calls made on its behalf, which may use a human user's credentials to access an external service. *This document describes the intended architecture, not current implementation status.* @@ -70,7 +70,7 @@ flowchart LR The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. -Before the agent loop starts, mecak8s's trusted session lifecycle code [loads and persists the fixed definition selection](agent-definitions.md#loading-definitions-for-a-session), supplies execution configuration, and [registers definitions and the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication. This loader responsibility is outside the untrusted loop, not a separate registrar or an isolation boundary. The registration API, durable schema, execution-context binding, and protection from same-process substitution remain open. +Before the agent loop starts, mecak8s's trusted session lifecycle code [loads and persists the fixed definition selection](agent-definitions.md#loading-definitions-for-a-session), supplies execution configuration, and [registers definitions and the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication. This loader responsibility is outside the untrusted loop, not a separate registrar or an isolation boundary. The registration API, durable schema, verified per-operation binding for reported context, and protection from same-process substitution remain open. The broker contains the agent trust issuer, the authorization and audit logic, and a grant issuer (AS-1). The vMCP gateway contains its own authorization server (AS-2), the provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or be operated separately, such as an enterprise connector gateway. In both cases AS-1 vouches for the user and acting agent with a short-lived grant that AS-2 redeems; see [MCP gateway modes](mcp-gateway-modes.md). The tools themselves execute the operations. @@ -80,7 +80,7 @@ The remaining trust question is how the broker knows that the reported user and Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. -The broker checks the registered reviewer's definition-derived server/tool ceiling with Cedar and retains existing user/connection checks. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. +The broker checks the reviewer's effective server/tool ceiling, its definition ceiling narrowed by the main's, with Cedar and retains existing user/connection checks. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 936d15e808..e3da040884 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -12,7 +12,9 @@ An agent definition describes how an agent should perform work. Mecatl represent | Session | The harness's persisted context for work, identified by a host session ID such as `S42`. | | Agent instance | One use of a selected definition in a session, with its own identity and any creation-parent association. A delegated child has its own instance. | | Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | +| Execution context | The workload, acting instance, session, initiating user, run, and any delegation a protected call is made under. In v1, the user, run, and delegation are mecak8s-reported; see [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | | Agent-instance incarnation | The durable identity allocated by mecak8s for that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | +| Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | The existing implementation persists a [session incarnation](../../../engine/session/incarnation.go) on each session aggregate, including child sessions. The proposal uses that durable incarnation for agent-instance identity; it is not the host session ID or a run ID. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). @@ -40,7 +42,7 @@ The proposed split keeps execution configuration in the harness and supplies the The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the agent's available tools. The broker needs an independently established ceiling on permitted MCP operations. A tool appearing in the harness catalog is not sufficient authorization to call it, and a model-supplied tool list cannot establish the broker's ceiling. -The broker ceiling is the set of permitted registered MCP server/tool pairs resolved under the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. The existing definition-source snapshot contract is not, by itself, broker initialization. +The broker ceiling is the set of permitted registered MCP server/tool pairs resolved under the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. At registration, the broker narrows it to each instance's [effective ceiling](mcp-authorization.md#delegated-authority). The existing definition-source snapshot contract is not, by itself, broker initialization. ## Loading definitions for a session @@ -48,17 +50,19 @@ In the proposed lifecycle, mecak8s's trusted session lifecycle code resolves the For Alice's session `S42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. +Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition, and the default anonymous child uses `system/explorer`; neither requires a definition file. Built-in definitions are registered and ceiling-resolved like any other, with the Mecatl release that ships them as their source and revision. `system/main` grants no name-based privilege: Alice's and Bob's default mains share the definition but not instances, users, or authority. A session started with a named definition, such as `project/code-reviewer`, registers that definition as its main instead. Whether the harness constructs the main loop from a real `AgentDef` or a built-in equivalent is an implementation choice. + Before the main loop starts, mecak8s allocates and persists the main's durable agent-instance incarnation, then authenticates to the broker with its workload ServiceAccount to register the selected definitions and main instance with no parent. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. ## Registering instances -The broker validates the workload-authenticated registration records and constructs the canonical instance subject. It checks that the workload may act for the session and instance and associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any. mecak8s allocates incarnations; the broker does not. A known definition or loop-supplied name alone is insufficient for issuance. +The broker validates the workload-authenticated registration records and constructs the canonical instance subject. It checks that the workload may act for the session and instance and associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any, and stores the effective ceiling computed from them. mecak8s allocates incarnations; the broker does not. A known definition or loop-supplied name alone is insufficient for issuance. When the main creates the reviewer, mecak8s allocates and persists the child's incarnation. It registers that particular child using the persisted incarnation, the reviewer definition already registered from the session selection, and the registered main-instance reference. The broker validates these records and constructs the child subject. This is a lifecycle assertion authenticated by mecak8s's workload credential, not proof derived from possession of the parent's SVID. Creation ancestry records who created an instance. Execution delegation separately links parent run `R41`, reviewer run `R42`, and the bounds of delegation `D42`. Registration establishes identity records; it does not authorize spawning, run execution, or a tool call. The harness manages execution, and the broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). -The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The API's initiating-user report is also a trusted assertion, separate from workload verification. +The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The API's initiating-user report is also a trusted assertion, separate from workload verification. Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances; mecak8s is a trusted attestor, not an independently verified one. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. ## Identity continuity across runs and restarts @@ -68,7 +72,7 @@ An instance can perform several runs without changing identity. Its fixed defini | --- | --- | | New run on the same instance | The incarnation and selected definition stay fixed. The new run has its own execution context and applicable bounds; the instance credential alone does not authorize it. | | Definition source edited | The session's pinned source and revision selection does not change. Restoration must not substitute whatever is current at the source. | -| Process restart or session restoration | Restore the original incarnation, definition selection, parent association, and authority bounds. Recover the unfinished execution context and its user/actor/connection association or fail closed. | +| Process restart or session restoration | Restore the original incarnation, definition selection, parent association, stored effective ceiling, and harness delegation bounds. Recover the unfinished execution context and its user/actor/connection association or fail closed. | | Instance credential expires | Renew for the registered identity after the required checks. Renewal grants no new authority and does not widen recovered bounds. | | Fresh incarnation | Register a distinct identity. Reusing a session or definition name does not inherit the old instance's authority. | @@ -76,8 +80,23 @@ Continuation preserves the unfinished run's original bounds. Current restriction Identity continuity is separate from broker attachment recovery. See [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart) for attachment checks, credential and execution recovery, and unknown operation outcomes. +## Definition provenance + +Each instance's registration carries a provenance record for the exact definition it uses. mecak8s's trusted resolver produces it from source resolution; the model never supplies it, and it is never reconstructed later from whatever is current at the source. + +The record has a versioned **profile** and profile-specific metadata. A Git profile might carry repository, path, and full commit; a Kubernetes profile the cluster, object UID, and observed revision; a service-backed profile a tenant namespace, object ID, and revision. `AgentDefSource` stays storage-independent: profiles are carried as metadata, not added to the core interface. Each profile defines required fields, schema, hashing rules, and revision semantics. Operator configuration names the profiles and evidence it accepts; a source cannot weaken that by selecting a more permissive profile. + +The record carries two digests: + +- **Source content digest:** the bytes read from the source. +- **Accepted definition digest:** the normalized definition mecak8s admitted and constructs the agent from. + +Parsing and normalization mean these differ. A commit plus a digest does not prove the bytes came from that commit; a locally modified file is the obvious counterexample. + +The broker records provenance as a mecak8s assertion unless it independently verified a fact, and audit marks which. "mecak8s reports loading these bytes from commit X" is distinct from "the broker verified these bytes against commit X"; v1 requires only the former. Provenance lives in the broker's per-instance registration record, not in instance credentials. It grants no permission by itself; later policy may require approved revisions or particular source authorities. + ## Source records and policy representation -The proposal adopts workload-authenticated mecak8s lifecycle assertions, host-allocated durable incarnations, and broker validation and subject construction as its semantic mechanism. The exact registration wire API, durable record schema, and execution-context binding remain unresolved, as does protection from same-process substitution. These are proposed semantics, not implemented contracts. +The proposal adopts workload-authenticated mecak8s lifecycle assertions, host-allocated durable incarnations, and broker validation and subject construction as its semantic mechanism. The exact registration wire API, durable record schema, and verified per-operation binding for reported context remain unresolved, as does protection from same-process substitution. These are proposed semantics, not implemented contracts. -Cedar is selected for the definition-derived ceiling. The representation of definition-source and revision records, registration API, and Cedar policies remains an implementation choice. V1 generates policy only from resolved definition facts; separately managed user, resource, argument, and dynamic delegation policies are deferred. Existing user/connection authorization checks remain required. +Cedar is selected for the effective server/tool ceiling. The representation of definition-source and revision records, registration API, and Cedar policies remains an implementation choice. V1 generates policy only from resolved definition facts; separately managed user, resource, argument, and dynamic delegation policies are deferred. Existing user/connection authorization checks remain required. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 8212f876a9..0c00167170 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status. The audit requirement applies to tool calls generally; the current design scope is broker-mediated MCP calls.* -The audit trail must explain which registered instance requested a call, which definition-derived ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. A v1 Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. +The audit trail must explain which registered instance requested a call, which effective ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. A v1 Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. ## Evidence and provenance @@ -11,10 +11,10 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | Evidence | Source and what it establishes | Limit | | --- | --- | --- | | Authenticated workload and instance | Broker validation of workload credentials and the instance JWT-SVID, with the registered session association | Does not authenticate the human or prove a task-specific delegation. | -| Definition source, admitted revision, ceiling, and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | +| Definition source, admitted revision, ceilings, and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | | Initiating user and session ownership | API authentication and server-side ownership checks, reported to the broker | The broker trusts the API report; it does not independently authenticate the human from it. | -| Run and execution-delegation links | Attributed lifecycle/run reports under the execution-context binding | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. | -| Ceiling evaluation and effective broker decision | Broker evaluation of the registered actor's generated policy and the exact server/tool pair, plus applicable existing checks | An allow proves neither dispatch nor downstream success. | +| Run and execution-delegation links | mecak8s reports in the workload-authenticated call request, part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. | +| Ceiling evaluation and effective broker decision | Broker evaluation of the registered actor's stored effective ceiling and the exact server/tool pair, plus applicable existing checks | An allow proves neither dispatch nor downstream success. | | Downstream identity and authorization | ToolHive AS and vMCP records of their own validations and decisions | Forwarded context remains reported unless independently verified there. | | Observed result and provider correlation | The response received by the recording component and safe provider references, when available | A broker-observed MCP response is not independent proof of provider-side effects. | @@ -26,10 +26,10 @@ The following readable fields illustrate a proposed broker authorization event f | --- | --- | | Event and call references | Event `authz-42`, operation `op-pr42-001`, attempt `attempt-1`, recording component, and observation time. Distinguish another attempt from another event for the same attempt. | | Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; host session `S42`; reviewer's agent-instance incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; API-reported Alice with issuer/subject and request reference. Preserve the provenance above. | -| Definition and policy | Registered `project/code-reviewer`, source reference, admitted revision `review-def-r17`, resolved ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | +| Definition and policy | Registered `project/code-reviewer`, source reference, provenance profile with source content and accepted definition digests, admitted revision `review-def-r17`, resolved definition ceiling reference, stored effective ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | | Target | Registered payments MCP server reference, exact `pull_request_read` tool, authorized connection `github-conn-42`. | | Evaluation | Cedar nominal decision `allow`, evaluation status `completed-without-errors`, relevant policy IDs, and generated policy revision. | -| Effective decision | `allow` only after all required checks pass; safe reason `definition-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. | +| Effective decision | `allow` only after all required checks pass; safe reason `effective-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. | | Safe request metadata | Repository `acme/payments` and PR number `42` only if explicitly allowlisted for audit. Their presence does not mean Cedar evaluated a PR-specific policy. | The policy revision and policy IDs must lead to retained generation inputs and policy evidence sufficient to explain the ceiling result. A definition name alone cannot explain which policy was used. Error and denial records need the same correlation and attribution where established; unavailable or unverified facts must remain marked as such. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 97de0f4339..28744905c7 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status.* -An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. In v1, the broker checks the acting instance's definition-derived server/tool ceiling with Cedar, alongside existing user/connection checks and trusted execution-context binding. User, resource, argument, and dynamic delegation policies are deferred; carrying run or delegation context does not mean Cedar evaluates those bounds. Renewing the instance credential does not itself extend or widen permission to call tools. +An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. In v1, the broker checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar, alongside existing user/connection checks and execution-context checks. User, resource, argument, and dynamic delegation policies are deferred; carrying run or delegation context does not mean Cedar evaluates those bounds. Renewing the instance credential does not itself extend or widen permission to call tools. ## Credential roles @@ -54,7 +54,7 @@ sequenceDiagram U->>H: List pull requests and review PR 42 in acme/payments
API access token Note over H,V: First call: main run R41 lists pull requests H->>B: pull_request_list(repository=acme/payments)
Workload + main SVID + R41 + op-list-prs-001 - B->>B: Authenticate and verify context
Check main server/tool ceiling and connection access + B->>B: Authenticate and check context
Check main server/tool ceiling and connection access B->>A: Exchange user subject_token + main actor_token
Broker client authentication A->>A: Validate user and main actor tokens and broker client
Authorize requested MCP target and access bounds A-->>B: Delegated token with sub=ToolHive user and act.sub=main @@ -71,7 +71,7 @@ sequenceDiagram Note over H,V: Second call: reviewer run R42 reads PR 42 H->>B: pull_request_read(repository=acme/payments, number=42)
Workload + reviewer SVID + R42 + D42 + op-pr42-001 - B->>B: Authenticate and verify context
Check reviewer server/tool ceiling and connection access + B->>B: Authenticate and check context
Check reviewer server/tool ceiling and connection access B->>A: Exchange user subject_token + reviewer actor_token
Broker client authentication A->>A: Validate user and reviewer actor tokens and broker client
Authorize requested MCP target and delegated access bounds A-->>B: Delegated token with sub=ToolHive user and act.sub=reviewer @@ -89,13 +89,13 @@ sequenceDiagram | [(3) Broker session and instance registration](#3-broker-session-and-instance-registration) | Before returning the registered reference and before (4), the issuer validates the workload and persisted registration records, then constructs the canonical subject. Registration remains identity recording only; it authorizes neither spawning, run execution, nor a tool call. | | [(4) Agent-instance JWT-SVID](#4-agent-instance-jwt-svid) | After the SVID is returned and before an invocation, mecak8s verifies its signature using the configured agent trust bundle and checks the registered subject, allowed algorithm, audience, and expiry. | | [(5) Enrollment](#5-enrollment-and-authorized-connection) | If an invocation reveals missing enrollment or a grant requiring user reauthorization, the broker returns an enrollment-required response without dispatch. mecak8s pauses, presents the broker link, and retries after completion through all checks. Enrollment is not permission to execute. Refreshable expiry is handled in custody where possible. | -| [(6) Per-operation authorization](#6-per-operation-authorization) | Main's list and reviewer's read each pass local permissions and hooks, broker credential/context checks, the actor's own definition ceiling, and connection checks before dispatch. `D42` is context, not v1 dynamic delegation policy. The [ceiling rules](mcp-authorization.md#definition-derived-ceiling) fail closed, including on any Cedar diagnostic error. | +| [(6) Per-operation authorization](#6-per-operation-authorization) | Main's list and reviewer's read each pass local permissions and hooks, broker credential/context checks, the actor's effective ceiling, and connection checks before dispatch. `D42` is context, not v1 dynamic delegation policy. The [ceiling rules](mcp-authorization.md#definition-derived-ceiling) fail closed, including on any Cedar diagnostic error. | | [(7) Token exchange and delegated MCP access token](#7-token-exchange-and-delegated-mcp-access-token) | Before requesting exchange, the broker obtains a current user access token from custody or refreshes it when needed. Before returning the delegated token, ToolHive AS validates the user token, actor credential, presenter, and bounded access. The broker restricts any delegated token to the applicable execution context; reuse never exceeds those bounds. | | [(8) Dispatch and observed outcome](#8-dispatch-and-observed-outcome) | Before resolving the upstream grant or calling the provider, vMCP validates the delegated token and operation policy and resolves the authorized connection. It performs each GitHub operation using the native provider credential held by ToolHive, never an SVID as the provider credential. After the provider result and before returning the sanitized result, the broker records the observed outcome and provider correlation when available. | Decision, dispatch, and outcome require durable evidence; persistence ordering and cross-service propagation are unspecified. Every subsequent tool call repeats authorization. -A child call uses the same invocation flow with the child's own JWT-SVID and attributed run and delegation context. It does not require or present the parent's SVID. The broker checks the child's registered parent-instance reference, not a parent token, when it constructs the child subject, then evaluates the child's own definition ceiling for the call. Broker enforcement of dynamic delegated bounds remains future work. +A child call uses the same invocation flow with the child's own JWT-SVID and attributed run and delegation context. It does not require or present the parent's SVID. The broker checks the child's registered parent-instance reference, not a parent token, when it constructs the child subject and computes the child's [effective ceiling](mcp-authorization.md#delegated-authority) from its definition ceiling and the main's. It evaluates that stored effective ceiling for the call. Broker enforcement of task-specific delegated bounds remains future work. ### (1) Company API access token @@ -146,7 +146,7 @@ The broker session reference identifies broker-owned state; knowing it grants no For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The broker constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). -Alice's identity remains a separate API report, not established by the workload credential. The issuance example in (4) preserves that distinction. See [source records and policy representation](agent-definitions.md#source-records-and-policy-representation) for the open API, schema, context-binding, and same-process protection questions. +Alice's identity remains a separate API report, not established by the workload credential. The issuance example in (4) preserves that distinction. See [source records and policy representation](agent-definitions.md#source-records-and-policy-representation) for the open API, schema, per-operation context-binding, and same-process protection questions. ### (4) Agent-instance JWT-SVID @@ -207,7 +207,7 @@ Local permissions and hooks run before each call. For the second call, trusted h } ``` -The broker checks main's list against the main's registered ceiling and this read against the reviewer's, following the [definition-derived ceiling rules](mcp-authorization.md#definition-derived-ceiling). Both calls require valid workload and actor credentials, trusted execution-context binding, and authorization to use the ToolHive subject and provider connection; the context-binding mechanism remains unresolved. The reviewer ceiling gives the same result for PR 43 on this server/tool pair: v1 does not verify PR-specific bounds from `D42`. Existing connection and downstream checks still apply; see [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). +The broker checks main's list against the main's effective ceiling and this read against the reviewer's, following the [definition-derived ceiling rules](mcp-authorization.md#definition-derived-ceiling). Both calls require valid workload and actor credentials, execution-context checks, and authorization to use the ToolHive subject and provider connection; in v1, user, run, and delegation remain mecak8s-reported. The reviewer ceiling gives the same result for PR 43 on this server/tool pair: v1 does not verify PR-specific bounds from `D42`. Existing connection and downstream checks still apply; see [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). **Illustrative broker evidence:** `op-pr42-001` passed policy revision `review-ceiling-r17-g1` without evaluation errors and was allowed after the required connection and context checks. The workload and reviewer credential were verified; Alice remains API-reported and `D42` reported execution context. This decision proves neither dispatch nor success. The [audit evidence shape](audit.md#illustrative-authorization-evidence) owns the full attribution, request, attempt, policy, and redaction requirements. @@ -341,7 +341,7 @@ This groups examples for reading, not storage or transport. Each AS validates it The broker holds its ToolHive user access and refresh tokens. ToolHive holds, refreshes, and injects native provider credentials for the authorized connection. Neither Mecatl nor the model receives those native provider credentials. This boundary concerns protected tool access; it does not relocate other harness credentials, such as those used to call an LLM provider. -Agent identity does not protect against compromise of the broker's signing authority or provider-credential storage. Separating issuance, authorization, and custody into modules does not create security boundaries against compromise of their shared process or host. +Agent-instance identity does not protect against compromise of the broker's signing authority or provider-credential storage. Separating issuance, authorization, and custody into modules does not create security boundaries against compromise of their shared process or host. ## Credential lifetimes and renewal @@ -365,7 +365,7 @@ An approval wait or provider-enrollment pause leaves the current run unfinished. After restart, recovering credentials is not enough to continue safely. The system must recover the unfinished execution context and establish the association between the user, acting agent, and provider connection. If that association cannot be established, the tool call must fail closed. The harness determines whether execution resumes; the broker authorizes subsequent MCP calls. -Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. In v1, the broker rechecks the fixed definition-derived ceiling and existing connection restrictions; the harness retains its run/delegation restrictions. Broker evaluation of dynamic delegated bounds is deferred. Credential renewal does not widen recovered authority. +Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. In v1, the broker rechecks the stored effective ceiling and existing connection restrictions; the harness retains its run/delegation restrictions. Broker evaluation of task-specific delegated bounds is deferred. Credential renewal does not widen recovered authority. An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an applicable idempotency mechanism. @@ -457,6 +457,6 @@ These IDs describe instance identity and creation ancestry. The initiating user The walkthrough's delegated token is a bearer without sender binding. Its signature protects the claims from alteration but does not prove which holder presented it. -Workload-level DPoP is follow-on work to bind the broker-facing OAuth credential to a presenting worker's key and reduce replay of stolen access tokens. It does not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent identity and tool-call authorization remain separate checks. +Workload-level DPoP is follow-on work to bind the broker-facing OAuth credential to a presenting worker's key and reduce replay of stolen access tokens. It does not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent-instance identity and tool-call authorization remain separate checks. Key custody, renewal, restart behavior, and replay handling belong in the implementation design. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 118ffeb8d3..c7636361a5 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -32,6 +32,11 @@ Verify [identity continuity](agent-definitions.md#identity-continuity-across-run Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. +## Definition provenance + +- Mecatl: `AgentDefSource` resolution returns source evidence; the trusted resolver produces the [provenance record](agent-definitions.md#definition-provenance) with both digests, rejects profiles the operator has not allowed, and registration carries the record. Prove a locally modified file yields a source digest that does not match its claimed commit's content. +- Broker: validate the record against its profile contract and the operator's evidence requirements, retain it with the instance record, and mark verified versus asserted facts in audit. + ## Workload-specific verification ### Kubernetes ServiceAccount bearer @@ -50,11 +55,12 @@ Workload mTLS does not bind an OAuth access token to its certificate, prove user ## Cedar authorization and audit verification -Cedar is the selected engine for the v1 definition-derived MCP server/tool ceiling. Policy representation is an implementation choice; user, resource, argument, and dynamic delegation policies are deferred. Required focused checks are: +Cedar is the selected engine for the v1 effective MCP server/tool ceiling. Policy representation is an implementation choice; user, resource, argument, and dynamic delegation policies are deferred. Required focused checks are: - Resolve `Tools`, `DisallowedTools`, and `MCPServers` with existing defaults, selection, and exclusion semantics into registered server/tool pairs. Reject ambiguous or unresolved grants; the same tool name on another server must not inherit access. - Prove deterministic generation from fixed admitted definition inputs, with retained source, definition revision, and generated policy revision. Prose and model output must not affect grants; source edits and restoration must not silently replace the admitted ceiling. - Select the actor's policy through trusted registration. Reject caller substitution of actor, definition, revision, or policy. Require an explicit resolved main ceiling, without name-based privilege. +- Compute each instance's effective ceiling at registration as its definition ceiling intersected with its parent's effective ceiling, and store it with the incarnation. Property-test `effective(child) ⊆ definition(child)` and `effective(child) ⊆ effective(parent)`, and cross-check against the harness's delegated-authority intersection. Prove a `deployer` child of a read-only reviewer is denied merge. Reject registration under an unregistered, revoked, or other-session parent; an empty intersection registers an instance whose MCP calls all deny. Revoking an instance revokes its descendants. Renewal and restoration reload the stored set and never recompute or widen it. - Evaluate each call, including calls using cached credentials and retries after enrollment. Default deny; generation/loading failures, evaluation failures, and any evaluation diagnostic errors deny, even if the nominal Cedar result is allow. - Check the exact dispatched server/tool pair and recheck changed requests. Prove the reviewer read passes its ceiling while excluded merge fails; PR 42 and PR 43 reads on the same pair have the same ceiling result. Do not claim this proves PR-specific delegation. - Preserve existing local, user/connection, ToolHive AS, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index fddf6e08e6..7c1b532b7f 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -2,15 +2,15 @@ *This chapter describes the intended architecture, not current implementation status.* -The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. V1 evaluates only a definition-derived MCP server/tool ceiling.** It supplements existing local permissions, user/connection checks, and ToolHive authorization; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. +The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. V1 evaluates only the acting instance's effective MCP server/tool ceiling: its definition ceiling narrowed by its parent's.** It supplements existing local permissions, user/connection checks, and ToolHive authorization; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. ## Definition-derived ceiling Trusted definition admission resolves the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics against registered MCP servers and their tools into permitted server/tool pairs. Resolution must preserve existing selection and exclusion semantics, including defaults, rather than interpreting a raw tool list as grants. A tool name alone cannot identify an authorized pair across servers. Unresolved or ambiguous references cannot grant access. -Cedar policies are generated deterministically from that resolved ceiling and tied to the fixed admitted definition source and revision. The same admitted inputs produce the same ceiling and generated policy revision. Generation uses structured definition facts, never an LLM or inferred intent from `Body`, descriptions, skills, or other prose. Retain the resolution inputs and generated policy revision so that the decision can be explained after the source changes. Representation, including the Cedar schema and entity/action/resource layout, is left to the implementer. +Cedar policies are generated deterministically from that resolved ceiling and tied to the fixed admitted definition source and revision. The same admitted inputs produce the same ceiling and generated policy revision. Generation uses structured definition facts, never an LLM or inferred intent from `Body`, descriptions, skills, or other prose. Retain the resolution inputs and generated policy revision so that the decision can be explained after the source changes. Each call is evaluated against the acting instance's effective ceiling, defined under [delegated authority](#delegated-authority). Representation, including the Cedar schema and entity/action/resource layout, is left to the implementer. -After authenticating the instance, the broker obtains its definition and generated policy association from trusted registration records. Caller-supplied definition names, revisions, policy identifiers, or tool lists cannot select a more privileged ceiling. The main instance also needs an explicit resolved ceiling; `main`, `system`, or a root identity grants no name-based privilege. +After authenticating the instance, the broker obtains its definition, stored effective ceiling, and policy association from trusted registration records. Caller-supplied definition names, revisions, policy identifiers, or tool lists cannot select a more privileged ceiling. The main instance also needs an explicit resolved ceiling; `main`, `system`, or a root identity grants no name-based privilege. For every call, the broker evaluates the exact registered server/tool pair that will be dispatched. Default deny applies: no matching permit means denial. Policy generation or loading failures and evaluation failures deny; **any Cedar evaluation diagnostic error makes the effective decision deny, even if Cedar also returns a nominal allow.** An error-free permit satisfies only this ceiling check. A changed target or tool requires another evaluation before dispatch. @@ -18,15 +18,17 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker also requires a trustworthy binding between the workload, acting instance, initiating user, session, run, and any delegation. Merely supplying run `R42` or delegation `D42` as request fields is insufficient. The context-binding mechanism remains unselected; missing or invalid binding must fail closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. In v1, mecak8s reports the user, run, and delegation in the workload-authenticated call request; the broker records them as reported, and none of them is a v1 Cedar input. Enforcing task-specific delegated bounds will require a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority A child's permitted MCP operations must stay within the authority delegated by its parent and the child's definition ceiling. Delegation can preserve or narrow that authority, not widen it. -Delegation history records how the acting instance came to act. An ancestor's permissions do not give the current actor additional rights. In v1, the broker evaluates the acting child's own definition ceiling; dynamic delegation-policy evaluation and proof that a child's policy is a subset are deferred. Existing harness delegation restrictions remain in force. +Delegation history records how the acting instance came to act. An ancestor's permissions do not give the current actor additional rights. At registration, the broker computes the instance's **effective ceiling**: its definition ceiling intersected with its parent's effective ceiling. A main has no parent, so its effective ceiling is its definition ceiling. The broker stores the result with the instance's incarnation and never recomputes or widens it: renewal and restoration reload the stored set, and a fresh incarnation gets a fresh computation. Registration fails if the parent is not a registered, unrevoked instance of the same session. An empty intersection registers an instance whose MCP calls all deny. Revoking an instance revokes its descendants. -An instance JWT-SVID identifies the agent; it does not prove delegated authority. Enforcing task-specific delegated limits at the broker is a future requirement, not a property of the v1 Cedar check. +For example, a read-only reviewer spawns a child from a `deployer` definition whose ceiling includes `pull_request_merge`. The child's effective ceiling is the deployer ceiling intersected with the reviewer's, so it excludes merge. The broker denies the child's merge call whatever the deployer definition allows and whatever another `deployer` instance may do. The harness performs the same intersection today; the broker computes it independently. Existing harness delegation restrictions remain in force. + +The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. ## Operations and resources @@ -34,7 +36,7 @@ V1 distinguishes registered MCP servers and tools, but does not evaluate resourc For example, a reviewer whose resolved ceiling includes `pull_request_read` on the registered payments server can pass the ceiling check for a read, but cannot pass it for `pull_request_merge` if merge is excluded. Reading PR 42 and reading PR 43 with that same server/tool pair produce the same ceiling result. The broker's v1 Cedar check cannot establish that the reviewer stayed within Alice's PR-42-only request. Existing connection and downstream checks still apply to either call. -Agent identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. +Agent-instance identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. ## User reports and the authorized request @@ -48,18 +50,18 @@ Request transformations must occur before the applicable checks, or the changed In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), Alice owns session `S42`. The main instance lists pull requests, then delegates review of PR 42 to a reviewer. The tool names and arguments are illustrative, not a selected MCP server contract. -| Request | V1 definition-ceiling check | +| Request | V1 effective-ceiling check | | --- | --- | -| Main run `R41`: `pull_request_list(repository=acme/payments)` | The registered main's generated policy must permit the resolved server and list tool. Main has no implicit exemption. | -| Reviewer run `R42` via `D42`: `pull_request_read(repository=acme/payments, number=42)` | The registered reviewer's generated policy must permit the resolved server and read tool. `D42` records the delegated task but is not a v1 Cedar policy input. | +| Main run `R41`: `pull_request_list(repository=acme/payments)` | The main's effective ceiling, which is its definition ceiling, must permit the resolved server and list tool. Main has no implicit exemption. | +| Reviewer run `R42` via `D42`: `pull_request_read(repository=acme/payments, number=42)` | The reviewer's effective ceiling, its definition ceiling intersected with the main's, must permit the resolved server and read tool. `D42` records the delegated task but is not a v1 Cedar policy input. | -Both requests also require valid credentials and trusted execution-context binding, and must pass existing local, connection, and ToolHive checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of delegated bounds will need trusted evidence of those bounds. +Both requests also require valid credentials and execution-context checks, and must pass existing local, connection, and ToolHive checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. For both calls, connection access includes authorization for Alice to use the ToolHive subject and provider account behind the connection. The walkthrough assumes this association for `github-conn-42`; successful OAuth or matching account names cannot establish it. The [association mechanism](credentials-and-lifecycle.md#provider-connection-selection) remains unresolved. ## Authorization, credentials, and dispatch -Local permissions and hooks apply before invocation. The broker then authenticates the caller and actor, verifies execution context, and evaluates the final operation and connection. Credential readiness determines whether an eligible request can proceed, not whether it is authorized. +Local permissions and hooks apply before invocation. The broker then authenticates the caller and actor, checks execution context, and evaluates the final operation and connection. Credential readiness determines whether an eligible request can proceed, not whether it is authorized. | Outcome | Dispatch behavior | | --- | --- | @@ -71,6 +73,6 @@ Missing enrollment can leave authorization incomplete. Refreshable expiry is han When needed, the broker obtains a current ToolHive user access token and exchanges it with the acting instance's SVID. ToolHive's authorization server independently validates the user and actor tokens and broker client, and authorizes the requested target and access bounds. The protected vMCP independently validates the delegated token, applies operation policy, and resolves the authorized connection before using ToolHive's native provider credential. Neither downstream check replaces the broker's exact-operation decision. Credentials stay in their designated custody, outside model history; see [credential roles and custody](credentials-and-lifecycle.md#credential-custody). -Cached credentials are not cached authorization. Every subsequent call repeats the definition-ceiling evaluation and existing connection and downstream checks; any delegated-token reuse must stay within the execution context for which it is permitted. +Cached credentials are not cached authorization. Every subsequent call repeats the effective-ceiling evaluation and existing connection and downstream checks; any delegated-token reuse must stay within the execution context for which it is permitted. An allow does not prove dispatch or provider success. The [audit contract](audit.md#decision-dispatch-and-observed-outcome) owns durable evidence and correlation requirements. An unknown outcome must not be automatically replayed merely because credentials were recovered. From 8e7bdd03fc1a0172f21539e56c71b90f50b59eb3 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 14 Oct 2026 22:26:53 +0200 Subject: [PATCH 13/50] Take the split-AS model into account --- docs/drafts/agent-identity/README.md | 27 +- .../agent-identity/agent-definitions.md | 4 +- docs/drafts/agent-identity/audit.md | 36 +- .../credentials-and-lifecycle.md | 537 ++++++++++-------- docs/drafts/agent-identity/implementation.md | 109 +++- .../agent-identity/mcp-authorization.md | 32 +- .../agent-identity/mcp-gateway-modes.md | 252 ++++---- go.work.sum | 8 + 8 files changed, 584 insertions(+), 421 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 90cd0f9276..aa071cf8c5 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -41,29 +41,31 @@ The existing `AgentDef` structure currently combines instructions, execution set The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. -The harness hosts the API and agent loop and authenticates the initiating user. An **agent-definition loader** supplies harness execution configuration and broker definition records and authority ceilings. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. +The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. An **agent-definition loader** supplies harness execution configuration and broker definition records and authority ceilings. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. ```mermaid flowchart LR - U["Client"] -->|"Session creation and requests"| H["mecak8s"] + U["Client"] -->|"Sign-in"| A1 + U -->|"Session creation and requests, AS-1 access token"| H["mecak8s"] F["Agent-definition sources"] --> R["Agent-definition loader (mecak8s lifecycle)"] R -->|"Execution configuration"| H subgraph B["Identity broker"] - I["Identity issuance and authorization"] - G["Grant issuer (AS-1)"] - I --> G + I["Authorization and audit
OAuth client mecatl"] + A1["AS-1: login and ID-JAG issuer"] end + A1 -->|"Redirect to company sign-in"| IdP["Company IdP"] subgraph W["vMCP gateway (embedded or external)"] T["Gateway AS (AS-2) and credential storage"] V["vMCP"] T --- V end R -->|"Workload-authenticated definition and instance registration"| I - H <-->|"User report, tool requests, and results"| I - G -->|"Chaining grant"| T - I <-->|"Authorized MCP calls"| V + H <-->|"Workload-authenticated MCP calls; AS-1 access token once at session open; agent SVID per call"| I + I -->|"RFC 8693: session refresh token, then ID-JAG per call"| A1 + I -->|"RFC 7523: redeem ID-JAG for V"| T + I <-->|"Authorized MCP calls with V"| V V <-->|"Protected calls"| P["MCP backends / providers"] I -->|"Decisions and observed outcomes"| A["Audit records"] ``` @@ -72,15 +74,17 @@ The diagram shows the broker-backed remote architecture's MCP path. Local deploy Before the agent loop starts, mecak8s's trusted session lifecycle code [loads and persists the fixed definition selection](agent-definitions.md#loading-definitions-for-a-session), supplies execution configuration, and [registers definitions and the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication. This loader responsibility is outside the untrusted loop, not a separate registrar or an isolation boundary. The registration API, durable schema, verified per-operation binding for reported context, and protection from same-process substitution remain open. -The broker contains the agent trust issuer, the authorization and audit logic, and a grant issuer (AS-1). The vMCP gateway contains its own authorization server (AS-2), the provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or be operated separately, such as an enterprise connector gateway. In both cases AS-1 vouches for the user and acting agent with a short-lived grant that AS-2 redeems; see [MCP gateway modes](mcp-gateway-modes.md). The tools themselves execute the operations. +The broker contains the agent trust issuer, authorization and audit logic, and AS-1, Mecatl's login authorization server. The vMCP gateway contains AS-2, provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or as an external gateway. -The remaining trust question is how the broker knows that the reported user and agent definition are genuine. The agent loop must not be able to substitute either. Giving agents distinct credentials helps identify them, but does not isolate agents sharing one `mecak8s` process. +Alice signs in through AS-1, which redirects her to the company IdP. Her client then calls mecak8s with an AS-1 access token. mecak8s validates that token and current session ownership. At session open it passes the token to the broker once, and the broker exchanges it at AS-1 for a refresh token bound to that broker session. On each tool call the broker presents that refresh token and the acting agent's JWT-SVID to AS-1, which returns an ID-JAG naming Alice and the agent. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. The broker is a single OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`. AS-2 maps the AS-1 subject to its native user; an external gateway also requires a one-time consent link created at AS-2's own login. AS-2 receives neither Alice's AS-1 token nor the agent SVID. See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. The tools themselves execute the operations. + +AS-1 witnessed Alice's sign-in itself, checks that the acting instance is registered to the session, and authorizes the client, subject, actor, and target. It does not prove that the agent registration, ancestry, run, or delegation reported by trusted lifecycle code is genuine. The agent loop must not choose those facts, and distinct agent credentials do not isolate agents sharing one `mecak8s` process. ## Following an action through the architecture Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. -The broker checks the reviewer's effective server/tool ceiling, its definition ceiling narrowed by the main's, with Cedar and retains existing user/connection checks. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call using Alice's GitHub credential, which stays outside `mecak8s`. If denied, no request reaches GitHub. +The broker checks the reviewer's effective server/tool ceiling, its definition ceiling narrowed by the main's, with Cedar. User and connection are enforced downstream: AS-2 redeems only for a mapped, and in external mode linked, user, and vMCP dispatches only with Alice's stored credential. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call through the authorized connection using its native provider credential. That credential stays outside the harness and model; in embedded mode it can reside inside the Mecatl deployment. If denied, no request reaches GitHub. GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. @@ -98,5 +102,6 @@ The proposed chapters develop each part of the architecture. | [MCP gateway modes](mcp-gateway-modes.md) | Chain the broker's grant issuer to the vMCP gateway's authorization server, so one protocol works with an embedded or an external gateway. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | | [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | +| [Credential flow rework](credential-flow-rework.md) | Record in-progress decisions that replace the credential flow above with an AS-1-issued ID-JAG. Until the rework lands, it takes precedence where the two disagree. | The [implementation and verification companion](implementation.md) records integration requirements and checks. This broker-centered architecture supersedes the earlier identity model's harness-owned signing and audit responsibilities; the donor documents are retained in Git history. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index e3da040884..d5daa720fa 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -12,7 +12,7 @@ An agent definition describes how an agent should perform work. Mecatl represent | Session | The harness's persisted context for work, identified by a host session ID such as `S42`. | | Agent instance | One use of a selected definition in a session, with its own identity and any creation-parent association. A delegated child has its own instance. | | Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | -| Execution context | The workload, acting instance, session, initiating user, run, and any delegation a protected call is made under. In v1, the user, run, and delegation are mecak8s-reported; see [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | +| Execution context | The workload, acting instance, session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the session. Run and delegation context remain mecak8s-reported in v1. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | | Agent-instance incarnation | The durable identity allocated by mecak8s for that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | | Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | @@ -62,7 +62,7 @@ When the main creates the reviewer, mecak8s allocates and persists the child's i Creation ancestry records who created an instance. Execution delegation separately links parent run `R41`, reviewer run `R42`, and the bounds of delegation `D42`. Registration establishes identity records; it does not authorize spawning, run execution, or a tool call. The harness manages execution, and the broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). -The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The API's initiating-user report is also a trusted assertion, separate from workload verification. Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances; mecak8s is a trusted attestor, not an independently verified one. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. +The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The user side does not rest on mecak8s: AS-1 witnessed Alice's sign-in and names her from the session's broker refresh token, but that does not prove run or delegation context. Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances; mecak8s is a trusted attestor, not an independently verified one. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. ## Identity continuity across runs and restarts diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 0c00167170..d08e631e70 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -2,7 +2,9 @@ *This chapter describes the intended architecture, not current implementation status. The audit requirement applies to tool calls generally; the current design scope is broker-mediated MCP calls.* -The audit trail must explain which registered instance requested a call, which effective ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. A v1 Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. +The audit trail must explain which registered instance requested a call, on whose behalf, which effective ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. A v1 Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. + +The human's identity traces back to one sign-in that AS-1 witnessed through the company IdP. Later records about the user rely on that sign-in and AS-1's session record, not on a report from the harness. ## Evidence and provenance @@ -12,44 +14,52 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | --- | --- | --- | | Authenticated workload and instance | Broker validation of workload credentials and the instance JWT-SVID, with the registered session association | Does not authenticate the human or prove a task-specific delegation. | | Definition source, admitted revision, ceilings, and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | -| Initiating user and session ownership | API authentication and server-side ownership checks, reported to the broker | The broker trusts the API report; it does not independently authenticate the human from it. | -| Run and execution-delegation links | mecak8s reports in the workload-authenticated call request, part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. | -| Ceiling evaluation and effective broker decision | Broker evaluation of the registered actor's stored effective ceiling and the exact server/tool pair, plus applicable existing checks | An allow proves neither dispatch nor downstream success. | -| Downstream identity and authorization | ToolHive AS and vMCP records of their own validations and decisions | Forwarded context remains reported unless independently verified there. | +| Initiating user and session ownership | AS-1 ran the sign-in through the company IdP and issued an access token for the Mecatl API. mecak8s records that it validated the token for its API audience and checked that the token's `(iss, sub)` owns the host session. The token's `sub_id` carries the company identity. | Establishes that AS-1 authenticated the user at sign-in and that the user currently owns the session. It does not establish fresh human action for each call, task-specific delegation, or reported run context. | +| Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | +| Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. The source of the delegating instance that AS-1 writes into the nested `act` remains to be specified. | +| Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, and actor checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. Whether the broker also needs its own connection evidence before dispatch is open. An allow proves neither dispatch nor downstream success. | +| ID-JAG issuance | AS-1 records each issuance: user from the broker refresh token's session record, session, validated actor, target AS-2, and ID-JAG `jti`. AS-1 validated the SVID and checked that the instance is registered to the session. | AS-1 does not see the user again per call; the user comes from the session record. Instance membership comes from the broker registry. | +| AS-2 redemption and consent | AS-2 records each redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. In external mode, AS-2 also records creation and revocation of the consent link. | AS-2 trusts AS-1's signature and receives neither the AS-1 access token nor the SVID. In external mode the consent link is AS-2's own evidence that the user chose Mecatl; in embedded mode consent is implicit. | +| vMCP decision | vMCP records its decision with the access-token reference `(iss, jti)`, native user, outer actor, tool, and outcome | Only the outer `act.sub` drives vMCP and gateway policy; inner `act` levels are carried for audit. Other forwarded context remains reported unless independently verified. | | Observed result and provider correlation | The response received by the recording component and safe provider references, when available | A broker-observed MCP response is not independent proof of provider-side effects. | +ToolHive's authorization server records no audit events today, so the AS-1 and AS-2 records above are new work. The plan is to reuse ToolHive's audit event recorder library, which emits JSON audit records for collection with fluentd or fluent-bit. + ## Illustrative authorization evidence -The following readable fields illustrate a proposed broker authorization event for the reviewer's call in the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution). They are an audit shape to guide implementation, not an immutable event API or a Cedar policy representation. +The following readable fields illustrate proposed evidence for the reviewer's call in the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), including its broker authorization event and references to AS-1, AS-2, and vMCP records. They guide implementation; they are not an immutable event API or Cedar policy representation. | Field group | Illustrative values or required evidence | | --- | --- | | Event and call references | Event `authz-42`, operation `op-pr42-001`, attempt `attempt-1`, recording component, and observation time. Distinguish another attempt from another event for the same attempt. | -| Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; host session `S42`; reviewer's agent-instance incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; API-reported Alice with issuer/subject and request reference. Preserve the provenance above. | +| Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; host session `hostS42`; broker session `A42`; reviewer's agent-instance incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; request reference `req-pr42-001`. Alice's AS-1 subject `(https://broker.example.com/as, u-alice-91)` comes from the session's AS-1 record; her company identity `(https://login.example.com, alice-204)` comes from `sub_id`. Preserve each fact's provenance. | +| Chained credential references | Broker client `mecatl` at AS-1 and AS-2. Source AS-1 access-token `jti` `at-alice-7731`, recorded by AS-1 at conversion, never the token itself. The broker refresh token, referenced by its AS-1 record for `A42`, never by value. ID-JAG issuer `https://broker.example.com/as` and `jti` `jag-pr42-001`. Actor `agent:reviewer-gh56`, delegated by `agent:main-cejr` in the nested `act`. AS-2 access token `(https://gateway.example.com, access-pr42-001)`. These references identify different credentials and do not replace the operation ID. | | Definition and policy | Registered `project/code-reviewer`, source reference, provenance profile with source content and accepted definition digests, admitted revision `review-def-r17`, resolved definition ceiling reference, stored effective ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | -| Target | Registered payments MCP server reference, exact `pull_request_read` tool, authorized connection `github-conn-42`. | +| Target | Registered payments MCP server reference and exact `pull_request_read` tool. AS-2 resolves native user `usr-alice-7c1d`, and vMCP loads the credential for `(gateway.example.com, usr-alice-7c1d, github)`; those records live with AS-2 and vMCP, not in the broker event. | | Evaluation | Cedar nominal decision `allow`, evaluation status `completed-without-errors`, relevant policy IDs, and generated policy revision. | -| Effective decision | `allow` only after all required checks pass; safe reason `effective-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. | +| Effective decision | `allow` only after the session, workload, actor, ceiling, and route checks pass. Safe reason `effective-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. Whether the broker also requires its own connection evidence is open; if it does, record that evidence reference and deny when it is missing. | | Safe request metadata | Repository `acme/payments` and PR number `42` only if explicitly allowlisted for audit. Their presence does not mean Cedar evaluated a PR-specific policy. | The policy revision and policy IDs must lead to retained generation inputs and policy evidence sufficient to explain the ceiling result. A definition name alone cannot explain which policy was used. Error and denial records need the same correlation and attribution where established; unavailable or unverified facts must remain marked as such. +The `agent:` identifiers in `act` are opaque on purpose: the SPIFFE path, with its creation ancestry, stays inside Mecatl. AS-1's issuance record must link each `agent:` identifier it writes to the registered instance it validated, so an investigator can map a gateway record back to that instance. + ## Decision, dispatch, and observed outcome The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed as separate facts. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. If completion is missing after possible dispatch, the outcome is unknown, not evidence of nonexecution. Credential recovery cannot justify automatic replay of an unknown operation. Decision, dispatch, and observed-outcome evidence must be durable and survive broker restart. The storage mechanism, write ordering relative to dispatch, failure handling when evidence cannot be persisted, and cross-service delivery and authenticated correlation remain explicitly unresolved implementation requirements. This design does not select a transactional ledger or claim atomicity between an audit write and an external effect. -Use operation and attempt references to connect the evidence; individual events also need their own references. A token identifier such as `jti`, when present, is supplementary because several calls can reuse one token. This correlation requirement does not establish an authenticated propagation contract between services. +Use operation and attempt references to connect the evidence; individual events also need their own references. The operation ID, the ID-JAG `jti`, and the AS-2 access token `(iss, jti)` are separate references. A cached access token serves several calls, so its `jti` cannot replace a per-operation reference. This correlation requirement does not establish an authenticated propagation contract between services; how `op-pr42-001` reaches AS-2 or vMCP records remains unspecified. ## Following a call in an investigation -Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. The decision explains why the server/tool pair passed or failed and whether any other required check prevented dispatch. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with reported user and lifecycle facts distinguished from broker-verified credentials. +Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. Session `A42` leads to AS-1's conversion record, which names user `u-alice-91`, the source access-token `jti`, and the hard expiry, and from there to mecak8s's record of the request and session ownership. On a cache miss, AS-1's issuance record links `A42` and the validated reviewer to ID-JAG `jag-pr42-001`; AS-2's redemption record links that `jti` to native user `usr-alice-7c1d` and access token `access-pr42-001`. In external mode, AS-2's consent-link record shows when Alice linked Mecatl. The vMCP record contains only the access-token reference it validates. A call served from the cache has no new AS-1 or AS-2 record; it links to the earlier issuance through the cached token. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with AS-1-witnessed user identity distinguished from workload-reported run, delegation, and lifecycle facts. -The investigator then follows dispatch and observed-outcome records for that attempt, rather than inferring execution from the allow. ToolHive records can explain downstream validation or denial. Where the provider supports safe tracing or audit correlation, retain the references needed to reach its operation or audit record and to trace that record back to the actor. Mark where correlation ends if provider evidence or authenticated cross-service correlation is unavailable. A successful response describes what the observer received, not a guarantee of provider-side effects. +The investigator then follows dispatch and observed-outcome records for that attempt, rather than inferring execution from an allow or token issuance. ToolHive records can explain downstream validation or denial. The native provider-credential path is not itself a backend-to-SaaS audit trail. Where the provider supports safe tracing or audit correlation, retain the references needed to reach its operation or audit record and trace it back to the actor. Mark where correlation ends if provider evidence or authenticated cross-service correlation is unavailable. A successful response describes what the observer received, not a guarantee of provider-side effects. ## Redaction and audit-store protection -Record only allowlisted argument and result metadata needed for attribution, with redaction applied before persistence or logging. Safe status categories and nonsecret provider references usually suffice for outcomes. Exclude tokens, refresh tokens, private keys, client secrets, raw headers, OAuth transaction secrets, and unrestricted argument or result payloads. Policy diagnostics and error text need the same treatment; dumping decoded credentials is not an audit strategy. +Record only allowlisted argument and result metadata needed for attribution, with redaction applied before persistence or logging. Safe status categories and nonsecret provider references usually suffice for outcomes. Exclude tokens, refresh tokens, ID-JAGs, client assertions, private keys, client secrets, raw headers, OAuth transaction secrets, and unrestricted argument or result payloads. Record credentials by issuer and `jti` or by record reference, never by value. Policy diagnostics and error text need the same treatment; dumping decoded credentials is not an audit strategy. Protecting persisted evidence against unauthorized disclosure or modification requires audit-store access and integrity controls. Recording authenticated identities does not itself protect the store. The [credential walkthrough](credentials-and-lifecycle.md#interactive-execution) owns credential validation and OAuth examples; this chapter specifies the evidence needed to investigate their use. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 28744905c7..7d43375ce0 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -1,123 +1,157 @@ # Credentials and lifecycle -*This chapter describes the intended architecture, not current implementation status.* +*This chapter describes the intended architecture, not current implementation status. Its decisions are recorded in the [credential flow rework](credential-flow-rework.md).* -An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. In v1, the broker checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar, alongside existing user/connection checks and execution-context checks. User, resource, argument, and dynamic delegation policies are deferred; carrying run or delegation context does not mean Cedar evaluates those bounds. Renewing the instance credential does not itself extend or widen permission to call tools. +An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. In v1, the broker checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar, alongside session and execution-context checks. User, resource, argument, and dynamic delegation policies are deferred. Renewing the instance credential does not itself extend or widen permission to call tools. + +The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that sign-in itself, so every later statement about the human traces back to a login AS-1 witnessed, not to a report from the harness. ## Credential roles -| Credential | What it establishes | Where it is presented | +| Credential | What it establishes | Issuer → holder → verifier | | --- | --- | --- | -| **Human login token** | The identity of the user making a request to Mecatl. | Human client → Mecatl's API. It is not forwarded as the downstream user token. | -| **Workload credential** | Which Mecatl service is contacting the broker, not which user or agent it represents. | Mecatl → broker. | -| **Agent-instance JWT-SVID** | Which agent instance is acting. It can span multiple runs. | Mecatl → broker; the broker also uses it as the actor token in the proposed ToolHive exchange. | -| **Broker OAuth client credential** | Which OAuth client is requesting access from ToolHive. | Broker → ToolHive authorization server. | -| **ToolHive user grant** | The user's authorization granted through ToolHive. Its access token supplies the user side of the exchange; its refresh token supports renewal. | Held by the broker; the access token is presented to ToolHive as `subject_token`. | -| **Delegated access token** | The user and acting agent together: `sub=user`, `act.sub=instance`, with the protected MCP endpoint as audience. | Broker → protected MCP endpoint. | -| **Native provider credential** | Access to the external provider under the connected account. | ToolHive → provider, such as GitHub. It stays outside Mecatl. | - -These are roles, not necessarily seven credentials sent on every call. Enrollment, issuance, and refresh happen when needed. - -Run IDs, session attachments, and broker session references are separate from credentials. A broker session reference grants neither run execution nor MCP access; the proposed registration discussion distinguishes existing references from registration responsibilities. +| **AS-1 access token** | Alice's identity for requests to the Mecatl API. | AS-1 → Alice's client (TUI or Studio) → mecak8s. Passed to the broker once, at session open. | +| **Client refresh token** | The client's ability to renew its own AS-1 access token. | AS-1 → client only. Never leaves the client. | +| **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and issuer. It identifies neither Alice nor an agent instance. | +| **Agent-instance JWT-SVID** | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | +| **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl` and to the session. | +| **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | +| **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | +| **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. Cached per session, instance, and gateway. | +| **Upstream provider credential** | Access to Alice's GitHub account. | GitHub → gateway credential store → GitHub. Never leaves the gateway. | +| **Signing keys** | Authority to issue credentials. | AS-1, AS-2, and the agent-instance issuer each keep their own key. Separate keys are not process isolation when components share a process. | + +### Example nomenclature + +| Kind | Name | Meaning | +| --- | --- | --- | +| Identity | Company Alice `(https://login.example.com, alice-204)` | Alice at the company IdP. AS-1 represents her as subject `u-alice-91`; AS-2 as native user `usr-alice-7c1d`. | +| Identity | `mecatl` | The broker's OAuth client at AS-1 and AS-2. Not a user or an agent. | +| State | `hostS42`, `A42` | Host session ID, and prose alias for the existing opaque `BrokerSessionRef`. Neither is a credential. | +| State | `main`, `reviewer`, `R41`, `R42`, `D42` | Registered agent instances, their runs, and the delegation from main to reviewer. | +| Reference | `op-list-prs-001`, `op-pr42-001` | Correlation IDs for two tool operations. They identify operations, not people, and authorize nothing. | +| Credential | `mainSVID`, `reviewerSVID` | Registered agent-instance credentials. | +| Credential | `RT-A42` | The broker refresh token for session `A42`. | +| Credential | `J-main`, `J-reviewer` | ID-JAGs naming Alice and each acting agent. | +| Credential | `V-main`, `V-reviewer` | AS-2 access tokens naming native Alice and each acting agent. | + +No credential value belongs in the model's context. ## Interactive execution -Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, owns harness session `S42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` first calls `pull_request_list`. After the list returns, it delegates review of PR 42 through `D42` to reviewer run `R42`, which calls `pull_request_read`. These tool names and arguments are illustrative, not a selected MCP server contract. The main instance has incarnation `inc_cejrv7re4uy3ubxyzj2gfxfp2u`; its reviewer child has incarnation `inc_gh56irfcpnupjqgfdw7dhzzw4m`. Runs identify execution, not credentials. In the diagram, `main` and `reviewer` abbreviate the exact SPIFFE subjects in [example instance identities](#example-instance-identities). The reviewer uses definition `project/code-reviewer`. Creation ancestry and delegation `D42` are separate facts, even though the main both creates this reviewer and delegates this task to it. - -The diagram starts from the [fixed definition selection and workload-authenticated registration](agent-definitions.md#loading-definitions-for-a-session) supplied by mecak8s's trusted lifecycle code. The main is registered before execution; the reviewer is registered when created, using the selected reviewer definition and its own persisted agent-instance incarnation. Section (3) identifies the inputs for each. Registration records identity, not permission to spawn, run, or call tools. It trusts mecak8s lifecycle assertions and does not establish same-process isolation; the [registration contract and open questions](agent-definitions.md#registering-instances) remain proposed, not implemented behavior. +Alice owns harness session `hostS42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` calls `pull_request_list`. After the list returns, main delegates the review through `D42` to reviewer run `R42`, which calls `pull_request_read`. Tool names and arguments are illustrative. The reviewer uses definition `project/code-reviewer`; main both creates it and delegates the task to it, but creation and delegation remain separate facts. In the diagram, `main` and `reviewer` abbreviate the SPIFFE subjects in [example instance identities](#example-instance-identities). -Operation `op-list-prs-001` identifies the main's list call. Operation `op-pr42-001` identifies the reviewer's PR read; the detailed credential and audit examples below follow this second call. Times are Unix seconds around `1800000000`, matching the delegated token. Examples are fictional decoded payloads or credential references, never usable bearer values. Audit examples contain only selected metadata; they are not copies of credentials or implemented cross-service events. The common operation ID denotes desired correlation, not an established authenticated propagation mechanism. Audit placement does not specify durable-write ordering. - -For this example only, configured workload audience `https://broker.example.com` covers the combined broker's issuance and invocation endpoints. The instance credential has two explicit audiences: that broker and `https://toolhive.example.com/token`. This illustrates a multi-audience deployment so the same actor credential can reach both validators; it does not select the architecture over separately issued credentials. Each recipient requires its own configured audience and trust material, never endpoints chosen by token claims. - -Instance credential issuance, provider enrollment, and token exchange occur only when needed. The broker checks every requested operation (see [delegated authority](mcp-authorization.md#delegated-authority) and [operations and resources](mcp-authorization.md#operations-and-resources)). - -The diagram follows two successful calls, with enrollment already complete and usable provider credentials in custody. Failure handling, enrollment, and internal checks are in the linked legend rather than separate arrows. The numbered explanatory sections are not message order. Participants are logical roles, so grouping them does not establish process isolation. +The gateway in this example is external, at `https://gateway.example.com`. Embedded mode follows the same steps with AS-2 and vMCP inside the Mecatl deployment. ```mermaid sequenceDiagram - actor U as Alice / human client - participant H as mecak8s (API / harness client) - box Combined broker - participant I as Agent-instance issuer - participant B as Tool authorization / broker OAuth client - participant A as ToolHive AS - participant V as Protected vMCP + actor U as Alice (TUI / Studio) + participant H as mecak8s + participant I as Agent-instance issuer + box Broker + participant B as Broker core + participant A1 as AS-1 + end + participant IdP as Company IdP + box Gateway + participant A2 as AS-2 + participant V as vMCP + end + participant G as GitHub + + Note over U,IdP: (1) Sign-in, once per login session + U->>A1: Authorization code + PKCE
(issuer from mecak8s protected-resource metadata) + A1->>IdP: Redirect to company sign-in
(AS-1 is an ordinary OIDC client there) + IdP-->>A1: Alice authenticated + A1-->>U: AS-1 access token (aud mecak8s) + client refresh token + + Note over U,A1: (2)(3) Session open + U->>H: PR review request + AS-1 access token + H->>H: Validate token, check ownership of hostS42 + H->>B: OpenSession: workload JWT + AS-1 access token (once) + B->>A1: RFC 8693: subject_token = AS-1 access token
requested_token_type = refresh_token
client mecatl (private_key_jwt) + A1-->>B: RT-A42 (bound to A42, hard maximum lifetime) + B-->>H: SessionSnapshot (BrokerSessionRef A42) + + rect rgb(245, 245, 245) + Note over H,I: (4) Main bootstrap, before R41 + H->>I: registerDefinitions, registerInstance(main), workload JWT + I-->>H: mainRef + canonical main subject + H->>I: requestSVID(mainRef), workload JWT + I-->>H: mainSVID + end + + Note over H,G: (6) Main call: pull_request_list + H->>B: InvokeTool: workload JWT, A42, op-list-prs-001, agentJwtSvid = mainSVID + B->>B: Check session, workload, actor, effective ceiling, route + opt No cached V for (A42, main, gateway) + B->>A1: (7) RFC 8693: subject_token = RT-A42
actor_token = mainSVID
requested_token_type = id-jag, audience = AS-2 + A1->>A1: Session open, hard expiry not passed
main registered to A42, build act + A1-->>B: J-main + B->>A2: (8) RFC 7523: assertion = J-main, client mecatl + A2->>A2: Map AS-1 subject to native user
External mode: require consent link + A2-->>B: V-main (sub usr-alice-7c1d, act main, aud vMCP) + end + B->>V: (9) tools/call pull_request_list + Bearer V-main + V->>V: Validate V-main
Load credential (gateway, usr-alice-7c1d, github) + V->>G: List PRs with Alice's GitHub token + G-->>V: Result + V-->>B: Result + B-->>H: Sanitized outcome + + rect rgb(245, 245, 245) + Note over H,I: Reviewer bootstrap, after the list succeeds + H->>H: Delegate review: D42 / R42 + H->>I: registerInstance(reviewer, parentRef = mainRef), workload JWT + I-->>H: reviewerRef + H->>I: requestSVID(reviewerRef), workload JWT + I-->>H: reviewerSVID end - Note over H,I: Proposed session bootstrap with fixed definitions and host-allocated incarnations - H->>I: Register main under workload SA authentication
Main incarnation + selected definition + no parent - I-->>H: Registered main reference and canonical subject - H->>I: Request main SVID
Workload credential + registered main reference - I-->>H: Main JWT-SVID - - U->>H: List pull requests and review PR 42 in acme/payments
API access token - Note over H,V: First call: main run R41 lists pull requests - H->>B: pull_request_list(repository=acme/payments)
Workload + main SVID + R41 + op-list-prs-001 - B->>B: Authenticate and check context
Check main server/tool ceiling and connection access - B->>A: Exchange user subject_token + main actor_token
Broker client authentication - A->>A: Validate user and main actor tokens and broker client
Authorize requested MCP target and access bounds - A-->>B: Delegated token with sub=ToolHive user and act.sub=main - B->>V: pull_request_list with main's delegated token - V->>V: Authenticate delegated token and authorize list operation - V-->>B: Pull request list including PR 42 - B-->>H: Sanitized list including PR 42 and observed outcome - - H->>H: Main delegates PR 42 review via D42 to reviewer R42
Allocate and persist child incarnation and definition - H->>I: Register reviewer under workload SA authentication
Child persisted incarnation + registered reviewer definition + registered parent reference - I-->>H: Registered reviewer reference and child subject - H->>I: Request reviewer SVID
Workload credential + registered reviewer reference - I-->>H: Reviewer's own JWT-SVID - - Note over H,V: Second call: reviewer run R42 reads PR 42 - H->>B: pull_request_read(repository=acme/payments, number=42)
Workload + reviewer SVID + R42 + D42 + op-pr42-001 - B->>B: Authenticate and check context
Check reviewer server/tool ceiling and connection access - B->>A: Exchange user subject_token + reviewer actor_token
Broker client authentication - A->>A: Validate user and reviewer actor tokens and broker client
Authorize requested MCP target and delegated access bounds - A-->>B: Delegated token with sub=ToolHive user and act.sub=reviewer - B->>V: pull_request_read PR 42 with reviewer's delegated token - V->>V: Authenticate delegated token and authorize PR 42 read - V-->>B: PR 42 result - B-->>H: Sanitized PR result and observed outcome - H-->>U: Review produced from reviewer findings
No credentials in model history + H->>B: InvokeTool: A42, op-pr42-001, agentJwtSvid = reviewerSVID + Note over B,G: Same checks and cache-miss path with actor_token = reviewerSVID
J-reviewer and V-reviewer: act = reviewer, nested act = main + B-->>H: Sanitized outcome + H-->>U: Review outcome ``` -| Legend key | Explanatory check or record (not a chronological message) | -| --- | --- | -| [(1) API access token](#1-company-api-access-token) | After the API request and before the main run, mecak8s validates the token and checks its issuer and subject against the stored session owner; only then does it start `R41`. This authenticates the API request and enforces session ownership, not MCP dispatch authorization. | -| [(2) Workload credential](#2-workload-credential) | The broker authenticates mecak8s for registration, issuance, and both invocations. This authenticates the service's lifecycle assertions, not isolation from compromised code in that service. Invalid workload credentials stop the request. | -| [(3) Broker session and instance registration](#3-broker-session-and-instance-registration) | Before returning the registered reference and before (4), the issuer validates the workload and persisted registration records, then constructs the canonical subject. Registration remains identity recording only; it authorizes neither spawning, run execution, nor a tool call. | -| [(4) Agent-instance JWT-SVID](#4-agent-instance-jwt-svid) | After the SVID is returned and before an invocation, mecak8s verifies its signature using the configured agent trust bundle and checks the registered subject, allowed algorithm, audience, and expiry. | -| [(5) Enrollment](#5-enrollment-and-authorized-connection) | If an invocation reveals missing enrollment or a grant requiring user reauthorization, the broker returns an enrollment-required response without dispatch. mecak8s pauses, presents the broker link, and retries after completion through all checks. Enrollment is not permission to execute. Refreshable expiry is handled in custody where possible. | -| [(6) Per-operation authorization](#6-per-operation-authorization) | Main's list and reviewer's read each pass local permissions and hooks, broker credential/context checks, the actor's effective ceiling, and connection checks before dispatch. `D42` is context, not v1 dynamic delegation policy. The [ceiling rules](mcp-authorization.md#definition-derived-ceiling) fail closed, including on any Cedar diagnostic error. | -| [(7) Token exchange and delegated MCP access token](#7-token-exchange-and-delegated-mcp-access-token) | Before requesting exchange, the broker obtains a current user access token from custody or refreshes it when needed. Before returning the delegated token, ToolHive AS validates the user token, actor credential, presenter, and bounded access. The broker restricts any delegated token to the applicable execution context; reuse never exceeds those bounds. | -| [(8) Dispatch and observed outcome](#8-dispatch-and-observed-outcome) | Before resolving the upstream grant or calling the provider, vMCP validates the delegated token and operation policy and resolves the authorized connection. It performs each GitHub operation using the native provider credential held by ToolHive, never an SVID as the provider credential. After the provider result and before returning the sanitized result, the broker records the observed outcome and provider correlation when available. | +The diagram follows the successful path. Other outcomes: + +- **No upstream credential.** vMCP finds no stored GitHub credential for Alice and answers "needs authorization". The broker returns the existing `mcp_authorization_pending` outcome; the TUI or Studio opens the authorization URL and rechecks, and the parked run resumes. +- **No consent link (external mode).** AS-2 refuses to redeem the ID-JAG. The broker returns the same pending outcome, and the URL first leads through AS-2's "Allow Mecatl" step. +- **Denial or failure.** Any denial before dispatch prevents the provider call. Retrying requires authorization again. +- **Unknown outcome.** If dispatch may have happened but no completion was observed, the outcome is unknown and is not replayed automatically. -Decision, dispatch, and outcome require durable evidence; persistence ordering and cross-service propagation are unspecified. Every subsequent tool call repeats authorization. +Every later tool call repeats the broker checks, including calls served from the `V` cache. -A child call uses the same invocation flow with the child's own JWT-SVID and attributed run and delegation context. It does not require or present the parent's SVID. The broker checks the child's registered parent-instance reference, not a parent token, when it constructs the child subject and computes the child's [effective ceiling](mcp-authorization.md#delegated-authority) from its definition ceiling and the main's. It evaluates that stored effective ceiling for the call. Broker enforcement of task-specific delegated bounds remains future work. +### (1) AS-1 access token -### (1) Company API access token +AS-1 is Mecatl's login authorization server. mecak8s's protected-resource metadata names AS-1, and the TUI and Studio already discover their issuer there, so neither client changes. AS-1 does not hold passwords: it redirects Alice to the company IdP as an ordinary OIDC client. The company IdP administrator registers one web application, AS-1, with AS-1's callback as redirect URI. -The company IdP issues an API access token to Alice's client, which presents it to Mecatl over TLS. The client holds it; it terminates at the API and is not retained for downstream access. This fictional JWT access-token payload is not an ID token. +The client receives an access token for the Mecatl API and keeps its own refresh token: the TUI in the OS keyring, Studio in sealed server-side cookies. A fictional decoded access token: ```json { - "iss": "https://login.example.com", - "sub": "alice-204", + "typ": "at+jwt", + "iss": "https://broker.example.com/as", + "sub": "u-alice-91", + "sub_id": {"format": "iss_sub", "iss": "https://login.example.com", "sub": "alice-204"}, "aud": "https://mecatl.example.com/api", + "client_id": "mecatui", "iat": 1799999900, - "exp": 1800003500 + "exp": 1800000800, + "jti": "at-alice-7731" } ``` -This example uses mecak8s with OIDC authentication and session ownership enforcement enabled. Alice submits her request to existing session `S42`, which belongs to her. mecak8s validates her access token and checks that its issuer and subject match the session's stored owner. If either check fails, it rejects the request. Otherwise, the harness starts main run `R41`. +mecak8s validates it as a JWT access token (RFC 9068, including `typ`), checks `aud`, and checks that `(iss, sub)` owns `hostS42`. If either check fails, it rejects the request. An `id_token` is not accepted here: its audience is the client, not the API. + +Session ownership is keyed by `(iss, sub)`, so the owner's issuer becomes AS-1's. Existing sessions owned by the company issuer need a migration; `sub_id` carries the company identity so mecak8s can match existing owners during the transition. The company IdP sees only AS-1 as the application: per-application IdP policies can no longer distinguish the TUI from Studio. -**Illustrative API evidence:** mecak8s accepted request `req-pr42-001` from authenticated user `(https://login.example.com, alice-204)` for session `S42`. The user identity comes from the validated token; session ownership comes from server-side state. This records API authentication and session ownership only. The broker has not yet authorized an MCP call to read PR 42. +**Evidence:** mecak8s accepted request `req-pr42-001` from `(https://broker.example.com/as, u-alice-91)` for `hostS42`. This records API authentication and ownership only. ### (2) Workload credential -Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s presents it to the broker over TLS, keeping it outside model context. This fictional decoded payload identifies the calling service: +Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s presents it to the broker and agent-instance issuer over TLS, outside model context: ```json { @@ -134,71 +168,91 @@ Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s prese } ``` -The broker checks the signature using trusted cluster keys, the issuer, allowed signing algorithm, broker audience, and expiry. It then checks that the authenticated ServiceAccount is allowed to contact it. If either check fails, it rejects the request. +The broker checks the signature against trusted cluster keys, the issuer, allowed algorithm, broker audience, and expiry, then checks that the ServiceAccount may contact it. This identifies the mecak8s service, not Alice or an agent instance. [SPIRE alternatives](#workload-authentication) replace this credential without changing its role. -**Illustrative broker evidence:** the caller authenticated as `(https://kubernetes.example.com, system:serviceaccount:mecatl:mecak8s)`. This identifies the mecak8s service, not Alice or the reviewer instance, and does not authorize the PR 42 call. +### (3) Session open and the broker refresh token -[SPIRE alternatives](#workload-authentication) replace this workload credential without changing its role. +At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, alongside workload authentication. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. -### (3) Broker session and instance registration +The broker exchanges that token at AS-1: -The broker session reference identifies broker-owned state; knowing it grants no access. The broker checks the calling service's authenticated identity and deployment. This associates the session with mecak8s, not with Alice, and is separate from harness session `S42`. See [pauses and restart](#pauses-and-restart) for attachment and recovery details. +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:access_token +requested_token_type=urn:ietf:params:oauth:token-type:refresh_token +client_id=mecatl +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` + +AS-1 checks, in order: + +1. **The client.** The `private_key_jwt` assertion verifies against `mecatl`'s registered key, with `iss` = `sub` = `mecatl`, `aud` = AS-1's issuer, a short expiry, and an unused `jti`. +2. **The subject token.** AS-1 issued it, `aud` is mecak8s, it is still active, and its `jti` has not been converted before. +3. **Policy.** Client `mecatl` may convert tokens from the TUI and Studio, once per session, with no wider scope than the original grant. + +AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record: + +| Field | Purpose | +| --- | --- | +| Client `mecatl` | Only the authenticated broker can use it. | +| User `u-alice-91` | Subject of every ID-JAG issued from it. | +| `A42` | The session binding. | +| `hostS42` and host incarnation | Restart and recovery continuity. | +| Source access-token `jti` | One conversion per session. | +| Issue time and hard expiry | Authority horizon. Rotation does not extend it. | +| Rotation chain | Reuse detection. | + +The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. In v1 the refresh token is not tied to Alice's AS-1 login session: disabling her company account takes effect at the hard expiry at the latest. Binding to the login session is later work. -For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The broker constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). +The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. -Alice's identity remains a separate API report, not established by the workload credential. The issuance example in (4) preserves that distinction. See [source records and policy representation](agent-definitions.md#source-records-and-policy-representation) for the open API, schema, per-operation context-binding, and same-process protection questions. +**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less ToolHive work but still requires the AS-1 record for revocation and one-conversion checks, does not rotate, and is an extension to the ID-JAG profile. See the [rework record](credential-flow-rework.md#d3-login-and-session-admission). + +**Evidence:** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, source token `jti`, session `A42`, and the hard expiry. ### (4) Agent-instance JWT-SVID -The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. The harness client holds it outside model history and presents it to the broker; the broker later presents it as the actor token to ToolHive AS. The issuer retains its private signing key. This decoded example deliberately uses only required JWT-SVID claims: +For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The agent-instance issuer constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). Registration records identity, not permission to spawn, run, or call tools. + +The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. Mecatl's harness holds it outside model history and presents it to the broker for invocation. The broker forwards it to AS-1 as `actor_token`; AS-2 receives only the actor claim in the ID-JAG. The configured broker audience is accepted by both the broker invocation validator and AS-1's actor validator. The agent issuer retains its private signing key. This fictional decoded example deliberately uses only required JWT-SVID claims: ```json { "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", - "aud": ["https://broker.example.com", "https://toolhive.example.com/token"], + "aud": ["https://broker.example.com"], "exp": 1800000600 } ``` -Before issuance, the issuer checks the workload and reviewer instance against the proposed registration in step (3); a definition's existence alone is insufficient. The harness checks that the returned subject is the registered reviewer instance, then validates its signature, allowed algorithm, expiry, and intended audiences against the configured agent trust-domain bundle. The broker and ToolHive AS each require their own audience and a permitted presenter. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. +Before issuance, the issuer checks the workload and reviewer instance against the registration; a definition's existence alone is insufficient. Mecatl checks that the returned subject is the registered reviewer instance, then validates its signature, allowed algorithm, expiry, and shared broker audience against the configured agent trust-domain bundle. The broker and AS-1 each validate the SVID for their configured audience and permitted presenter. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and trust domain are distinct from OAuth AS-1 and AS-2; AS-1 uses the validated registered identity to form the ID-JAG's `act` claim. + +See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. + +### (5) Broker client authentication -**Illustrative issuance evidence for (3) and (4):** only if mecak8s's proposed lifecycle registration supplies the definition provenance and instance association, and the API supplies its user report, could the broker correlate the issued subject with the following facts. Today's broker contract does not supply these registration/user proofs, and `AgentDef` has no source or revision fields today. This is conditional audit evidence, not implemented behavior, additional JWT claims, or a registration API; protection against same-process substitution remains unresolved. +The subject tokens the broker presents are bearer tokens: they prove whose authority is used, not who presents them. The broker therefore authenticates as OAuth client `mecatl` on every token-endpoint request, at AS-1 and at AS-2, with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion): ```json +{"alg": "ES256", "kid": "broker-2026-10"} { - "issuance_ref": "issue-42", - "definition": { - "name": "project/code-reviewer", - "revision": "review-def-r17", - "source": "repo://acme/payments/.mecatl/agents/code-reviewer.md", - "evidence_source": "mecak8s lifecycle registration" - }, - "initiating_user": { - "iss": "https://login.example.com", - "sub": "alice-204", - "evidence_source": "API report", - "request_ref": "req-pr42-001" - } + "iss": "mecatl", "sub": "mecatl", + "aud": "https://broker.example.com/as", + "jti": "ca-7f3a", "iat": 1800000000, "exp": 1800000060 } ``` -The JWT's `sub` identifies the reviewer instance. It does not carry Alice's identity or the definition revision; the correlated evidence records those separately and preserves their different sources. - -See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. - -### (5) Enrollment and authorized connection - -For this same PR read, `th-user-731` is the ToolHive-side subject associated with Alice **in this example**, not her company-login subject. The example assumes Alice is authorized to use that subject and provider account `github-account-731` through connection `github-conn-42` for `acme/payments`. The association mechanism remains open: referring to the same person, matching names or ID suffixes, and successful OAuth do not prove authorization. `github-account-731` is a distinct provider account ID; its shared suffix with the ToolHive subject has no evidentiary meaning. Model arguments cannot select another user's connection. +`aud` is the authorization server's issuer identifier, following current guidance on client-assertion audiences. The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. -If enrollment is needed, Alice completes it before the pending call is reauthorized. The broker holds the ToolHive user grant and uses its access token as `subject_token`; ToolHive holds the native provider grant for this connection. Neither grant reaches the harness or model. Client authentication and credential placeholders are shown once under [credential custody](#credential-custody); see also [provider-connection selection](#provider-connection-selection). +This makes "accept only from the broker" enforceable. A leaked Alice access token or a leaked `RT-A42` is useless without the key. A refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5). A `client_secret` is not used: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. -**Illustrative enrollment evidence:** ToolHive records verified client `mecatl-broker`, authenticated subject `th-user-731`, enrollment `enroll-42`, connection `github-conn-42`, and the provider-reported account. The broker records connection readiness with Alice still API-reported. Records exclude tokens, client authentication material, codes, and transaction secrets. - -Refresh behavior belongs in [credential lifetimes and renewal](#credential-lifetimes-and-renewal). +One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. ### (6) Per-operation authorization -Local permissions and hooks run before each call. For the second call, trusted harness client code presents the workload and reviewer credentials with operation `op-pr42-001`, run `R42`, delegation `D42`, and this illustrative request: +Local permissions and hooks run before each call. For the reviewer's call, trusted harness client code sends `InvokeTool` with workload authorization metadata, `A42`, `call.id=op-pr42-001`, and `agentJwtSvid=reviewerSVID`. The model supplies only the tool content: ```json { @@ -207,175 +261,170 @@ Local permissions and hooks run before each call. For the second call, trusted h } ``` -The broker checks main's list against the main's effective ceiling and this read against the reviewer's, following the [definition-derived ceiling rules](mcp-authorization.md#definition-derived-ceiling). Both calls require valid workload and actor credentials, execution-context checks, and authorization to use the ToolHive subject and provider connection; in v1, user, run, and delegation remain mecak8s-reported. The reviewer ceiling gives the same result for PR 43 on this server/tool pair: v1 does not verify PR-specific bounds from `D42`. Existing connection and downstream checks still apply; see [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). - -**Illustrative broker evidence:** `op-pr42-001` passed policy revision `review-ceiling-r17-g1` without evaluation errors and was allowed after the required connection and context checks. The workload and reviewer credential were verified; Alice remains API-reported and `D42` reported execution context. This decision proves neither dispatch nor success. The [audit evidence shape](audit.md#illustrative-authorization-evidence) owns the full attribution, request, attempt, policy, and redaction requirements. +Before any exchange or dispatch, the broker checks: -Recovered authority remains subject to the original bounds described in [pauses and restart](#pauses-and-restart). +- the session `A42` is open and bound to the authenticated workload and `hostS42`; +- the SVID names an instance registered to `A42`; +- the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors; +- the route to the gateway. -### (7) Token exchange and delegated MCP access token +A cache hit skips the token exchanges, never these checks. In v1, user, run, and delegation are not Cedar inputs; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). -In this token exchange, ToolHive exchanges the user access token and agent-instance JWT-SVID for the access token that the broker presents to vMCP. This example proposes the [RFC 9068 JWT access-token profile](https://www.rfc-editor.org/rfc/rfc9068.html#section-2) with [RFC 8693 delegation claims](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). It is a proposed contract, not a claim about existing ToolHive behavior. The exchange output is an OAuth access token, not a JWT-SVID. +User and connection authorization are enforced downstream: AS-2 redeems only for a mapped (and, in external mode, linked) user, and vMCP dispatches only with Alice's stored credential. Whether the broker additionally needs its own connection evidence before dispatch remains open. -Before reading the token: `sub=th-user-731` names Alice's assumed authorized subject in ToolHive's namespace, not `(https://login.example.com, alice-204)` from her company login. `act.sub` names the reviewer instance executing `R42` under delegation `D42`; `client_id=mecatl-broker` names the broker requesting the token, not another agent or user. The token is for the same PR read at `https://mcp.example.com/payments/mcp` through `github-conn-42`; it does not itself establish that connection association. +**Evidence:** `op-pr42-001` passed the effective-ceiling policy and session, workload, and actor checks. This decision proves neither dispatch nor success. The [audit chapter](audit.md#illustrative-authorization-evidence) owns the full evidence shape. -The decoded header and payload below use fictional values. A real token is signed; its signature and serialized bearer value are omitted here. +### (7) ID-JAG from AS-1 -**Header:** +On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)): -```json -{ - "typ": "at+jwt", - "alg": "RS256", - "kid": "toolhive-example-key-1" -} +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token=RT-A42 +subject_token_type=urn:ietf:params:oauth:token-type:refresh_token +actor_token=reviewerSVID +actor_token_type=urn:ietf:params:oauth:token-type:jwt +requested_token_type=urn:ietf:params:oauth:token-type:id-jag +audience=https://gateway.example.com +client_id=mecatl + client assertion ``` -**Payload:** +AS-1 resolves `RT-A42` to its record and checks that the session is open, the hard expiry has not passed, and the token is bound to `mecatl`. It validates the SVID and checks that the reviewer is registered to `A42`. It authorizes the combination of client, subject, actor, and target AS-2, then issues: ```json +{"typ": "oauth-id-jag+jwt", "alg": "ES256", "kid": "as1-key-1"} { - "iss": "https://toolhive.example.com", - "sub": "th-user-731", - "act": { - "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m" - }, - "client_id": "mecatl-broker", - "aud": "https://mcp.example.com/payments/mcp", - "iat": 1800000000, - "exp": 1800000300, - "jti": "d680052b-7081-41aa-b777-147239446ab3" + "iss": "https://broker.example.com/as", + "sub": "u-alice-91", + "aud": "https://gateway.example.com", + "client_id": "mecatl", + "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "jti": "jag-pr42-001", + "iat": 1800000001, + "exp": 1800000121 } ``` -ToolHive AS validates the user access token, the instance SVID for its own audience, and the broker's client authentication. It checks that the broker may present this actor and that the requested MCP target and access bounds are authorized before issuing the token. +- `act` names the acting instance with an opaque, stable `agent:` identifier, not the SPIFFE path, so internal structure does not cross into the gateway's domain. The nested `act` records the instance that delegated this task. Only the outer `act.sub` identifies the actor for vMCP session binding and gateway policy; inner levels serve audit. Depth is capped. The source of the delegating instance, the reported delegation context that `InvokeTool` does not carry today, remains to be specified. +- `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. +- AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; the draft forbids an issuer redeeming its own ID-JAG (§9.3). -The audience is vMCP, not GitHub. The token lasts five minutes. No scope was requested in this example; operation policy still bounds access, so omitting `scope` does not grant unrestricted access. +ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both drafts are Internet-Drafts, not RFCs. -**Illustrative AS evidence:** exchange `exchange-42` records the verified user, actor, client, target, and issued token's `(iss, jti)`. Caller-supplied run context remains reported. The broker holds the bearer outside the harness and limits reuse to the applicable execution context. +**Evidence:** AS-1 records the ID-JAG issuance: user, session `A42`, validated actor, target, and `jti`. -See [downstream token exchange](#downstream-token-exchange) for token correlation and actor-history limits, and [sender binding](#sender-binding) for replay protection beyond this bearer example. +### (8) AS-2 access token -### (8) Dispatch and observed outcome +The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), authenticating again as `mecatl`. AS-2 checks the `typ`, that `aud` is its own issuer, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then resolves the native user: -The broker presents the delegated token from step (7) to vMCP with the PR 42 request. vMCP checks the expected token type, trusted issuer, allowed algorithm, signature against configured issuer keys, audience, and expiry before relying on its claims. It then checks the operation and arguments against policy and verifies authorization to use `github-conn-42`. +| Mode | How AS-2 resolves and authorizes the user | +| --- | --- | +| Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit; AS-1, AS-2, and the credential store share one trust domain. | +| External | Identity chaining across trust domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | -ToolHive supplies the provider credential from custody for the authorized target. GitHub validates that native credential, not the SVID or delegated MCP token. +The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). AS-2 never resolves users through `sub_id` (§9.5). -**Illustrative vMCP evidence:** +AS-2 issues a short-lived access token and no refresh token (§4.4.3): ```json +{"typ": "at+jwt", "alg": "RS256", "kid": "gateway-example-key-1"} { - "event": "vmcp.authorization", - "time": 1800000002, - "operation_id": "op-pr42-001", - "provider_connection": "github-conn-42", - "token_ref": { - "iss": "https://toolhive.example.com", - "jti": "d680052b-7081-41aa-b777-147239446ab3" - }, - "validated_identity": { - "subject": "th-user-731", - "actor": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", - "oauth_client": "mecatl-broker" - }, - "request": { - "tool": "pull_request_read", - "arguments": { - "repository": "acme/payments", - "number": 42 - } - }, - "authorization": { - "decision": "allow", - "source": "vmcp-policy-evaluation" - } + "iss": "https://gateway.example.com", + "sub": "usr-alice-7c1d", + "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "client_id": "mecatl", + "aud": "https://gateway.example.com/mcp", + "iat": 1800000005, + "exp": 1800000120, + "jti": "access-pr42-001" } ``` -This illustrative record captures validated claims and vMCP's policy decision, not a bearer credential or proof of dispatch. - -**Illustrative outcome evidence:** the broker and vMCP record their dispatch attempts and observed responses for `op-pr42-001`. vMCP retains provider reference `provider-request-42` only if returned and safe to store. The broker records the MCP response it received; the API returns a sanitized answer without credentials in model history. A successful response describes what the observer received, not guaranteed provider-side effects or a complete provider audit trail. Missing completion leaves an unknown outcome; see [pauses and restart](#pauses-and-restart). +The broker caches `V-reviewer` for `(A42, reviewer, gateway)` until it expires, then obtains a new ID-JAG. vMCP binds its session (`Mcp-Session-Id`) to the user and the outer `act.sub`, so each agent instance has its own vMCP session. -The [general audit contract](audit.md) requires durable decision, dispatch, and outcome evidence. Persistence ordering and authenticated cross-service propagation remain unspecified; recipients must label propagated context as reported unless independently verified. +**Evidence:** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. -## User identity - -Mecatl identifies the authenticated user by the issuer and subject of the validated login token, represented by `session.Principal`. The user's association with the acting agent must remain traceable through delegation and audit. This design does not introduce a user SPIFFE path or a separate user SVID; the agent-instance SPIFFE ID identifies the agent, not the user. +### (9) vMCP decision and upstream credential -The Mecatl user identity and the ToolHive user subject are not necessarily the same identifier. Their association with the authorized provider connection must be established rather than inferred from matching names or subjects. +vMCP validates `V-reviewer` against AS-2's keys, `typ`, audience, and expiry, and authorizes the operation. It then loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. One record per user and provider exists, refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. -The authority design proposes trusting Mecatl's authenticated API component to report the initiating user to the broker, rather than forwarding the original login token. The broker authenticates the reporting workload, but this does not independently prove the user's authentication. How the report is protected from substitution by the untrusted agent loop remains to be specified. Audit records must distinguish this reported identity from facts the broker verified itself. - -## Credential custody +Open-source ToolHive keys stored credentials by `tsid`, a per-interactive-login session ID, and a delegated `V` carries none. A per-user lookup for delegated tokens is required in standalone deployments. -Human login tokens terminate at Mecatl's API. They are validated for incoming requests, not retained for downstream access or scheduled execution. +If no credential is stored, vMCP answers "needs authorization" and the user connects GitHub through the existing authorization flow. A later call after connection passes every check again. -Mecatl's client code holds and presents agent-instance credentials without exposing them to the model. They stay out of tool arguments, results, conversation history, logs, and environment variables. Keeping credentials hidden from the model does not protect them from a compromised harness process or isolate agents sharing that process. +**Evidence:** vMCP records its decision with the access-token reference `(iss, jti)`, user, outer actor, tool, and outcome. Neither a successful MCP response nor an authorization record proves provider-side effects. Persistence ordering and authenticated cross-service propagation of `op-pr42-001` remain unspecified. -The broker's issuer holds the agent signing authority. Mecatl receives signed instance credentials and public verification keys, never the issuer's private signing key. +## User identity -The broker authenticates as OAuth client `mecatl-broker` to ToolHive AS; ToolHive authenticates separately to the provider. Operators provision each client's authentication material and server-side registration. These references leave the authentication methods open; a client ID alone is not a credential. +AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s uses the AS-1 identity for API authentication and session ownership. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. -| OAuth client | Client ID | Authentication material held by that client | -| --- | --- | --- | -| Broker → ToolHive AS | `mecatl-broker` | `` | -| ToolHive → provider AS | `` | `` | +Compared with a harness report, a compromised mecak8s can misuse tokens of users currently passing through it but cannot name arbitrary users. A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. -Enrollment yields these opaque credential placeholders; their token formats are not selected: +AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. -```json -{ - "toolhive": { - "access_token": "", - "refresh_token": "" - }, - "provider": { - "access_token": "", - "refresh_token": "" - } -} -``` +The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. This selects neither multiplayer support nor run-owner attribution in place of session ownership. -This groups examples for reading, not storage or transport. Each AS validates its OAuth client's authentication against its registration. ToolHive AS validates the ToolHive user grant, subject, intended use, and validity; the provider validates its native access token. Enrollment requires OAuth transaction protections; callbacks and code redemption are omitted. Client credentials and grants stay outside the harness and model. +## Credential custody -The broker holds its ToolHive user access and refresh tokens. ToolHive holds, refreshes, and injects native provider credentials for the authorized connection. Neither Mecatl nor the model receives those native provider credentials. This boundary concerns protected tool access; it does not relocate other harness credentials, such as those used to call an LLM provider. +The following custody boundaries do not imply a selected persistence implementation or process-isolation boundary. -Agent-instance identity does not protect against compromise of the broker's signing authority or provider-credential storage. Separating issuance, authorization, and custody into modules does not create security boundaries against compromise of their shared process or host. +| Credential | Holder and use | Custody and renewal | +| --- | --- | --- | +| AS-1 access token | Client presents it to mecak8s; mecak8s passes it to the broker once at session open. | Not stored by the server side. | +| Client refresh token | TUI keyring or Studio sealed cookie. | Never leaves the client. | +| Workload credential | Platform-managed; mecak8s presents it to the issuer and broker. | Projected credential or configured SPIRE identity. Authenticates the workload, not a user or agent. | +| Agent-instance JWT-SVID | Harness holds `mainSVID` / `reviewerSVID` outside model context; broker forwards it to AS-1 as `actor_token`. | Agent-instance issuer keeps its signing key. Renewal requires the same registered identity and does not widen authority. | +| Broker session `A42` (state) | Broker owns the workload and host-session binding. | Recovered under workload and continuity checks. Possession of the reference grants nothing. | +| Broker refresh token `RT-A42` | Broker; presented only to AS-1. | Bound to `mecatl` and `A42`; rotates; hard expiry; revoked when the session closes. | +| Broker private key | Broker; signs client assertions for AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | +| ID-JAG | Broker, from issuance to redemption. | Single-use, short-lived. | +| AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, and gateway; per-call checks still apply. No refresh token. | +| Consent link (state) | AS-2, external mode. | Until revoked in the gateway UI or by Mecatl. | +| Upstream provider credential | Gateway credential store. | Keyed by (gateway, user, provider); provider-specific renewal stays at the gateway. | + +Keep credential values out of model context, tool arguments, results, transcripts, logs, environment variables, audit records, diagnostics, and errors. Hiding a credential from the model does not protect it from a compromised process that holds it. This protected MCP path does not relocate other harness credentials, such as those used to call an LLM provider. ## Credential lifetimes and renewal -An agent instance keeps its identity across runs, while its short-lived instance JWT-SVID can expire and be renewed. Renewing that credential establishes continued use of the same identity; the broker still authorizes each MCP tool call. - -A single run may need several delegated access tokens, either for different protected MCP endpoints or because an earlier token expires. Each token has its own audience, expiry, and access limits. Obtaining or refreshing one does not widen permission to call tools. +An agent instance keeps its identity across runs. These lifetimes are independent. -The broker presents its ToolHive refresh token to ToolHive AS with required client authentication to renew the user access token. ToolHive similarly presents an optional provider refresh token to the provider AS with required upstream client authentication. Each AS validates its refresh grant; custodians must safely store rotated replacements. Without a provider refresh token, renewal requires another supported path or enrollment. +| Item | Expiry or loss of authority | Replacement | +| --- | --- | --- | +| AS-1 access token | AS-1 policy; short. Expiry ends API authentication, not the broker session. | The client refreshes with its own refresh token. | +| Client refresh token | AS-1 policy and upstream sign-in status. | Sign in again. | +| Workload JWT | Expired credentials are rejected. | Platform rotation for the same workload. | +| `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the ceiling does not widen. | +| `RT-A42` | Hard expiry, session close, revocation, or reuse detection. Rotation does not extend the hard expiry. | A new session open with a fresh AS-1 access token. | +| ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | +| `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | +| Broker private key | Operator rotation. | New key published through `jwks_uri`. | +| Consent link | Revocation at AS-2. | The user links again. | +| Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | -The user's login token, the agent-instance credential, and downstream access tokens have separate lifetimes. Expiry of the login token used to start work does not, by itself, end that work. A provider connection can also remain available after that login token expires. Neither continued connectivity nor successful token refresh authorizes a later tool call without the broker's policy checks. +The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The value of `RT-A42`'s hard expiry is not selected; it is the authority horizon for runs without Alice present and should be known to the company IdP administrator. -Ending a run in the harness does not automatically invalidate tokens already issued. A recipient that validates a self-contained bearer token without checking current authorization state may accept it until expiry. The mechanism for communicating cancellation or withdrawal to the broker and downstream recipients remains to be specified. +Closing or revoking the session revokes `RT-A42` and invalidates the broker's `V` cache for that session. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. ## Pauses and restart -The broker's session reference and broker session incarnation are separate from the host session ID and [agent-instance incarnation](agent-definitions.md#definitions-and-executing-instances). An attachment binding identifies the exact logical attachment incarnation; separate recovery references and continuity checks govern stored-credential recovery. An existing stable broker session can recover a new process-local attachment and binding through those checks. Expired custody withdraws connected authority without necessarily destroying the broker session. Those references do not combine user, agent, definition, and authority into one record. +The broker's session reference and broker session incarnation are separate from the host session ID and [agent-instance incarnation](agent-definitions.md#definitions-and-executing-instances). An existing stable broker session can recover a new process-local attachment through continuity checks. Those references do not combine user, agent, definition, and authority into one record. -Workload-only session access checks the exact authenticated workload and configured deployment, rejecting caller-supplied owner assertions. A replacement Pod with the same workload identity can serve that partition; a different identity cannot assume access. +Workload-only session access checks the exact authenticated workload and configured deployment, and rejects workload-supplied owner assertions. A replacement Pod with the same workload identity can serve the existing partition; a different identity cannot assume access. -An approval wait or provider-enrollment pause leaves the current run unfinished. Before dispatching the pending MCP operation, the broker checks it against the applicable authorization policy. Connecting a provider account does not approve the pending operation. +An approval wait or provider-connection pause leaves the current run unfinished. Before dispatching the pending operation, the broker checks it against the applicable policy again. Connecting a provider account does not approve the pending operation. -After restart, recovering credentials is not enough to continue safely. The system must recover the unfinished execution context and establish the association between the user, acting agent, and provider connection. If that association cannot be established, the tool call must fail closed. The harness determines whether execution resumes; the broker authorizes subsequent MCP calls. +After restart, recovering credentials is not enough to continue safely. Recovery must restore `A42` and its workload and host binding, find `RT-A42`'s AS-1 record still valid, and reestablish the registered actors and unfinished execution. If `RT-A42` has expired or been revoked, tool calls fail closed until a new session open supplies fresh authority. The harness decides whether execution resumes; the broker authorizes subsequent calls. -Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. In v1, the broker rechecks the stored effective ceiling and existing connection restrictions; the harness retains its run/delegation restrictions. Broker evaluation of task-specific delegated bounds is deferred. Credential renewal does not widen recovered authority. +Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. In v1, the broker rechecks the stored effective ceiling; the harness retains its run and delegation restrictions. Credential renewal does not widen recovered authority. -An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an applicable idempotency mechanism. +An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an idempotency mechanism. ## Scheduled runs -A scheduled invocation executes without a live interactive user. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. - -Human login tokens are not retained for later invocations. Provider credentials remain in broker/ToolHive custody. A saved owner or an available provider connection does not, by itself, authorize an MCP operation. +A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -The harness starts and manages scheduled runs. The broker authorizes their MCP tool calls. The mechanism for establishing unattended authority is a separate design concern. +A scheduled run uses the broker refresh token of the session it runs in. That refresh token's hard expiry is the limit of unattended authority: after it, or after revocation, the run's tool calls fail closed until Alice signs in again. A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. What a scheduled run does when consent or a connection is missing, fail and notify or wait, remains open. ## Workload authentication @@ -399,27 +448,21 @@ For SPIRE authentication, audit evidence identifies the configured trust domain/ A workload credential alone cannot distinguish `code-reviewer` from `deployer` when both run in the same Mecatl service. A self-declared agent name does not establish that distinction for broker policy. -## Downstream token exchange +A future option is to authenticate the broker as OAuth client `mecatl` with its SPIFFE workload identity instead of a separate private key; ToolHive's authorization server already accepts SPIFFE client authentication. -To obtain delegated access to a protected MCP endpoint, the broker presents three distinct credentials to ToolHive's authorization server: its ToolHive user access token as `subject_token`, the agent-instance JWT-SVID as `actor_token`, and its own OAuth client authentication. +## AS-2 access-token use and actor history -The instance credential must be valid for presentation to ToolHive's authorization server. Acceptance by the broker alone does not establish that audience. +The broker redeems the ID-JAG as described in [step (8)](#8-as-2-access-token). AS-2 issues an access token naming its native user in `sub`, the acting agent in `act`, the OAuth client `mecatl`, and the vMCP resource in `aud`. The broker presents this token to vMCP; the SVID is not an AS-2 input. -ToolHive issues a delegated access token identifying the user in `sub`, the acting instance in `act.sub`, and the broker OAuth client in `client_id`. Its audience is the protected MCP endpoint, not the external provider such as GitHub. These identities do not by themselves authorize particular tool arguments; the broker still checks the requested MCP operation. +The ID-JAG `jti`, AS-2 token `(iss, jti)`, and operation ID are separate references. A token can be reused for multiple authorized calls, so its `jti` cannot replace a per-operation reference. Each recipient records only what it verifies or observes. -The protected MCP endpoint validates the delegated token and applies operation policy. ToolHive resolves the authorized provider connection and uses its native credential for the external call. Validated claims identify the user and current actor directly; permissions and account associations can still require authoritative records. - -The issuer-qualified `jti` supports token correlation but does not prevent replay or replace a per-operation identifier: several calls can reuse one token. The walkthrough's shared operation ID expresses desired correlation, not an established authenticated join between services. - -The token's single `act` object identifies the current actor. RFC 8693 nested `act` objects describe previous actors and are informational for access control. Creation ancestry and current execution delegation remain separate evidence, not actor history inferred from the SPIFFE path or copied into nested claims. +The outer `act` identifies the current actor ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)). The nested `act` records the instance that delegated the current task, a chain of prior actors in RFC 8693's sense; it does not record creation ancestry, which stays in the SPIFFE path inside Mecatl. Until the agent ceiling is also carried as scope or `authorization_details`, the gateway enforces user and connection, and the broker alone enforces the agent ceiling. ## Provider-connection selection -ToolHive resolves the provider connection from validated identity and authoritative connection records. +The vMCP gateway resolves the provider credential from the validated user and the route's provider, keyed by (gateway, native user, provider). -The model cannot select another user's credentials or redirect them by supplying a URL, header, audience, or credential selector. The broker and ToolHive resolve the target and credentials within the authorized connection. - -If the required identity, connection, or authorization evidence is missing, invalid, stale, or revoked, the call fails closed. It must not fall back to a service credential, another user, or a more privileged agent. A denied tool call dispatches no backend operation. +The model cannot select another user's credentials or redirect them by supplying a URL, header, audience, or credential selector. If required identity, link, connection, or authorization evidence is missing, invalid, stale, or revoked, the call fails closed. It does not fall back to a service credential, another user, or a more privileged agent. A denied tool call dispatches no backend operation. Missing credentials or consent surface through the existing `mcp_authorization_pending` flow; how an external gateway reports them to the broker as a structured outcome remains open. ## Instance identity and creation ancestry @@ -455,8 +498,6 @@ These IDs describe instance identity and creation ancestry. The initiating user ## Sender binding -The walkthrough's delegated token is a bearer without sender binding. Its signature protects the claims from alteration but does not prove which holder presented it. - -Workload-level DPoP is follow-on work to bind the broker-facing OAuth credential to a presenting worker's key and reduce replay of stolen access tokens. It does not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent-instance identity and tool-call authorization remain separate checks. +`RT-A42`, the ID-JAG, and `V` are all presented only by the broker. `RT-A42` and the ID-JAG are usable only together with the broker's client authentication, so they are sender-constrained through `mecatl`'s private key. `V` remains a bearer token presented to vMCP; its signature protects the claims but does not prove the holder. DPoP-binding `V` to a broker key would close that gap; ToolHive supports DPoP in neither the authorization server nor vMCP today. -Key custody, renewal, restart behavior, and replay handling belong in the implementation design. +Binding a credential to a worker's key would not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent-instance identity and tool-call authorization remain separate checks. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index c7636361a5..390325a7f8 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -1,11 +1,13 @@ # Implementation and verification -This companion records implementation and verification requirements for the [agent identity and authority design](README.md). Requirements here are not claims about current implementation status or known vulnerabilities. +This companion records implementation and verification requirements for the [agent identity and authority design](README.md). Requirements here are not claims about current implementation status or known vulnerabilities. The credential flow follows [credentials and lifecycle](credentials-and-lifecycle.md); its decisions are recorded in the [credential flow rework](credential-flow-rework.md). ## Session ownership and isolation Verify that session enumeration, session access, and event replay enforce the authenticated caller's ownership, including on shared storage. Include negative tests proving another user cannot obtain session titles, prompts, or archived conversation content. +Session ownership is keyed by the AS-1 `(iss, sub)` of the validated access token. Existing sessions owned by the company issuer need a migration; during the transition, mecak8s may match existing owners through the company identity in the AS-1 token's `sub_id`. The migration details remain open. Test that a `sub_id` match never grants access to a session owned by a different AS-1 subject once that session has migrated. + Verify that scheduled invocations preserve owner attribution without treating saved ownership as fresh user authentication or permission to use a provider connection. ## SPIFFE identifier construction @@ -14,23 +16,112 @@ Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `. Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs; a Mecatl cap and compatible name/depth bounds remain undecided. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. +## AS-1 grant and AS-2 user resolution + +The flow has four integration points: AS-1 sign-in, the session-open exchange, the per-call ID-JAG, and AS-2 redemption. The original company token never leaves AS-1: AS-1 is an ordinary OIDC client of the company IdP, and nothing downstream receives or exchanges the company token. + +**Client authentication.** The broker is one OAuth client, `mecatl`, registered at both AS-1 and AS-2. On every token-endpoint request it authenticates with `private_key_jwt`: a short-lived assertion with `iss` = `sub` = `mecatl`, `aud` = that server's issuer identifier, and a single-use `jti`. ID-JAG §4.4.1 requires the ID-JAG's `client_id` to match the client redeeming it at AS-2, so both servers must use the same client ID. A `client_secret` is not accepted. In external mode the gateway administrator registers the broker's `jwks_uri` at AS-2 once. + +**Sign-in.** mecak8s's protected-resource metadata names AS-1. The TUI and Studio sign in there with authorization code and PKCE and receive an AS-1 access token (`typ=at+jwt`, `aud` = the Mecatl API) and their own refresh token. mecak8s validates the access token under RFC 9068 and checks session ownership. An `id_token` is not accepted. + +**Session open.** At `OpenSession`, mecak8s passes the user's current AS-1 access token to the broker once, alongside workload authentication. It replaces the harness-reported `human {issuer, subject}`. The broker exchanges it at AS-1 with RFC 8693: `subject_token_type=urn:ietf:params:oauth:token-type:access_token`, `requested_token_type=urn:ietf:params:oauth:token-type:refresh_token`. AS-1 accepts the token only if: + +- AS-1 issued it, its `aud` is mecak8s, it is still active, and its `jti` has not been converted before; +- the presenter is the authenticated client `mecatl`; +- the request asks for no wider scope than the original grant. + +Accepting a token whose `aud` is mecak8s from a different client is an explicit, narrow AS-1 policy for client `mecatl`, not a general audience bypass. AS-1 issues a broker refresh token and creates its record: client `mecatl`, AS-1 `sub`, broker session reference, host session and incarnation, source token `jti`, issue time and hard expiry, and rotation chain. The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. In v1 the refresh token is not tied to the user's AS-1 login session. + +**Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: + +1. resolves the refresh token to its record and checks that it is bound to `mecatl`, that the session is open, and that the hard expiry has not passed; +2. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; +3. authorizes the combination of client, subject, actor, and target AS-2; +4. issues the ID-JAG. + +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and nested `act`. The outer `act.sub` is an opaque, stable `agent:` identifier for the acting instance. The inner `act` names the instance that delegated the current task. Every level uses an opaque `agent:` identifier, never a SPIFFE path or definition name, and omits `act.iss`. Depth is capped. Where AS-1 gets the delegating instance is open: `InvokeTool` does not carry that delegation context today. `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). + +**AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then maps the AS-1 subject to a native user: + +| Mode | Mapping | +| --- | --- | +| Embedded | Users log in to AS-2 through AS-1, so the AS-1 `(iss, sub)` maps directly to the native user. Consent is implicit. | +| External | AS-2 redeems only for an AS-1 `(iss, sub, client_id)` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | + +AS-2 never resolves a user through `sub_id` (§9.5), email, or display name. Unknown subjects fail closed. A native-user match does not authorize use of a provider connection. + +AS-2 issues `V`: `typ=at+jwt`, `sub` = native user, the ID-JAG's nested `act` unchanged, `client_id=mecatl`, `aud` = the vMCP resource, and `jti`. It issues no refresh token (§4.4.3); after expiry the broker obtains a new ID-JAG from the broker refresh token. If the refresh token has expired or been revoked, fail closed until a new session open supplies a fresh AS-1 access token. + +**vMCP and the upstream credential.** vMCP validates `V` under RFC 9068 and, if the request uses a `resource` parameter, RFC 8707. It binds its session to the user and the outer `act.sub`. It loads the upstream credential keyed by (gateway, native user, provider). A missing credential answers "needs authorization", which the broker returns as the existing `mcp_authorization_pending` outcome. Consent is checked at redemption, not here. + +**Open.** Whether the broker needs its own authorized-connection evidence before dispatch, beyond AS-2's mapping and vMCP's credential lookup, remains open. So does how an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome. No callback or private mapping-response fields are specified. + ## Credential verification -Operator configuration fixes accepted workload issuers, audiences and identities, issuance limits, and trusted public signing keys. Tokens must not select verification endpoints, CA trust, or server names. +Operator configuration fixes accepted workload and agent issuers, audiences, identities, and trusted signing keys. Token claims do not select verification endpoints, CA trust, or server names. For each call, the broker authenticates the workload and validates the agent-instance JWT-SVID against its configured trust bundle, registered subject, broker audience, expiry, and permitted presenter. JWT-SVID does not require `iss` or `jti`. + +Test client authentication at both servers: + +- AS-1 and AS-2 reject requests without a valid `private_key_jwt` assertion for `mecatl`, including a wrong `aud`, an expired assertion, a reused `jti`, and a key not registered for `mecatl`. +- A leaked AS-1 access token or broker refresh token presented without the broker's key is rejected. + +Test the session-open exchange: + +- AS-1 rejects access tokens it did not issue, tokens for another audience, expired or revoked tokens, a second conversion of the same `jti`, and requests for wider scope. +- AS-1 accepts a mecak8s-audience token only from client `mecatl`; another client presenting the same token is rejected. +- The refresh token record names the session; rotation keeps the hard expiry; reuse of a rotated token is detected; session close revokes it. + +Test ID-JAG issuance: -For each tool call, the broker authenticates the workload and validates the agent-instance JWT-SVID's signature against the configured agent trust-domain bundle, required `sub`/`aud`/`exp` claims, expected audience, expiry, and permitted presenter. JWT-SVID does not require `iss` or `jti`. The broker checks that the credential identifies the expected registered instance and agent-instance incarnation. Credential validation does not replace authorization of the requested operation. +- AS-1 rejects an expired, revoked, or foreign-client refresh token, and a refresh token whose session is closed or past its hard expiry. +- AS-1 rejects an untrusted or expired SVID, an SVID with an audience not accepted by both AS-1 and the broker, and an instance not registered to the refresh token's session. +- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and nested opaque `act` within the depth cap. A caller cannot change `sub` or `act` through request parameters. -At token exchange, ToolHive's authorization server validates the user token and the actor credential's trusted bundle association, signature, required claims, audience, expiry, and permitted presenter. The instance JWT-SVID needs an explicit ToolHive-AS audience; validity for presentation to the broker alone is insufficient. +Test AS-2 redemption: -The protected MCP endpoint validates the delegated token's issuer, audience, expiry, and bounded access, applies operation policy, and resolves only the authorized user/tenant/target's provider connection. +- AS-2 rejects a wrong `typ`, untrusted issuer, wrong `aud`, expired ID-JAG, reused `jti`, and `client_id` that does not match the authenticated client. +- In external mode, AS-2 rejects a subject with no consent link and a revoked link. A link for another AS-1 issuer or client does not match. +- AS-2 never resolves through `sub_id` or email; a matching `sub_id` without a mapping fails closed. +- `V` carries the native user, the ID-JAG's nested `act` unchanged, `client_id=mecatl`, and the vMCP audience, with no refresh token. -Whether the two presentation points use one multi-audience instance credential or separately issued credentials remains to be specified. The [walkthrough](credentials-and-lifecycle.md#interactive-execution) illustrates the former without selecting it as the deployment contract. +Test vMCP and the upstream credential: + +- vMCP rejects a `V` with the wrong issuer, audience, `typ`, or expiry. +- vMCP loads the credential for (gateway, native user, provider) of a delegated `V` that carries no `tsid`, and returns "needs authorization" when none exists. One user's `V` never loads another user's credential. +- Two agent instances acting for the same user get separate vMCP sessions. + +Verify that preliminary broker denial, AS-1 denial, AS-2 denial, and vMCP denial each prevent provider dispatch, and that a missing consent link or upstream credential surfaces as `mcp_authorization_pending`. Verify that AS-1 access tokens, broker refresh tokens, ID-JAGs, `V`, and client assertions never reach model or tool content, transcripts, logs, environment variables, or audit records, including errors and diagnostics. The server side never stores the AS-1 access token durably. An expired or revoked broker refresh token denies new ID-JAG issuance; it must not be replaced by a reported issuer/subject pair or a fabricated continuation. Verify issuance rejects a known definition without a trusted particular-instance registration, an unauthorized workload, mismatched ancestry, or a stale agent-instance incarnation. These are proposed checks under the [workload-authenticated mecak8s lifecycle registration](agent-definitions.md#registering-instances); the exact wire API, durable schema, and protection from same-process substitution remain unspecified. Verify [identity continuity](agent-definitions.md#identity-continuity-across-runs-and-restarts) preserves the instance identity, fixed definition selection, and original bounds. Test [attachment recovery](credentials-and-lifecycle.md#pauses-and-restart) with a replacement process in the same authenticated workload and deployment partition; reject a different workload or possession of a noncredential broker session reference alone. -Account-association checks must reject OAuth success without authorization for the company user to use the ToolHive subject and provider connection; the association mechanism remains unselected. +Test user resolution and connection entitlement separately: neither an OAuth success nor a native-user match grants access to a provider connection. + +## ToolHive work list + +AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive's. The rework checked these capabilities against ToolHive main `ce74cfa37` by reading, not running, the code. Sizes are the rework's estimates. + +| Capability | Today | Size | When | +| --- | --- | --- | --- | +| Exchange of a self-issued access token presented by another confidential client (the mecak8s-audience exception) | Mostly; the requested `aud` must be a subset of the subject token's `aud` | Small to medium | v1 | +| Refresh token in a token-exchange response, with the session-bound record | No; exchange issues access tokens only | Medium to large | v1 | +| Issue ID-JAGs (`typ=oauth-id-jag+jwt`, audience = another AS) | No; redemption and an outbound XAA client only | Large | v1 | +| Refresh token as `subject_token` | No | Large | v1 | +| `act` from the registered actor input, nested | Partly; `buildActClaim` nests from a client or self-issued actor token | Medium | v1 | +| ID-JAG redemption keeps nested `act` | Yes, copied unchanged | None; verify only | v1 | +| AS-2 maps the AS-1 subject to a native user after ID-JAG redemption, and stops marking the token as having no upstream session | No; emits `iss#sub` and marks no upstream session | Large | v1 | +| Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, enterprise decorator only | Medium | v1 | +| Consent link at AS-2's own login (external mode only) | No | Not sized | v1 | +| `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | v1 | +| Refresh token bound to the AS-1 login session (`sid`), so logout revokes it | No; only `tsid` exists | Large | Later | +| DPoP issuance and validation for `V` | No | Large | Later | +| Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | +| Cedar at the gateway reading `act` | No | Part of the narrowing work | Later | + +The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. + +Until the narrowing work lands, the broker alone enforces the agent ceiling; the gateway enforces user and connection. ## Definition provenance @@ -63,11 +154,11 @@ Cedar is the selected engine for the v1 effective MCP server/tool ceiling. Polic - Compute each instance's effective ceiling at registration as its definition ceiling intersected with its parent's effective ceiling, and store it with the incarnation. Property-test `effective(child) ⊆ definition(child)` and `effective(child) ⊆ effective(parent)`, and cross-check against the harness's delegated-authority intersection. Prove a `deployer` child of a read-only reviewer is denied merge. Reject registration under an unregistered, revoked, or other-session parent; an empty intersection registers an instance whose MCP calls all deny. Revoking an instance revokes its descendants. Renewal and restoration reload the stored set and never recompute or widen it. - Evaluate each call, including calls using cached credentials and retries after enrollment. Default deny; generation/loading failures, evaluation failures, and any evaluation diagnostic errors deny, even if the nominal Cedar result is allow. - Check the exact dispatched server/tool pair and recheck changed requests. Prove the reviewer read passes its ceiling while excluded merge fails; PR 42 and PR 43 reads on the same pair have the same ceiling result. Do not claim this proves PR-specific delegation. -- Preserve existing local, user/connection, ToolHive AS, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. +- Preserve existing local checks, AS-1 and AS-2 decisions, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. The gateway does not evaluate the agent ceiling in v1; Cedar reading `act` there is later work. - Verify audit provenance, definition and policy revision correlation, safe reasons and policy IDs, and distinct decision/dispatch/outcome evidence per operation and attempt. Diagnostic errors must yield an effective deny in the record. Redaction must exclude secrets and unrestricted payloads. Test unknown outcomes without automatic replay and durability across restart once storage, ordering, and failure handling are specified; no transactional ledger is selected here. ## Parking and recovery: implementation status Protected MCP calls can park while awaiting provider reauthorization and resume with account, connection, catalogue, and permission checks. Offline fixtures cover credential and enrolled-connection recovery after host and broker replacement. -Parked broker flows remain process-local. Resuming the same pending operation after broker replacement and renewing agent-instance JWT-SVIDs across runs remain unfinished integration work. The existing recovery path does not establish authenticated human ownership. Credential recovery does not recover external effects; unknown outcomes remain fenced against automatic replay. +Parked broker flows remain process-local. Resuming the same pending operation after broker replacement and renewing agent-instance JWT-SVIDs across runs remain unfinished integration work. The existing recovery path does not establish authenticated human ownership. After broker replacement, recovery must restore the broker session and its workload and host binding and find the broker refresh token's AS-1 record still valid; if the refresh token has expired or been revoked, tool calls fail closed until a new session open. Credential recovery does not recover external effects; unknown outcomes remain fenced against automatic replay. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 7c1b532b7f..30d036af7e 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status.* -The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. V1 evaluates only the acting instance's effective MCP server/tool ceiling: its definition ceiling narrowed by its parent's.** It supplements existing local permissions, user/connection checks, and ToolHive authorization; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. +The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. V1 evaluates only the acting instance's effective MCP server/tool ceiling: its definition ceiling narrowed by its parent's.** It supplements existing local permissions and the user and connection checks that AS-2 and vMCP enforce downstream; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. ## Definition-derived ceiling @@ -18,7 +18,7 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. In v1, mecak8s reports the user, run, and delegation in the workload-authenticated call request; the broker records them as reported, and none of them is a v1 Cedar input. Enforcing task-specific delegated bounds will require a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report: mecak8s validates her AS-1 access token and enforces session ownership, then passes the token to the broker once at `OpenSession`. The broker exchanges it at AS-1 for a broker refresh token bound to that session, and every later ID-JAG names the user from that refresh token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). In v1, user, run, and delegation are not Cedar inputs. Run and delegation context remain trusted harness reports. Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority @@ -30,6 +30,8 @@ For example, a read-only reviewer spawns a child from a `deployer` definition wh The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. +The gateway sees delegation only as identity, not as authority. AS-1 writes the acting instance as the outer `act` in the ID-JAG, and the instance that delegated the task as a nested `act`, each as an opaque `agent:` identifier; AS-2 copies the chain into `V`. vMCP binds its session to the outer `act.sub`; inner levels serve audit. Neither level carries the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). + ## Operations and resources V1 distinguishes registered MCP servers and tools, but does not evaluate resource or argument policies. Permission to call a tool is not evidence of authorization for a particular repository or PR. User, resource, argument, and dynamic delegation policies are deferred; v1 does not provide comprehensive enterprise user-policy enforcement. @@ -38,13 +40,13 @@ For example, a reviewer whose resolved ceiling includes `pull_request_read` on t Agent-instance identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. -## User reports and the authorized request +## User token and the authorized request -Mecak8s reports the initiating user from the verified session owner. The broker authenticates the Mecak8s workload and relies on its user report under an explicit trust relationship; it does not independently authenticate the human from that report. Model-generated arguments must not choose or override the reported user. +Mecatl validates Alice's AS-1 access token for its API, checks current session ownership, and passes the token to the broker once at `OpenSession`. The broker converts it at AS-1 into a session-bound broker refresh token; no company IdP token is forwarded to AS-1 or the broker. On a cache miss, the broker presents that refresh token as `subject_token` and the acting instance's SVID as `actor_token`, authenticating as OAuth client `mecatl` with `private_key_jwt`. AS-1 checks that the session is open and the instance is registered to it, then issues an ID-JAG naming Alice and the actor. The broker redeems the ID-JAG at AS-2 as the same client and receives `V`. Neither the AS-1 access token, the refresh token, nor the SVID reaches AS-2. Model-generated arguments cannot choose or override identity evidence. See [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization) and the steps after it for token types, fields, and validation. -The broker checks the actual MCP server/tool pair against the ceiling and retains existing checks on the resolved provider connection. The final request, including arguments, must remain bound to those checks and dispatch, but recording or binding arguments does not mean v1 Cedar authorizes their resource-specific meaning. +The broker checks the actual MCP server/tool pair against the effective ceiling before any exchange or dispatch. User and connection authorization are enforced downstream: AS-2 redeems the ID-JAG only for a mapped user (embedded) or a consent-linked user (external), and vMCP dispatches only with the user's stored credential, keyed by (gateway, native user, provider). Whether the broker also needs its own connection evidence before dispatch is open. The final request, including arguments, must remain bound to the checks and dispatch, but recording or binding arguments does not mean v1 Cedar authorizes their resource-specific meaning. -Request transformations must occur before the applicable checks, or the changed request must be checked again. Dispatch must use the checked request and provider connection. +Request transformations must occur before the applicable checks, or the changed request must be checked again. Dispatch must use the checked request. ## Two requests, two authorization decisions @@ -55,24 +57,22 @@ In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execut | Main run `R41`: `pull_request_list(repository=acme/payments)` | The main's effective ceiling, which is its definition ceiling, must permit the resolved server and list tool. Main has no implicit exemption. | | Reviewer run `R42` via `D42`: `pull_request_read(repository=acme/payments, number=42)` | The reviewer's effective ceiling, its definition ceiling intersected with the main's, must permit the resolved server and read tool. `D42` records the delegated task but is not a v1 Cedar policy input. | -Both requests also require valid credentials and execution-context checks, and must pass existing local, connection, and ToolHive checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. +Both requests also require valid credentials and execution-context checks, and must pass local checks and the downstream AS-2 and vMCP checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. -For both calls, connection access includes authorization for Alice to use the ToolHive subject and provider account behind the connection. The walkthrough assumes this association for `github-conn-42`; successful OAuth or matching account names cannot establish it. The [association mechanism](credentials-and-lifecycle.md#provider-connection-selection) remains unresolved. +For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. Whether the broker should hold its own connection evidence before dispatch, in addition to these downstream checks, is open. See [provider-connection selection](credentials-and-lifecycle.md#provider-connection-selection). ## Authorization, credentials, and dispatch -Local permissions and hooks apply before invocation. The broker then authenticates the caller and actor, checks execution context, and evaluates the final operation and connection. Credential readiness determines whether an eligible request can proceed, not whether it is authorized. +Local permissions and hooks apply before invocation. The broker then authenticates the workload and actor, checks session and execution context, evaluates the effective server/tool ceiling, and verifies the route. A failure stops the call before any exchange. If these checks pass, the broker uses a cached `V` or obtains one through AS-1 and AS-2, then dispatches to vMCP. | Outcome | Dispatch behavior | | --- | --- | -| Denial | Invalid credentials or context, or denied policy, dispatch no backend operation. | -| Enrollment required | Missing enrollment or required user reauthorization dispatches nothing. mecak8s pauses and presents the broker's enrollment link. After completion, the pending request retries through all checks; enrollment does not imply permission to execute. | -| Allow and credentials ready | The broker dispatches only the exact authorized request using the authorized connection. The allow covers this call, not later calls, spawning, or run execution. | - -Missing enrollment can leave authorization incomplete. Refreshable expiry is handled in credential custody where possible; otherwise the broker requires user reauthorization. Missing, invalid, stale, or revoked identity, connection, or authorization evidence must fail closed, without fallback to a service credential, another user, or a more privileged agent. +| Denial | Invalid credentials or context, a denied ceiling, an expired or revoked broker refresh token, or denied downstream policy stops the request before provider dispatch. | +| Authorization pending | AS-2 refuses to redeem because no consent link exists (external mode), or vMCP finds no stored provider credential. The broker returns the existing `mcp_authorization_pending` outcome; the user completes the link or connection, and the call retries through every check. How an external gateway reports these refusals to the broker as a structured outcome is open. Connecting does not approve the pending operation. | +| Allow | The broker's checks pass and the broker dispatches with `V`. vMCP validates `V`, applies its operation policy, and loads the user's stored provider credential; it dispatches only if it finds one. | -When needed, the broker obtains a current ToolHive user access token and exchanges it with the acting instance's SVID. ToolHive's authorization server independently validates the user and actor tokens and broker client, and authorizes the requested target and access bounds. The protected vMCP independently validates the delegated token, applies operation policy, and resolves the authorized connection before using ToolHive's native provider credential. Neither downstream check replaces the broker's exact-operation decision. Credentials stay in their designated custody, outside model history; see [credential roles and custody](credentials-and-lifecycle.md#credential-custody). +Missing, invalid, stale, or revoked identity, consent, connection, or authorization evidence fails closed, without fallback to a service credential, another user, or a more privileged agent. -Cached credentials are not cached authorization. Every subsequent call repeats the effective-ceiling evaluation and existing connection and downstream checks; any delegated-token reuse must stay within the execution context for which it is permitted. +When no valid cached `V` exists for the session, instance, and gateway, the broker authenticates to AS-1 as OAuth client `mecatl` with `private_key_jwt` and requests an ID-JAG by RFC 8693 token exchange, with the session's broker refresh token as `subject_token` and the actor SVID as `actor_token`. It redeems the ID-JAG at AS-2 with RFC 7523's JWT-bearer grant, authenticating again as `mecatl`. AS-2 resolves the native user and returns `V` naming that user, the acting agent in `act`, and client `mecatl`. AS-2 issues no refresh token; when `V` expires, the broker repeats the exchange. A cached `V` skips the exchange and redemption, never the broker's checks or vMCP's authorization. AS-2 and vMCP make their own grant, identity, operation, and connection decisions; a downstream allow does not replace the broker's ceiling decision, and a broker allow does not replace theirs. See [credential roles](credentials-and-lifecycle.md#credential-roles) and the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution) for the full sequence. An allow does not prove dispatch or provider success. The [audit contract](audit.md#decision-dispatch-and-observed-outcome) owns durable evidence and correlation requirements. An unknown outcome must not be automatically replayed merely because credentials were recovered. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 441534e31a..b03c34d7cb 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -1,224 +1,232 @@ # MCP gateway modes -*This chapter describes the intended architecture, not current implementation status.* +*This chapter describes the intended architecture, not current implementation status. Its decisions are recorded in the [credential flow rework](credential-flow-rework.md).* -Mecatl can reach protected MCP tools through a ToolHive vMCP gateway in two ways. In **embedded mode**, the vMCP and its authorization server run inside the Mecatl deployment. In **external mode**, Mecatl points at a gateway that someone else runs, such as an enterprise connector gateway. This chapter describes one protocol that works in both modes, so the broker's authorization and audit stay the same wherever the gateway runs. +Mecatl can reach protected MCP tools through a vMCP gateway in two ways. In **embedded mode**, the vMCP and its authorization server run inside the Mecatl deployment. In **external mode**, Mecatl points at an external gateway. Both modes use the same protocol, roles, and token formats; they differ in how AS-2 resolves and authorizes the user. Co-location does not create an isolation boundary. + +This chapter covers the protocol between AS-1 and AS-2 and how the two modes differ. The full walkthrough, from Alice's sign-in through session open to the vMCP call, is in [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution). ## Who holds what | | Embedded mode | External mode | | --- | --- | --- | | Broker authorization and audit | Mecatl | Mecatl | -| Agent-instance JWT-SVIDs | Mecatl (broker issuer) | Mecatl (broker issuer) | -| Grant issuer (AS-1) | Mecatl, inside the broker | Mecatl, inside the broker | +| Agent-instance identities | Mecatl (separate agent issuer) | Mecatl (separate agent issuer) | +| Login and ID-JAG issuer (AS-1) | Mecatl, inside the broker | Mecatl, inside the broker | | Gateway authorization server (AS-2) | Mecatl, separate issuer and key | Gateway operator | -| Provider credentials (GitHub, Google) | Mecatl's AS-2 storage | Gateway operator | -| Trust in AS-1 | Local configuration | Gateway administrator's configuration | +| Provider credentials | Mecatl's gateway store, keyed by (gateway, native user, provider) | Gateway operator, keyed the same way | +| Trust in AS-1 | Local configuration; AS-1 is the SSO IdP AS-2 trusts | Gateway administrator registers AS-1 as an assertion issuer only | +| User consent | Implicit | One-time consent link at AS-2's own login | ## Two authorization servers -The vMCP accepts only access tokens issued by its own authorization server. It finds the user's provider credentials through that token, so whichever server holds Alice's GitHub token must also issue the token the vMCP accepts. That server is **AS-2**. +**AS-1** is Mecatl's login authorization server, running inside the broker. Alice signs in through AS-1, which redirects her to the company IdP as an ordinary OIDC client. Her client receives an AS-1 access token for the Mecatl API. At `OpenSession`, mecak8s passes that token to the broker once. The broker exchanges it at AS-1 with RFC 8693 for a session-bound broker refresh token, `RT-A42`. The [session-open step](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token) describes that exchange and the AS-1 record behind `RT-A42`. + +**AS-2** is the gateway's authorization server. The vMCP accepts only access tokens AS-2 issued. On each MCP request, the vMCP validates the token and loads the user's provider credential. + +Between them, per call and on a cache miss, the broker runs two steps: + +1. It exchanges `RT-A42` at AS-1 (RFC 8693), with the agent-instance JWT-SVID as `actor_token`, and receives an **ID-JAG** ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) addressed to AS-2. +2. It redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant and receives the AS-2 access token `V`. -AS-2 cannot authenticate Mecatl's users or agents by itself: it does not know which agent instance is acting or which session it belongs to. That knowledge lives in the broker. So the broker runs a second authorization server, **AS-1**, whose only job is to vouch for "this user, with this agent acting" in a form AS-2 can verify. +The broker authenticates at both servers as one OAuth client, `mecatl`, with `private_key_jwt`. ID-JAG §4.4.1 requires the ID-JAG's `client_id` to match the client that redeems it, so one client ID is used at both. `mecatl` is the broker's client registration, not an identity of the acting agent. The [broker client authentication step](credentials-and-lifecycle.md#5-broker-client-authentication) shows the client assertion. -The two servers do not share sessions or storage. AS-1 signs a short-lived grant addressed to AS-2, and AS-2 redeems it. This is [OAuth Identity and Authorization Chaining Across Domains](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/) (`draft-ietf-oauth-identity-chaining-17`): a token exchange at the first server produces a JWT authorization grant, and the client presents that grant to the second server with the RFC 7523 JWT-bearer grant. +AS-2 receives neither Alice's AS-1 access token, `RT-A42`, nor the SVID. It receives only the ID-JAG. + +AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode: ID-JAG §9.3 forbids an issuer from redeeming an ID-JAG it issued. The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across trust domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. ## Following one call -Alice, identified by company issuer `https://login.example.com` and subject `alice-204`, asks Mecatl to read PR 42 in `acme/payments`. The broker has already authorized the reviewer's `pull_request_read` call (see [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization)). The gateway in this example is external, at `https://gateway.example.com`. +Alice, AS-1 subject `u-alice-91`, owns harness session `hostS42`. Her session is already open: the broker holds `RT-A42` for broker session `A42`. Her reviewer agent instance asks to read PR 42 in `acme/payments`. The broker checks the session, workload, actor, effective ceiling, and route. With no cached `V` for `(A42, reviewer, gateway)`, it exchanges `RT-A42` and the reviewer SVID at AS-1 for an ID-JAG, redeems the ID-JAG at AS-2 for `V`, and calls the vMCP. The gateway in this example is external, at `https://gateway.example.com`. ```mermaid sequenceDiagram - participant B as Broker - participant A1 as AS-1 (broker) - participant A2 as AS-2 (gateway) + participant B as Broker (client mecatl) + participant A1 as AS-1 + participant A2 as AS-2 gateway participant V as vMCP participant P as GitHub - B->>A1: (1) Token exchange: subject evidence for Alice + reviewer JWT-SVID - A1->>A1: Check the reviewer belongs to a run Alice owns - A1-->>B: (2) JWT authorization grant, aud = AS-2 - B->>A2: (3) JWT-bearer grant, authenticated as client mecatl - A2->>A2: Check issuer, audience, jti, client, subject, actor - A2-->>B: (4) Access token: Alice, reviewer acting, aud = vMCP - B->>V: (5) pull_request_read + access token - V->>V: Resolve Alice's GitHub credential - V->>P: Read PR 42 + Note over B: Holds RT-A42 from session open + B->>B: Broker checks
Session, workload, actor, effective ceiling and route + Note over B,P: Any denial before provider dispatch stops the request + opt No cached V for (A42, reviewer, gateway) + B->>A1: (1) RFC 8693 + private_key_jwt
subject_token = RT-A42, actor_token = reviewer SVID
requested_token_type = id-jag, audience = AS-2 + A1->>A1: Resolve RT-A42 record: session open, not expired
Validate SVID, reviewer registered to A42
Build nested act + A1-->>B: (2) ID-JAG (sub u-alice-91, act, client_id mecatl) + B->>A2: (3) RFC 7523 assertion = ID-JAG + private_key_jwt + A2->>A2: Check typ, aud, client_id, jti
Resolve native user
External mode: require consent link + A2-->>B: (4) V (sub usr-alice-7c1d, act, aud vMCP) + end + B->>V: (5) tools/call pull_request_read + Bearer V + V->>V: Validate V
Load credential (gateway, usr-alice-7c1d, github) + V->>P: Read PR 42 with Alice's GitHub token P-->>V: Result V-->>B: Result ``` -Steps (1) to (4) run only when the broker has no valid cached access token for this user, instance, and vMCP. +A cache hit skips steps (1) through (4), not the broker checks or the vMCP's own validation and credential lookup. + +The broker checks run before any exchange. They establish that the session is open and bound to the authenticated workload, that the SVID names an instance registered to `A42`, and that the instance's effective ceiling allows this server and tool. They do not establish that AS-2 can resolve Alice or that a provider connection exists. AS-2 redeems only for a mapped user, and in external mode only for a linked one; the vMCP dispatches only with a stored credential for that user. Whether the broker also needs its own connection evidence before dispatch remains open. No denial at the broker, AS-1, AS-2, or vMCP permits a provider call. -### (1) Token exchange at AS-1 +### (1) RFC 8693 token exchange at AS-1 -The broker asks AS-1 for a grant addressed to AS-2. The decoded form parameters: +The broker asks AS-1 for an ID-JAG. The subject token is the broker refresh token from session open, which ID-JAG §4.3 allows as `subject_token`. The actor token is the registered reviewer JWT-SVID. Neither token authenticates the client; the `private_key_jwt` assertion does. ```text +POST https://broker.example.com/as/oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange -requested_token_type=urn:ietf:params:oauth:token-type:jwt -audience=https://gateway.example.com -subject_token= -subject_token_type= -actor_token= +subject_token=RT-A42 +subject_token_type=urn:ietf:params:oauth:token-type:refresh_token +actor_token= actor_token_type=urn:ietf:params:oauth:token-type:jwt +requested_token_type=urn:ietf:params:oauth:token-type:id-jag +audience=https://gateway.example.com +client_id=mecatl +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= ``` -`audience` names AS-2, not the vMCP: the chaining draft (§2.3.1) uses it to identify the second authorization server. The vMCP resource appears in step (3). +AS-1 checks: -Before issuing, AS-1 checks more than token validity. It confirms that the reviewer instance belongs to a run whose owner is the subject. A valid user token paired with an unrelated agent's credential is rejected (`actor_unauthorized` in `draft-mcguinness-oauth-actor-profile-01` §6.6.2). +- **The client.** The assertion verifies against `mecatl`'s registered key. +- **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user. +- **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. The SVID's audience is a configured shared broker audience accepted by both AS-1's actor validator and the broker's invocation validator. JWT-SVID does not require `iss` or `jti`. +- **The target.** `gateway.example.com` is an allowed AS-2 for this client. + +AS-1 authorizes the client, subject, actor, and target as one combination, not any set of valid signatures. Token validation alone does not prove agent registration, delegation, or run context; those depend on trusted lifecycle and request context. + +AS-1 returns the ID-JAG in the standard token-exchange response ([RFC 8693 §2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2.2.1)): + +```json +{ + "access_token": "", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A", + "expires_in": 120 +} +``` -The broker issued the reviewer's JWT-SVID and presents it to its own AS-1. The actor in the grant therefore rests on the broker's records, not on independent evidence. +Here `access_token` carries the grant for AS-2; it is not an access token the vMCP accepts. The broker holds it only until redemption. AS-1 never issues an access token for AS-2's audience. ### (2) The grant ```json +{"typ": "oauth-id-jag+jwt", "alg": "ES256", "kid": "as1-key-1"} { "iss": "https://broker.example.com/as", - "sub": "alice-204", - "sub_id": { - "format": "iss_sub", - "iss": "https://login.example.com", - "sub": "alice-204" - }, + "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": { - "iss": "spiffe://agents.example.com", - "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m" - }, - "jti": "grant-pr42-001", - "iat": 1800000000, - "exp": 1800000120 + "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "jti": "jag-pr42-001", + "iat": 1800000001, + "exp": 1800000121 } ``` -The grant is a plain JWT, as in the chaining draft's own example. Neither the draft nor RFC 7523 defines a `typ` value for it. It cannot be confused with an access token: its issuer is AS-1 and its audience is AS-2, so the vMCP rejects it, and the vMCP accepts only `at+jwt` tokens (RFC 9068). +This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. -- `sub_id` (RFC 9493) states the company identity as an issuer and subject pair. AS-2 resolves the user from it, never from `sub` alone. RFC 9493 allows these members to differ from the grant's own `iss` and `sub`, as long as both name the same user. -- `client_id` is Mecatl's client identifier at AS-2, which may differ from its identifier at the company IdP. The claim and its check come from ID-JAG (§3.1, §4.4.1): AS-2 must match it against the authenticated client. -- `act` (RFC 8693) names the reviewer instance. `act.iss` is the SPIFFE trust domain, because JWT-SVIDs carry no `iss` (actor-profile §6.4). -- The grant lives for two minutes and can be redeemed once. +`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). Each level carries an opaque, stable `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is omitted; the ID-JAG's own `iss` is AS-1. The outer level is the acting instance, and only it identifies the actor for vMCP session binding and gateway policy. The inner level is the instance that delegated this task; it serves audit. The chain records task delegation, not creation ancestry, and its depth is capped. Where AS-1 obtains the delegating instance remains open: `InvokeTool` does not carry delegation context today. + +`aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). + +The ID-JAG's AS-1 issuer, AS-2 audience, and `typ` distinguish it from `V`. The vMCP validates its configured AS-2 issuer and resource audience and rejects the ID-JAG. ### (3) JWT-bearer grant at AS-2 +The broker authenticates to AS-2 as `mecatl` with `private_key_jwt` and redeems the ID-JAG with the [RFC 7523 §2.1 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1): + ```text grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer -assertion= +assertion= resource=https://gateway.example.com/mcp +client_id=mecatl +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= ``` -The broker authenticates to AS-2 as client `mecatl`, for example with `private_key_jwt`. AS-2 checks: +The `resource` value identifies the vMCP using [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html). AS-2 checks, following ID-JAG §4.4.1: `typ` is `oauth-id-jag+jwt`; the signature verifies against the trusted AS-1 issuer's keys; `aud` is AS-2's own issuer; `client_id` matches the authenticated client `mecatl`; `jti` is unused; and the lifetime is current. It also applies its actor and resource policy. [RFC 7523 §3](https://www.rfc-editor.org/rfc/rfc7523.html#section-3) defines the base JWT-bearer validation. + +AS-2 then resolves the native user. This is where the two modes differ: -- the grant's issuer is a trusted AS-1, with its key pinned or fetched from AS-1's configured `jwks_uri` -- `aud` is AS-2, and `exp - iat` is at most 300 seconds -- `jti` has not been seen before, using a replay store shared by all AS-2 replicas -- `client_id` matches the authenticated client -- `sub_id` resolves to an existing, active user, through the same directory lookup that the company SSO login uses -- `act.iss` and `act.sub` match the actor allowlist configured for AS-1 +| Mode | How AS-2 resolves and authorizes the user | +| --- | --- | +| Embedded | ID-JAG as specified. Users log in to AS-2 through AS-1, so AS-1 is the SSO IdP AS-2 trusts (§4.1) and the subject is the shared AS-1 `sub` (§5). `u-alice-91` maps directly to native user `usr-alice-7c1d`. Consent is implicit: AS-1, AS-2, and the credential store share one trust domain. | +| External | Identity chaining across trust domains ([chaining §§2.1, 2.5](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html#section-2.1)) using the ID-JAG token profile. AS-2's users log in through the gateway's own IdP, so the §4.1 precondition, that the resource AS already trusts the IdP for SSO, does not hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer. It redeems only when the ID-JAG's `(iss, sub)` and `client_id` match a consent link, which supplies the native user (chaining §2.2, deployment-specific resolution). No just-in-time provisioning. | + +The external consent link records (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, grant time, and scope or providers). Alice creates it once per gateway, through AS-2's own login, by approving "Allow Mecatl" on a consent screen AS-2 generates. It is keyed on the AS-1 `sub` in the ID-JAG, never on a `sub_id` (§9.5). It stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Admin pre-consent without an independent witness of the user is rejected for the same reason. The link's creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). The gateway UI can revoke the link. + +An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked user as a side effect of lookup. Resolving the user does not establish that a provider connection exists; the vMCP's credential lookup remains a separate check. ### (4) Access token ```json +{"typ": "at+jwt", "alg": "RS256", "kid": "gateway-example-key-1"} { - "typ": "at+jwt", "iss": "https://gateway.example.com", "sub": "usr-alice-7c1d", "aud": "https://gateway.example.com/mcp", "client_id": "mecatl", - "act": { - "iss": "spiffe://agents.example.com", - "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m" - }, + "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, "iat": 1800000005, - "exp": 1800000120 + "exp": 1800000120, + "jti": "access-pr42-001" } ``` -`typ` is shown in the payload for readability; it belongs in the JOSE header. `sub` is AS-2's own identifier for Alice, the same one her enrollment produced. AS-2 issues no refresh token (chaining §5.4), and the access token expires no later than the grant. The broker caches it per user, instance, and vMCP. Where the vMCP verifies DPoP (RFC 9449), the token is bound to a broker key. +This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT access token omits its signature. AS-2 issues its own `iss`, puts its native user ID in `sub`, and copies the nested `act` unchanged. Its lifetime does not exceed the ID-JAG's. AS-2 issues no refresh token (ID-JAG §4.4.3); when `V` expires, the broker obtains a new ID-JAG from `RT-A42`. The broker caches `V` per session, instance, and gateway, and runs its checks before every use of a cached token. ### (5) Dispatch -The broker sends `pull_request_read` with the access token. The vMCP validates it, looks up Alice's GitHub credential by user, and injects it into the backend request. GitHub sees Alice's account. The access token records which agent acted, and the broker's audit record holds the rest of the story. - -## Subject evidence - -AS-2 trusts AS-1's word about which user is acting. The evidence AS-1 requires determines what the gateway administrator is trusting. - -**Stepping stone (A2).** AS-1 trusts the session owner that mecak8s verified, presented as a JWT that the mecak8s workload signs. No company token is involved. A compromised Mecatl could act as any user that AS-2's policy allows. This phase exists only to bring the chain up before A1, and requires the AS-2 checks below. - -**Target (A1).** The broker holds Alice's company refresh token, uses it only at the company IdP, and presents one of the following to AS-1: - -- an access token from the company IdP with `aud` set to AS-1, where the IdP can issue one (preferred) -- a fresh ID token whose `aud` is Mecatl's client identifier (fallback) - -AS-1 validates it against the company IdP. A user deactivated at the IdP can no longer refresh, so AS-1 stops issuing grants for them. The broker never sends a company access token addressed to another audience, or the refresh token itself, to AS-1. - -## What the gateway administrator configures - -In external mode, the administrator decides how much to trust AS-1: - -- AS-1's issuer and `jwks_uri`, or pinned keys -- the Mecatl client registration and its authentication method -- the company issuers and users AS-1 may assert, and the actor prefix it may assert, such as `spiffe://agents.example.com/mecatl/*` -- the vMCP resources Mecatl may request -- rate limits per user and per actor -- a switch to revoke trust in AS-1 +The broker sends the checked `pull_request_read` request with `V` to the vMCP. The vMCP validates `V` against AS-2's keys, `typ`, audience, and expiry, applies its own operation policy, and loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. GitHub sees Alice's GitHub token, not the ID-JAG, the SVID, or `V`. If no credential is stored, the vMCP answers "needs authorization" and the broker returns the existing `mcp_authorization_pending` outcome. The design does not claim that downstream credentials establish a backend-to-SaaS audit trail. -Every redemption is audited at AS-2 with `jti`, the resolved user, `act`, `client_id`, `resource`, and the decision. +Open-source ToolHive keys stored credentials by `tsid`, a per-interactive-login session ID, and `V` carries none. Standalone deployments need a per-user lookup; the enterprise connector gateway already stores credentials per user. -## Why identity chaining and not ID-JAG +AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. Their audit references distinguish ID-JAG `jti` `jag-pr42-001` from access-token `jti` `access-pr42-001`, and neither replaces operation `op-pr42-001`. Audit requirements and evidence limits are described in [Audit](audit.md). -The [Identity Assertion JWT Authorization Grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/) (ID-JAG, "Cross App Access") has the same message shape, but `-04` requires three things AS-1 cannot provide: +## Gateway administrator configuration -- the grant's issuer must be the IdP that AS-2 already trusts for SSO (§4.1) -- the `subject_token` must have been issued by that IdP (§4.3) -- `sub` must be in the issuer's namespace (§3.1) +In external mode, the gateway administrator configures AS-2 with: -AS-2 logs users in through the company IdP, not through AS-1. The chaining draft has none of these constraints and allows the first server to add or transcribe claims (§2.3.2, §2.5), which covers `act` and `sub_id`. +- AS-1's issuer and signing keys, trusted as an ID-JAG issuer only, not as an SSO IdP; +- the `mecatl` OAuth client, with the broker's `jwks_uri` registered once for `private_key_jwt`; +- the consent-link flow and its revocation in the gateway UI; +- permitted actors and allowed vMCP resources; +- ID-JAG lifetime and `jti` replay handling. -A company IdP that issues ID-JAGs can later serve as a second, parallel path for calls that only need the user's identity. Okta's documented implementation does not name the acting agent and limits ID-JAG volume per user, so it does not replace AS-1. +Mecatl's operator configures AS-1 with the `mecatl` client, the accepted agent trust domain and audience, and the allowed AS-2 targets. Embedded mode uses the same roles and checks with local configuration, and AS-2 trusts AS-1 as its SSO IdP. Co-location does not create isolation. ## Sessions at the vMCP Each agent instance has its own vMCP session (`Mcp-Session-Id`). The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. - Sessions are never shared between instances. -- The vMCP binds each session to the user and the acting instance (`iss`, `sub`, `act.iss`, `act.sub`). A token naming another instance cannot use the session. +- The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and outer `act.sub`. A token naming another instance in its outer `act` cannot use the session. Inner `act` levels do not affect the binding. - The run is not part of the session or token. The run ID travels in trace headers. - The broker stores each instance's session ID. After a restart it reattaches. When the vMCP reports the session gone (idle timeout or eviction), the broker creates a new one. A wedged session can be reset explicitly. - An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. ## Embedded mode -Embedded mode runs AS-1, AS-2, and the vMCP in one deployment and uses the same messages over loopback. The two authorization servers keep separate issuer URLs, signing keys, and storage namespaces, and AS-2 trusts AS-1 through local configuration. +Embedded mode runs AS-1, AS-2, and the vMCP in one deployment. It uses the same ID-JAG exchange at AS-1 and RFC 7523 redemption at AS-2, with the same token formats, validation, and client authentication as external mode. Only AS-2's subject resolution differs: AS-1 is AS-2's SSO IdP, and consent is implicit. Implementations can co-locate protocol handlers or reuse them as libraries; the design does not require a separate network service or a private issuance interface. The two authorization servers keep separate issuer URLs, signing keys, and storage namespaces. -This keeps one code path for both modes. It does not add isolation: anyone who compromises the process holds both keys. Distinct issuers and audiences are what keep a grant from being used as an access token. +This keeps one protocol path for both modes. It does not add isolation: anyone who compromises the process holds both keys. Distinct issuers, audiences, and `typ` values keep the ID-JAG from being used as an AS-2 access token. ## Outside this chapter -- **Missing provider connections.** In external mode, the gateway is the only party that knows Alice has not connected GitHub. How it reports that, and how the broker shows Alice a connection link, is a separate design. -- **Revocation** beyond short lifetimes and IdP refresh failure. -- **Gateway authorization on `act`**, such as Cedar policies that consider the acting agent. +- **Revocation** beyond short ID-JAG and access-token lifetimes, consent-link revocation, and `RT-A42`'s hard expiry. +- **Structured gateway outcomes.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome remains open. So does whether the "Allow Mecatl" step and the provider's consent screen can be one redirect chain. +- **Scheduled runs** when consent or a connection is missing: whether they fail and notify or wait remains open. +- **Broker connection evidence.** Whether the broker needs its own evidence of a provider connection before dispatch remains open. +- **Gateway authorization on `act`**, such as Cedar policies that consider the acting agent. Until the agent ceiling is carried in the ID-JAG and `V` as scope or `authorization_details` (RFC 9396), the broker alone enforces it; the gateway enforces user and connection. -## Required ToolHive changes +## Integration requirements and evidence limits -- AS-1: token exchange that issues a JWT grant for another authorization server, accepting a SPIFFE JWT-SVID as `actor_token`. -- AS-2: a per-issuer policy on the JWT-bearer grant path for chaining grants, with real client authentication, `client_id` binding, single-use `jti`, and `act` preserved and validated. -- AS-2: resolve grant subjects from `sub_id` through the same user directory as SSO login, replacing the `issuer#subject` form and the exact `subject_bindings` list. -- vMCP: find provider credentials by user, gated by grant issuer, client, resource, and provider, not by `sub` alone. Tokens that carry `act` must still be able to receive provider credentials. -- vMCP: include the acting instance in the session binding. -- Connector gateway: expose trusted-issuer and inbound-grant configuration. -- Two authorization servers in one process: separate storage prefixes and persistent keys. +AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state, hard expiry, and the actor's registration to the session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG with nested opaque `act`. -## Pending changes to credentials and lifecycle +AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves the nested `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. -The [credentials and lifecycle](credentials-and-lifecycle.md) walkthrough still describes a single ToolHive authorization server, with the broker holding a ToolHive user grant. It needs these changes to match this chapter: +The vMCP must validate `V`, bind its session to the user and outer `act.sub`, and load provider credentials keyed by (gateway, native user, provider). Do not add a callback or response fields that expose AS-2's private native mappings to the broker. The design does not claim a backend-to-SaaS audit trail. -- **Instance credential audience.** The intro and step (4) give the JWT-SVID two audiences, the broker and the ToolHive token endpoint. It needs only the broker's, because AS-1 runs inside the broker. -- **Sequence diagram and legend.** The single exchange at ToolHive AS becomes the two hops in [following one call](#following-one-call): a token exchange at AS-1, then a JWT-bearer grant at AS-2. Both run only when no cached access token is valid. -- **Step (5), enrollment.** Alice enrolls at AS-2 through company SSO. AS-2 links the grant to her by resolving `sub_id` through the same directory lookup, which settles the association that step (5) leaves open. The broker no longer holds a ToolHive user grant. -- **Step (7).** It becomes a short version of the two hops and the access token, which now carries `act.iss` and `client_id=mecatl`. Grant details link here rather than being repeated. -- **Step (8).** Only the example values for the OAuth client and token issuer change. -- **Credential roles, custody, and lifetimes.** The ToolHive user grant role and its refresh path go away. They are replaced by subject evidence (see [subject evidence](#subject-evidence)) and the chaining grant. AS-2 issues no refresh token; the broker requests a new grant instead. -- **Retained login tokens (open decision).** Step (1) and scheduled runs say human login tokens are not retained. Under A1, the broker keeps the company refresh token in custody and uses it only at the company IdP. That principle needs rewording, or A1 needs revisiting. +Embedded and external deployments implement the same protocol roles and token formats. Embedded wiring uses local configuration, separate issuer keys and storage namespaces, and implicit consent; external wiring uses gateway-administrator trust and the consent link. Co-location is not isolation. diff --git a/go.work.sum b/go.work.sum index 4148022064..3f0440b13b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -327,6 +327,7 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= @@ -399,6 +400,7 @@ github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.36/go.mod h1:bgDHl7/zlHq1A4Ux github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.38/go.mod h1:fVuMvXXuUvjoc32pcntZUJkxMs3ABu2uuH6mzoHxYTw= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.7.3 h1:20BeplqqLCEhNCvWSxEya42pYMWzTvlgTP89PdrENEM= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.7.3/go.mod h1:V/qpLvyzbAHd67zhBa+QCIv3j2XPhz4ePjHwH0UqnVo= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.7.4/go.mod h1:zhE73dAXSqWCB+He1U5KbCeVbZ7UQoulTU1NR1KfuDk= github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM= github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= @@ -543,6 +545,7 @@ github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daT github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= @@ -733,6 +736,7 @@ github.com/gobuffalo/tags/v3 v3.1.4 h1:X/ydLLPhgXV4h04Hp2xlbI2oc5MDaa7eub6zw8oHj github.com/gobuffalo/tags/v3 v3.1.4/go.mod h1:ArRNo3ErlHO8BtdA0REaZxijuWnWzF6PUXngmMXd2I0= github.com/gobuffalo/validate/v3 v3.3.3 h1:o7wkIGSvZBYBd6ChQoLxkz2y1pfmhbI4jNJYh6PuNJ4= github.com/gobuffalo/validate/v3 v3.3.3/go.mod h1:YC7FsbJ/9hW/VjQdmXPvFqvRis4vrRYFxr69WiNZw6g= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -1163,8 +1167,10 @@ github.com/stacklok/toolhive-catalog v0.20260908.0 h1:WRC+cV63KCEaMu8qTiTeCAPNCd github.com/stacklok/toolhive-catalog v0.20260908.0/go.mod h1:4n/zNE768KzRjQA0jhkj5b+b4b1TXjHBhRptk87qRS8= github.com/stacklok/toolhive-core/redisconn/aws v0.0.3 h1:xEBYyiobMa/2ELNDMsgAXu09Z7qjeRJo2RgEunhLvKE= github.com/stacklok/toolhive-core/redisconn/aws v0.0.3/go.mod h1:Db5yTBULVGF5Q1mXQhxk87tBMyB8o4uJ9Vs2Kx4PGUk= +github.com/stacklok/toolhive-core/redisconn/aws v0.0.5/go.mod h1:QPbPJuXUDZpUvPBPMm6yvdqbGjaAI7oJFOm7zcr+BPk= github.com/stacklok/toolhive-core/redisconn/azure v0.0.2 h1:Y4ND9e+6L7Oxt5XKkkoOoQ4lKQOeW5o7O9FHIENXCJ8= github.com/stacklok/toolhive-core/redisconn/azure v0.0.2/go.mod h1:e2l9sodOGRgEPy9zc9P6JE4N4zIYl8LlKitbk7hPN1c= +github.com/stacklok/toolhive-core/redisconn/azure v0.0.3/go.mod h1:quKD5w47rTkCqrLerwL7eP3UI776rpJEZKWzK/Gn4ZI= github.com/stacklok/toolhive-core/redisconn/gcp v0.0.2 h1:mHGe5yU3rWeFiF1xh4WOFMGi/ElEvpJoEy2vE8FFmr0= github.com/stacklok/toolhive-core/redisconn/gcp v0.0.2/go.mod h1:zJwqPbX4i2kAGmv/Jh8ttsviJg6W2oP7l/q6BVa72hk= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= @@ -1604,6 +1610,7 @@ k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= k8s.io/code-generator v0.37.0 h1:AC915wukzlVHHODAQYxvQ25WKibPh95faJ2kxf8dzuo= k8s.io/code-generator v0.37.0/go.mod h1:qg7E/uDlyvevVRL1V8+h2z9UWmi/8gxaRka/lVXUBdk= +k8s.io/code-generator v0.37.1/go.mod h1:RJepz1uW2WnHEwPDoCGzI3p4V23C7WuUdtqjueDl1uU= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= @@ -1614,6 +1621,7 @@ k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kms v0.37.0 h1:WDW3pSc5yFB1Od4YXXL3i5oK+vXI2v++LAV82+Ks1Z0= k8s.io/kms v0.37.0/go.mod h1:0U6zfwLkfJr9O/jLgz2GARnKLh2uIhkq2EeaU66J5ow= +k8s.io/kms v0.37.1/go.mod h1:0U6zfwLkfJr9O/jLgz2GARnKLh2uIhkq2EeaU66J5ow= mvdan.cc/editorconfig v0.3.0 h1:D1D2wLYEYGpawWT5SpM5pRivgEgXjtEXwC9MWhEY0gQ= mvdan.cc/editorconfig v0.3.0/go.mod h1:NcJHuDtNOTEJ6251indKiWuzK6+VcrMuLzGMLKBFupQ= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= From e2e684a6a859150d1cae6c0c074d1c31123e7cc0 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 12:43:26 +0200 Subject: [PATCH 14/50] docs: rework agent identity credential flow around AS-1 ID-JAG Replace the harness-attested human, the self-minted association assertion E, and the bespoke chaining grant G. Alice signs in through AS-1, which runs inside the broker and federates to the company IdP. At OpenSession the broker exchanges her AS-1 access token once for a session-bound refresh token. Per call, AS-1 issues an ID-JAG that AS-2 redeems for the vMCP access token. Record the decisions and rationale in credential-flow-rework.md: - D1: upstream credentials keyed by (gateway, native user, provider). - D2: ID-JAG in both modes. External mode is identity chaining with a consent link that AS-2 witnesses. - D3: AS-1 login, private_key_jwt client auth, session-bound refresh token, and sub_id as the session owner key. - D4: single-level act now; chained exchange and scope narrowing later. - D5: the agent SVID stays the actor input, with its known limits. - D6: the broker does not check provider connections. - D7: schedule-bound, pre-authorized, renewable authority. Rewrite the credentials chapter around one flow diagram with a section per credential. Reconcile the gateway modes, MCP authorization, audit, agent definitions, README, and implementation chapters with the new flow. Move component-level detail into the implementation companion. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 2 +- .../agent-identity/agent-definitions.md | 2 +- docs/drafts/agent-identity/audit.md | 20 +- .../agent-identity/credential-flow-rework.md | 240 ++++++++++++++++++ .../credentials-and-lifecycle.md | 99 +++----- docs/drafts/agent-identity/implementation.md | 112 ++++++-- .../agent-identity/mcp-authorization.md | 20 +- .../agent-identity/mcp-gateway-modes.md | 28 +- 8 files changed, 408 insertions(+), 115 deletions(-) create mode 100644 docs/drafts/agent-identity/credential-flow-rework.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index aa071cf8c5..8a933c0abb 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -102,6 +102,6 @@ The proposed chapters develop each part of the architecture. | [MCP gateway modes](mcp-gateway-modes.md) | Chain the broker's grant issuer to the vMCP gateway's authorization server, so one protocol works with an embedded or an external gateway. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | | [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | -| [Credential flow rework](credential-flow-rework.md) | Record in-progress decisions that replace the credential flow above with an AS-1-issued ID-JAG. Until the rework lands, it takes precedence where the two disagree. | +| [Credential flow rework](credential-flow-rework.md) | Record the decisions behind the current credential flow and their rationale, the ToolHive gaps, and open questions. | The [implementation and verification companion](implementation.md) records integration requirements and checks. This broker-centered architecture supersedes the earlier identity model's harness-owned signing and audit responsibilities; the donor documents are retained in Git history. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index d5daa720fa..989fc70d5d 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -84,7 +84,7 @@ Identity continuity is separate from broker attachment recovery. See [pauses and Each instance's registration carries a provenance record for the exact definition it uses. mecak8s's trusted resolver produces it from source resolution; the model never supplies it, and it is never reconstructed later from whatever is current at the source. -The record has a versioned **profile** and profile-specific metadata. A Git profile might carry repository, path, and full commit; a Kubernetes profile the cluster, object UID, and observed revision; a service-backed profile a tenant namespace, object ID, and revision. `AgentDefSource` stays storage-independent: profiles are carried as metadata, not added to the core interface. Each profile defines required fields, schema, hashing rules, and revision semantics. Operator configuration names the profiles and evidence it accepts; a source cannot weaken that by selecting a more permissive profile. +The record has a versioned **profile** and profile-specific metadata. A Git profile might carry repository, path, and full commit; a Kubernetes profile the cluster, object UID, and observed revision; a service-backed profile a tenant namespace, object ID, and revision. Each profile defines its required fields and revision semantics. Operator configuration names the profiles and evidence it accepts; a source cannot weaken that by selecting a more permissive profile. The record carries two digests: diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index d08e631e70..954891e4f7 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -14,16 +14,16 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | --- | --- | --- | | Authenticated workload and instance | Broker validation of workload credentials and the instance JWT-SVID, with the registered session association | Does not authenticate the human or prove a task-specific delegation. | | Definition source, admitted revision, ceilings, and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | -| Initiating user and session ownership | AS-1 ran the sign-in through the company IdP and issued an access token for the Mecatl API. mecak8s records that it validated the token for its API audience and checked that the token's `(iss, sub)` owns the host session. The token's `sub_id` carries the company identity. | Establishes that AS-1 authenticated the user at sign-in and that the user currently owns the session. It does not establish fresh human action for each call, task-specific delegation, or reported run context. | +| Initiating user and session ownership | AS-1 ran the sign-in through the company IdP and issued an access token for the Mecatl API. mecak8s records that it validated the token for its API audience and checked that the company identity in the token's `sub_id` owns the host session. The record keeps both the owner (company identity) and the AS-1 subject. | Establishes that AS-1 authenticated the user at sign-in and that the user currently owns the session. It does not establish fresh human action for each call, task-specific delegation, or reported run context. | | Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | -| Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. The source of the delegating instance that AS-1 writes into the nested `act` remains to be specified. | -| Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, and actor checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. Whether the broker also needs its own connection evidence before dispatch is open. An allow proves neither dispatch nor downstream success. | +| Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. The delegation lives in Mecatl's harness and broker records only; the ID-JAG and `V` name the current actor, not the instance that delegated the task. | +| Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, and actor checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. An allow proves neither dispatch nor downstream success. | | ID-JAG issuance | AS-1 records each issuance: user from the broker refresh token's session record, session, validated actor, target AS-2, and ID-JAG `jti`. AS-1 validated the SVID and checked that the instance is registered to the session. | AS-1 does not see the user again per call; the user comes from the session record. Instance membership comes from the broker registry. | | AS-2 redemption and consent | AS-2 records each redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. In external mode, AS-2 also records creation and revocation of the consent link. | AS-2 trusts AS-1's signature and receives neither the AS-1 access token nor the SVID. In external mode the consent link is AS-2's own evidence that the user chose Mecatl; in embedded mode consent is implicit. | -| vMCP decision | vMCP records its decision with the access-token reference `(iss, jti)`, native user, outer actor, tool, and outcome | Only the outer `act.sub` drives vMCP and gateway policy; inner `act` levels are carried for audit. Other forwarded context remains reported unless independently verified. | +| vMCP decision | vMCP records its decision with the access-token reference `(iss, jti)`, native user, actor, tool, outcome, and the propagated operation ID | The gateway sees only the current actor in `act.sub`, not who delegated the task. The operation ID is reported, unverified trace context, as is other forwarded context unless independently verified. | | Observed result and provider correlation | The response received by the recording component and safe provider references, when available | A broker-observed MCP response is not independent proof of provider-side effects. | -ToolHive's authorization server records no audit events today, so the AS-1 and AS-2 records above are new work. The plan is to reuse ToolHive's audit event recorder library, which emits JSON audit records for collection with fluentd or fluent-bit. +AS-1 and AS-2 must produce the records above; ToolHive's authorization server records no audit events today, so this is new work. ## Illustrative authorization evidence @@ -33,11 +33,11 @@ The following readable fields illustrate proposed evidence for the reviewer's ca | --- | --- | | Event and call references | Event `authz-42`, operation `op-pr42-001`, attempt `attempt-1`, recording component, and observation time. Distinguish another attempt from another event for the same attempt. | | Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; host session `hostS42`; broker session `A42`; reviewer's agent-instance incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; request reference `req-pr42-001`. Alice's AS-1 subject `(https://broker.example.com/as, u-alice-91)` comes from the session's AS-1 record; her company identity `(https://login.example.com, alice-204)` comes from `sub_id`. Preserve each fact's provenance. | -| Chained credential references | Broker client `mecatl` at AS-1 and AS-2. Source AS-1 access-token `jti` `at-alice-7731`, recorded by AS-1 at conversion, never the token itself. The broker refresh token, referenced by its AS-1 record for `A42`, never by value. ID-JAG issuer `https://broker.example.com/as` and `jti` `jag-pr42-001`. Actor `agent:reviewer-gh56`, delegated by `agent:main-cejr` in the nested `act`. AS-2 access token `(https://gateway.example.com, access-pr42-001)`. These references identify different credentials and do not replace the operation ID. | +| Chained credential references | Broker client `mecatl` at AS-1 and AS-2. Source AS-1 access-token `jti` `at-alice-7731`, recorded by AS-1 at conversion, never the token itself. The broker refresh token, referenced by its AS-1 record for `A42`, never by value. ID-JAG issuer `https://broker.example.com/as` and `jti` `jag-pr42-001`. Actor `agent:reviewer-gh56` in the single-level `act`; delegation `D42` from main is in the harness and broker records, not the token. AS-2 access token `(https://gateway.example.com, access-pr42-001)`. These references identify different credentials and do not replace the operation ID. | | Definition and policy | Registered `project/code-reviewer`, source reference, provenance profile with source content and accepted definition digests, admitted revision `review-def-r17`, resolved definition ceiling reference, stored effective ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | | Target | Registered payments MCP server reference and exact `pull_request_read` tool. AS-2 resolves native user `usr-alice-7c1d`, and vMCP loads the credential for `(gateway.example.com, usr-alice-7c1d, github)`; those records live with AS-2 and vMCP, not in the broker event. | | Evaluation | Cedar nominal decision `allow`, evaluation status `completed-without-errors`, relevant policy IDs, and generated policy revision. | -| Effective decision | `allow` only after the session, workload, actor, ceiling, and route checks pass. Safe reason `effective-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. Whether the broker also requires its own connection evidence is open; if it does, record that evidence reference and deny when it is missing. | +| Effective decision | `allow` only after the session, workload, actor, ceiling, and route checks pass. Safe reason `effective-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. | | Safe request metadata | Repository `acme/payments` and PR number `42` only if explicitly allowlisted for audit. Their presence does not mean Cedar evaluated a PR-specific policy. | The policy revision and policy IDs must lead to retained generation inputs and policy evidence sufficient to explain the ceiling result. A definition name alone cannot explain which policy was used. Error and denial records need the same correlation and attribution where established; unavailable or unverified facts must remain marked as such. @@ -46,15 +46,15 @@ The `agent:` identifiers in `act` are opaque on purpose: the SPIFFE path, with i ## Decision, dispatch, and observed outcome -The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed as separate facts. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. If completion is missing after possible dispatch, the outcome is unknown, not evidence of nonexecution. Credential recovery cannot justify automatic replay of an unknown operation. +The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed as separate facts. If vMCP reports which provider connection it used, the broker records that identifier as observed, not as something the broker authorized. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. If completion is missing after possible dispatch, the outcome is unknown, not evidence of nonexecution. Credential recovery cannot justify automatic replay of an unknown operation. Decision, dispatch, and observed-outcome evidence must be durable and survive broker restart. The storage mechanism, write ordering relative to dispatch, failure handling when evidence cannot be persisted, and cross-service delivery and authenticated correlation remain explicitly unresolved implementation requirements. This design does not select a transactional ledger or claim atomicity between an audit write and an external effect. -Use operation and attempt references to connect the evidence; individual events also need their own references. The operation ID, the ID-JAG `jti`, and the AS-2 access token `(iss, jti)` are separate references. A cached access token serves several calls, so its `jti` cannot replace a per-operation reference. This correlation requirement does not establish an authenticated propagation contract between services; how `op-pr42-001` reaches AS-2 or vMCP records remains unspecified. +Use operation and attempt references to connect the evidence; individual events also need their own references. The operation ID, the ID-JAG `jti`, and the AS-2 access token `(iss, jti)` are separate references. A cached access token serves several calls, so its `jti` cannot replace a per-operation reference. To reach one operation from a gateway record, the broker must propagate the operation ID with each MCP request, and vMCP records it as reported, unverified trace context. This propagation is required for correlation; the mechanism is still to be chosen, and it is not an authenticated propagation contract. Because `act` names only the current actor, this operation ID is what keeps delegation `D42` reachable from a gateway log. ## Following a call in an investigation -Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. Session `A42` leads to AS-1's conversion record, which names user `u-alice-91`, the source access-token `jti`, and the hard expiry, and from there to mecak8s's record of the request and session ownership. On a cache miss, AS-1's issuance record links `A42` and the validated reviewer to ID-JAG `jag-pr42-001`; AS-2's redemption record links that `jti` to native user `usr-alice-7c1d` and access token `access-pr42-001`. In external mode, AS-2's consent-link record shows when Alice linked Mecatl. The vMCP record contains only the access-token reference it validates. A call served from the cache has no new AS-1 or AS-2 record; it links to the earlier issuance through the cached token. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with AS-1-witnessed user identity distinguished from workload-reported run, delegation, and lifecycle facts. +Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. Session `A42` leads to AS-1's conversion record, which names user `u-alice-91`, the source access-token `jti`, and the hard expiry, and from there to mecak8s's record of the request and session ownership. On a cache miss, AS-1's issuance record links `A42` and the validated reviewer to ID-JAG `jag-pr42-001`; AS-2's redemption record links that `jti` to native user `usr-alice-7c1d` and access token `access-pr42-001`. In external mode, AS-2's consent-link record shows when Alice linked Mecatl. The vMCP record contains the access-token reference it validates and the reported operation ID `op-pr42-001`. A call served from the cache has no new AS-1 or AS-2 record; it links to the earlier issuance through the cached token, and to its own operation through the reported operation ID. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with AS-1-witnessed user identity distinguished from workload-reported run, delegation, and lifecycle facts. The investigator then follows dispatch and observed-outcome records for that attempt, rather than inferring execution from an allow or token issuance. ToolHive records can explain downstream validation or denial. The native provider-credential path is not itself a backend-to-SaaS audit trail. Where the provider supports safe tracing or audit correlation, retain the references needed to reach its operation or audit record and trace it back to the actor. Mark where correlation ends if provider evidence or authenticated cross-service correlation is unavailable. A successful response describes what the observer received, not a guarantee of provider-side effects. diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md new file mode 100644 index 0000000000..037248b57c --- /dev/null +++ b/docs/drafts/agent-identity/credential-flow-rework.md @@ -0,0 +1,240 @@ +# Credential flow rework + +*Work record, not current behavior. It collects decisions taken while reworking the credential flow in [credentials and lifecycle](credentials-and-lifecycle.md). The existing chapters stay unchanged until the rework is complete; then their diagrams and prose are replaced in one pass.* + +The flow was reviewed right to left, from the vMCP call to a SaaS provider back to the user's login. For each credential the review asked what it must contain, what it proves, and who issues and verifies it. The guiding question is provenance: who first established each fact, and how many signers relayed it before it reached the party that relies on it. + +The design must work in two deployments: + +- **Standalone Mecatl**, with no Stacklok Enterprise Platform (SEP), no Directory, and no connector gateway. +- **Enterprise**, integrating with Directory and the connector gateway. + +Roles are therefore pluggable. Where a role is backed by Directory in enterprise deployments, Mecatl provides a local implementation that can later delegate to SEP. + +## Summary of the converged flow + +```mermaid +sequenceDiagram + actor U as Alice (TUI / Studio) + participant H as mecak8s + box Broker + participant B as Broker core + participant A1 as AS-1 (login + ID-JAG issuer) + end + participant IdP as Company IdP + box Gateway + participant A2 as AS-2 + participant V as vMCP + end + participant G as GitHub + + U->>A1: Sign in: authorization code + PKCE
(issuer discovered from protected-resource metadata) + A1->>IdP: Redirect Alice to company IdP sign-in
(AS-1 is an ordinary OIDC client there) + IdP-->>A1: Alice authenticated + A1-->>U: AS-1 access + refresh token (client-held, as today) + U->>H: Request + AS-1 access token + H->>B: OpenSession: workload auth + AS-1 access token (once) + B->>A1: RFC 8693: subject_token = access token (aud = Mecatl API)
client mecatl, private_key_jwt + A1-->>B: Broker refresh token (session-bound, hard max lifetime) + Note over H,B: Every tool call + H->>B: InvokeTool: workload auth + sessionRef + agent JWT-SVID + opt No cached V for (session, instance, gateway) + B->>A1: RFC 8693: subject_token = broker refresh token
actor_token = agent JWT-SVID
requested_token_type = id-jag, audience = AS-2 + A1->>A1: Validate SVID, check instance belongs to session
Build act + A1-->>B: ID-JAG (sub = Alice at AS-1, act, client_id = mecatl) + B->>A2: RFC 7523: assertion = ID-JAG, client mecatl + A2->>A2: Map AS-1 subject to native user
External mode: require consent link + A2-->>B: V (sub = native user, act, aud = vMCP) + end + B->>V: tools/call + V + V->>V: Validate V, load credential by (gateway, user, github) + V->>G: API call + Alice's GitHub token +``` + +When no credential or consent exists, vMCP or AS-2 refuses and the broker returns the existing `mcp_authorization_pending` outcome. The client opens the authorization URL and rechecks, as both the TUI and Studio already do. + +## Decisions + +### D1: upstream credential lookup (vMCP to provider) + +- Upstream credentials are keyed by **(gateway, native user, provider)**: one record per user and provider, refreshed as a single row with compare-and-swap. The enterprise connector gateway already does this through its user-keyed storage decorator. +- Open-source ToolHive keys credentials by interactive login session, which a delegated `V` does not have, so vMCP would find nothing. Standalone Mecatl needs a per-user lookup in open-source ToolHive; the enterprise decorator is not available there. +- vMCP validates `V`, loads the credential for the route's provider, and calls the provider. A missing credential produces "needs authorization". +- Consent is not checked here. It is checked once, when AS-2 redeems the grant (D2). +- Roles: **identity resolver** (AS-1 subject to native user), **consent store**, **credential store**. Standalone Mecatl provides local implementations; enterprise backs them with Directory and the connector gateway. Nothing in the flow presumes Directory. + +### D2: grant redeemed at AS-2 + +- AS-1 issues an **ID-JAG** ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)). It replaces the bespoke grant `G` and the self-minted association assertion `E`. The broker redeems the ID-JAG at AS-2 with RFC 7523 and receives `V`. +- **Why ID-JAG rather than the original chaining grant `G`.** ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html) (ID-JAG §1, §2.2). It reuses that draft's exchange, its RFC 7523 redemption, its no-refresh-token rule, and its discovery metadata. On top, it fixes: + - an explicit `typ=oauth-id-jag+jwt`; + - required `client_id`, `jti`, `iat`, and `aud` = the resource AS issuer; + - redemption checks that bind the redeeming client (§4.4.1); + - security rules (§9). + + The original `G` would have had to re-specify all of that as a private profile. ToolHive already redeems ID-JAGs with these checks and keeps `act`; its plain JWT-bearer redemption does not. Issuance is new work for either format. One format is used in both modes: the modes differ in AS-2 policy, not in the token. +- **How each mode is described:** + - **Embedded:** ID-JAG as specified. AS-1 is the SSO IdP that AS-2 trusts (§4.1), and the subject is the shared AS-1 `sub` (§5). + - **External:** identity chaining across trust domains (chaining §2.1, §2.5) using the ID-JAG token profile. The §4.1 precondition, that the resource AS already trusts the IdP for SSO, does **not** hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer and resolves subjects only through the explicit consent/identity link (§2.2 deployment-specific resolution). There is no just-in-time provisioning. + - **Deliberate deviation:** `aud_sub` and `email`, both RECOMMENDED by §3.1, are omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. Never resolve through `sub_id` (§9.5). +- AS-1 and AS-2 stay **separate issuers with separate keys**, also in embedded mode. ID-JAG §9.3 forbids an IdP from redeeming an ID-JAG it issued itself. Separate issuers are not process isolation when both run in one process. +- One OAuth client ID, `mecatl`, at both AS-1 and AS-2. ID-JAG §4.4.1 requires the ID-JAG's `client_id` to match the client authenticating at AS-2. +- AS-2 does not issue refresh tokens (§4.4.3). When `V` expires, the broker obtains a new ID-JAG. +- **Consent**, so that a compromised AS-1 cannot use the credentials of a user who never chose Mecatl: + + | Mode | Consent | + | --- | --- | + | Embedded / standalone | Implicit. Users log in to AS-2 through AS-1, and AS-1, AS-2, and the credential store share one trust domain. | + | External gateway | A one-time "Allow Mecatl" step at **AS-2's own login**, generated by AS-2. It records the link (native user, AS-1 issuer + AS-1 `sub`, client `mecatl`). AS-2 redeems an ID-JAG only for a linked subject. Revocable in the gateway UI. | + + The link is keyed on the AS-1 `sub` that appears in the ID-JAG, not on `sub_id` (§9.5). Admin pre-consent at AS-2 without an independent witness of the user is rejected: it would let a compromised AS-1 name every user. + +- **Requirement (decided).** AS-2 trusts AS-1 as an ID-JAG issuer in every mode, and that trust covers signature, issuer, audience, client, and replay. Mapping an AS-1 subject to a native user is a separate requirement: + - **External mode:** AS-2 maps an AS-1 subject to a native user only through a link the user establishes once, witnessed by AS-2 itself. A mapping asserted only by AS-1 is not accepted unless an administrator explicitly decides to accept that risk. + - **Embedded mode:** users already sign in to AS-2 through AS-1, so the mapping exists. + - **Missing link or connection:** the gateway must give the broker enough to send the user to a URL where they can fix it, and the call can then be retried. + + **Ask of ToolHive:** redeem ID-JAGs to native users through such links, support creating the link, and signal "link or connection missing" with a URL. How the link is created and how the signal is carried are implementation choices. +- **The external consent link.** It does two jobs: it maps AS-1's subject to AS-2's native user, and it is the user's first-hand statement at the gateway that Mecatl may act for them. + - The link names the native user, the AS-1 issuer and `sub`, and client `mecatl`. AS-2 redeems an ID-JAG only when its `iss`, `sub`, and `client_id` match a link, and the link supplies the native user. + - The user creates it once per gateway, through AS-2's own login, when the first call comes back as "link required" through the existing `mcp_authorization_pending` path. + - The link flow must resist link swapping: an attacker must not be able to get a victim to link the victim's gateway account to the attacker's AS-1 subject. + - A shared company IdP behind AS-1 and AS-2 can make the step silent, but does not remove it. AS-1, not the IdP, signs `sub_id` (§9.5), so a compromised AS-1 could name any user. + - Dropping the link takes either an administrator's risk decision to trust AS-1 for all users, or a future path where the company IdP itself issues the ID-JAG for AS-2, which needs IdP support and loses `act` at the gateway. + +### D3: login and session admission + +- mecak8s's protected-resource metadata points at **AS-1**, a ToolHive authserver instance running inside the broker, separate from AS-2, that federates upstream to the company IdP. The TUI and Studio already discover the issuer from that metadata, so neither client changes. Both keep their own AS-1 tokens as today. +- At `OpenSession`, mecak8s passes the user's current AS-1 access token to the broker **once**, in place of the harness-attested `human {issuer, subject}`. The broker exchanges it at AS-1 (RFC 8693, option (i): AS-1 accepts a token with `aud` = the Mecatl API when presented by the broker) and receives a **broker-held refresh token** for that session. AS-1 policy: + - accept only tokens AS-1 issued, with `aud` = the Mecatl API, still active, with an unused `jti`; + - accept them only from the authenticated broker client (`private_key_jwt` or mTLS); + - grant no wider scope than the original; + - one conversion per session. +- **Broker client authentication.** The subject token is a bearer token and proves nothing about who presents it. The broker therefore authenticates as OAuth client `mecatl` with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion). On each token-endpoint request it signs a short-lived JWT (`iss` = `sub` = `mecatl`, `aud` = AS-1's issuer identifier, `exp` about 60 seconds, single-use `jti`). AS-1 verifies it against the broker's registered public key or `jwks_uri`. + + ```text + POST https://broker.example.com/as/oauth/token + grant_type=urn:ietf:params:oauth:grant-type:token-exchange + subject_token= + subject_token_type=urn:ietf:params:oauth:token-type:access_token + requested_token_type=urn:ietf:params:oauth:token-type:refresh_token + client_id=mecatl + client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_assertion= + ``` + + This makes "accept only from the broker" enforceable. A leaked access token or broker refresh token is useless without the private key, which never leaves the broker and can live in a KMS or HSM. A refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5), so DPoP on it is redundant. The same client and key are registered at AS-2; in external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected because it is shared, replayable once leaked, and rotated by hand. mTLS (RFC 8705) is an equivalent but harder-to-deploy alternative. ToolHive already supports `private_key_jwt`, so v1 needs only configuration. +- **Future option: SPIFFE client authentication.** ToolHive's authserver already accepts a SPIFFE JWT-SVID as client authentication. The broker's workload identity could then serve as its OAuth client credential, with no separate key to manage. It is a place where SPIFFE authenticates a real workload, the broker, rather than an in-process agent. +- What AS-1 issues where: an **access token** only at login (`typ=at+jwt`, `aud` = the Mecatl API), as clients and protected-resource-metadata discovery already expect. An `id_token` is not usable there: its audience is the client, not mecak8s or the broker. Per call AS-1 issues the ID-JAG, which is a grant rather than an access token. AS-1 never issues an access token for AS-2's audience. +- **Alternative to the broker refresh token:** a signed session assertion JWT (`sub` = Alice, `aud` = AS-1, bound to the broker client and session, `jti`), presented as `subject_token_type=jwt`. It needs less ToolHive work, because exchange already issues and accepts JWTs. It is not stateless: one conversion per session, a maximum lifetime, and revocation still require an AS-1 session record looked up by `jti` on every exchange. It does not rotate, and it is a profile extension, since ID-JAG §4.3 lists identity assertions and refresh tokens as subject tokens. Like the refresh token, and unlike `E`, AS-1 issues it only after validating a real login token. +- **Session binding of the broker refresh token.** The refresh token is opaque to the broker. Its meaning lives in an AS-1 record: + + | Field | Purpose | + | --- | --- | + | Client `mecatl` | Only the authenticated broker can use it. | + | User (AS-1 `sub`) | Subject of every ID-JAG issued from it. | + | Broker session reference (`A42`) | The binding. | + | Host session and incarnation | Restart and recovery continuity checks. | + | Source access-token `jti` | One conversion per session. | + | Issue time and hard expiry | Authority horizon. Rotation does not extend it. | + | Rotation chain | Reuse detection. | + + Enforcement points: + + 1. **OpenSession.** AS-1 creates the record. The broker supplies its session reference, either as an extension token-request parameter or in-process. + 2. **Each ID-JAG exchange.** The refresh token resolves the record. AS-1 checks that the session is open, that the hard expiry has not passed, and that the acting instance belongs to this session in the registry. + 3. **Session close.** The broker revokes the refresh token and AS-1 deletes the record. + 4. **Rotation.** The new refresh token inherits the record. + + The session reference is broker-asserted. The binding contains bugs, such as cross-session confusion and stale sessions. It does not protect against a compromised broker. This fits ToolHive's stored-session model, the same mechanism that carries `tsid` today. + + A token format that carries these claims, a signed session assertion or a refresh token that AS-1 encodes internally as a signed JWT, adds readable copies of the same data. AS-1 must still look up the record for revocation and the one-conversion rule, and must still read the registry for instance membership. That format pays off only if AS-1 must validate without access to the store. +- The broker refresh token is the subject of each ID-JAG exchange (ID-JAG §4.3 allows a refresh token as `subject_token`). It is the standard form of the human authority in `A42`. `A42` keeps only the workload and host-session binding. +- **v1 simplification:** the broker refresh token is not tied to the user's AS-1 login session. It has a hard maximum lifetime and is revoked when the broker session closes. Disabling the company account therefore takes effect at that maximum lifetime at the latest. Binding to the login session (`sid`), so logout revokes it, is later work. +- **Owner identity.** The session owner stays the company identity `(company iss, company sub)`. mecak8s reads it from the validated AS-1 access token's `sub_id`, not from the token's own `iss`/`sub`. Existing owners keep matching, so no migration is needed. The owner remains the human's stable identity across AS-1 user-store rebuilds, and it is the identity the run owner will use later. AS-1 must always set `sub_id`. mecak8s rejects an AS-1 token without it and trusts it because AS-1 ran that sign-in. + +### D4: delegation visibility at the gateway + +- **Short term (B): single-level `act`.** The ID-JAG and `V` name only the current actor, for example `"act": {"sub": "agent:reviewer-gh56"}`. There is no nested `act`. + - The identifier is opaque, stable, and per instance: no SPIFFE path, no definition name, and no `act.iss`. The ID-JAG's own `iss` is AS-1. + - AS-1 derives it from the validated `actor_token`, the calling instance's JWT-SVID. The full SVID stops at AS-1. + - The delegation, for example that main delegated `D42` to the reviewer, stays in Mecatl's harness and broker records, not in the token. + - Why not nest now: RFC 8693 §4.1 says prior actors in a nested `act` are informational only and must not drive access decisions, and the gateway only ever used the outer `act.sub`, so dropping the inner level loses no enforcement. The protocol-clean way to get a nested `act` is the chained exchange below. Building the inner level from the registered parent would record creation, not delegation, which is wrong. Building it from a harness-reported delegation would make AS-1 sign a claim it never checked. +- **Later: chained exchange, together with C.** Nesting comes from a second exchange at delegation time: + 1. When main delegates `D42`, the broker exchanges at AS-1 with `subject_token=RT-A42` and `actor_token=mainSVID`. + 2. AS-1 returns a delegation token `T-D42`: `sub` = Alice, `act` = main, narrowed to the task. + 3. The reviewer's calls under `D42` use `subject_token=T-D42` and `actor_token=reviewerSVID`. + 4. AS-1 wraps the existing actor: `act {reviewer, act {main}}` ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)). This matches the pattern in draft-mcguinness-oauth-actor-profile, an individual draft. + + It costs a broker operation at delegation time. The broker holds `T-D42` per delegation and renews it from AS-1 server state. The `V` cache key becomes `(A42, D42, reviewer)`. `T-D42` is revoked when the task ends. `T-D42` is also where per-task narrowing, as scope or RFC 9396 `authorization_details`, would live. The McGuinness profile requires `act.iss`; D4 omits it, which conforms to RFC 8693. +- **Long term (C): rescope each token.** AS-1 writes the instance's effective ceiling into the ID-JAG as scope or `authorization_details` (RFC 9396), AS-2 carries it into `V`, and the gateway enforces it as a second enforcement point. The ID-JAG and `V` formats reserve that slot now. +- Until C, the agent ceiling is enforced by the broker alone; the gateway enforces user and connection. The threat model states this. + +### D5: per-agent JWT-SVID stays the actor input + +The agent-instance JWT-SVID stays in scope. The harness presents it on `InvokeTool`, and the broker forwards it to AS-1 as `actor_token`. Reasons: + +- It gives agent identity its own issuer and trust domain, separate from the OAuth issuers. +- It is a place to carry agent metadata, such as ceiling provenance. +- It keeps open a path to RAR-shaped (RFC 9396) policy and to attestation. + +Known limits, recorded so the documents do not overclaim: + +- While agents share one harness process, the SVID asserts the agent rather than authenticating it. The issuer is in the broker, the broker presents the SVID, and AS-1 still checks in the registry that the instance belongs to the session. +- AS-1 can also place ceiling metadata and `authorization_details` in the ID-JAG from its registry. The gateway never sees the SVID. +- Recent SPIFFE work does not change this for in-process agents. The [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340) (SPIRE 1.15.2, July 2026, experimental) lets a trusted broker obtain SVIDs for a referenced PID or Kubernetes object. With a `KubernetesObjectReference`, per-agent SVIDs would be attested if each agent instance were its own Kubernetes object, such as a CRD or sandbox pod, created by a controller the harness cannot impersonate. + +Possible strengthenings, not selected for v1: + +- **Session claim.** The SVID names the session it was issued for. AS-1 can then check that the SVID and the subject token belong to the same session, without relying on the broker's pairing. It is still issued on the harness's word. +- **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession. A stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. + +Out of scope for this version: attesting per-agent identity, for example per-agent Kubernetes objects attested through the SPIFFE Broker API. It requires agents to run in their own pod, sandbox, or object. + +Reviewed and not selected: an `InvokeTool` actor field accepting either an instance reference or an SVID. + +## User-visible flow + +- **Sign in** once per login session, through AS-1 to the company IdP; usually silent with single sign-on. +- **Connect a provider** once, through the existing `mcp_authorization_pending` path. In external mode the same path first shows the gateway's "Allow Mecatl" step. +- **Everyday use and scheduled runs** need nothing from the user until the broker refresh token's maximum lifetime or a revocation. +- **Revoke** in Mecatl, or, for external gateways, in the gateway UI. + +## ToolHive gaps + +What each decision asks of ToolHive, and whether it exists today. Current behavior and sizes are in the [ToolHive work list](implementation.md#toolhive-work-list). + +| Needed for | Capability | Today | +| --- | --- | --- | +| D3 | Exchange of a self-issued access token presented by another confidential client | Mostly | +| D3 | Refresh token in a token-exchange response | No | +| D3, later | Refresh token bound to the login session | No | +| D2 | Issue ID-JAGs for another AS | No | +| D2 | Refresh token as `subject_token` | No | +| D4 | Single-level `act` from the actor input; nesting later with the chained exchange | Partly | +| D4 | ID-JAG redemption keeps `act` | Yes | +| D2, D1 | Native user and stored credentials after ID-JAG redemption | No | +| D2 | Consent link at AS-2's own login (external mode) | No | +| D1 | Per-user credential lookup in open-source ToolHive | No | +| D3 | `private_key_jwt` for the broker | Yes | +| Later | DPoP issuance and validation | No | +| D4 (C) | Scope or `authorization_details` narrowing enforced per tool, including on `act` | Partly | + +### D6: the broker does not check provider connections + +The broker authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user through the mapping and, in external mode, the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions. + +The original design required the broker to hold its own authorized-connection evidence before dispatch. Nothing supplies that evidence. In external mode the mapping and connection records belong to the gateway operator, so every call would fail closed. The broker could only copy the gateway's data, which is not an independent check. Each party therefore decides what it knows first-hand. + +Future broker Cedar policies stay request-shaped: user and identity-provider attributes from AS-1, agent instance and definition provenance, server and tool, resources named in the arguments, and session and delegation context. If vMCP reports the connection it used, the broker records it as observed. A pre-run connection status query, if added later, is advisory and used for UX only, never as an allow or deny input. + +### D7: scheduled runs are pre-authorized + +- **Authority.** A schedule has its own delegated authority from the user: created at schedule setup, bound to the schedule rather than a session, with an explicit horizon, and revoked when the schedule is deleted. Session-bound authority cannot serve schedules, because it is revoked when the session closes. +- **Pre-authorization.** At setup, every connector the schedule may use is connected: the gateway link in external mode, and the provider connection in every mode. Gaps are fixed interactively then. +- **At run time,** nothing is interactive. A lapsed link, credential, or authority fails the call. The run ends with "needs owner action", and the owner is notified through a link into Mecatl, never a raw connect URL. +- **Renewal** extends the horizon and reconnects lapsed connectors without recreating the schedule. The owner is notified ahead of the horizon. + +## Open + +- Whether to adopt either SVID strengthening listed in D5, the session claim or key binding. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 7d43375ce0..d49e061ef9 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -63,7 +63,7 @@ sequenceDiagram U->>A1: Authorization code + PKCE
(issuer from mecak8s protected-resource metadata) A1->>IdP: Redirect to company sign-in
(AS-1 is an ordinary OIDC client there) IdP-->>A1: Alice authenticated - A1-->>U: AS-1 access token (aud mecak8s) + client refresh token + A1-->>U: AS-1 access token (aud = Mecatl API) + client refresh token Note over U,A1: (2)(3) Session open U->>H: PR review request + AS-1 access token @@ -109,7 +109,7 @@ sequenceDiagram end H->>B: InvokeTool: A42, op-pr42-001, agentJwtSvid = reviewerSVID - Note over B,G: Same checks and cache-miss path with actor_token = reviewerSVID
J-reviewer and V-reviewer: act = reviewer, nested act = main + Note over B,G: Same checks and cache-miss path with actor_token = reviewerSVID
J-reviewer and V-reviewer: act = reviewer B-->>H: Sanitized outcome H-->>U: Review outcome ``` @@ -143,11 +143,11 @@ The client receives an access token for the Mecatl API and keeps its own refresh } ``` -mecak8s validates it as a JWT access token (RFC 9068, including `typ`), checks `aud`, and checks that `(iss, sub)` owns `hostS42`. If either check fails, it rejects the request. An `id_token` is not accepted here: its audience is the client, not the API. +mecak8s validates it as a JWT access token (RFC 9068, including `typ`), checks `aud`, and checks that the company identity in `sub_id` owns `hostS42`. If either check fails, it rejects the request. An `id_token` is not accepted here: its audience is the client, not the API. -Session ownership is keyed by `(iss, sub)`, so the owner's issuer becomes AS-1's. Existing sessions owned by the company issuer need a migration; `sub_id` carries the company identity so mecak8s can match existing owners during the transition. The company IdP sees only AS-1 as the application: per-application IdP policies can no longer distinguish the TUI from Studio. +Session ownership is keyed by the company identity `(https://login.example.com, alice-204)` that AS-1 carries in `sub_id`, not by AS-1's own `(iss, sub)`. Existing owners keep matching without migration, and the owner stays Alice's stable human identity. AS-1 must always set `sub_id`; mecak8s rejects an AS-1 token without it. The company IdP sees only AS-1 as the application: per-application IdP policies can no longer distinguish the TUI from Studio. -**Evidence:** mecak8s accepted request `req-pr42-001` from `(https://broker.example.com/as, u-alice-91)` for `hostS42`. This records API authentication and ownership only. +**Evidence:** mecak8s accepted request `req-pr42-001` from owner `(https://login.example.com, alice-204)`, authenticated by AS-1 as `u-alice-91`, for `hostS42`. This records API authentication and ownership only. ### (2) Workload credential @@ -174,42 +174,15 @@ The broker checks the signature against trusted cluster keys, the issuer, allowe At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, alongside workload authentication. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. -The broker exchanges that token at AS-1: +The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authenticating as client `mecatl`, and asks for a refresh token. AS-1 accepts only a token it issued for the Mecatl API that is still active, presented by `mecatl`, converted at most once, and asking for no wider scope than the original grant. The [implementation companion](implementation.md#as-1-grant-and-as-2-user-resolution) lists the exact checks. -```text -POST https://broker.example.com/as/oauth/token -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token= -subject_token_type=urn:ietf:params:oauth:token-type:access_token -requested_token_type=urn:ietf:params:oauth:token-type:refresh_token -client_id=mecatl -client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -client_assertion= -``` - -AS-1 checks, in order: - -1. **The client.** The `private_key_jwt` assertion verifies against `mecatl`'s registered key, with `iss` = `sub` = `mecatl`, `aud` = AS-1's issuer, a short expiry, and an unused `jti`. -2. **The subject token.** AS-1 issued it, `aud` is mecak8s, it is still active, and its `jti` has not been converted before. -3. **Policy.** Client `mecatl` may convert tokens from the TUI and Studio, once per session, with no wider scope than the original grant. - -AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record: - -| Field | Purpose | -| --- | --- | -| Client `mecatl` | Only the authenticated broker can use it. | -| User `u-alice-91` | Subject of every ID-JAG issued from it. | -| `A42` | The session binding. | -| `hostS42` and host incarnation | Restart and recovery continuity. | -| Source access-token `jti` | One conversion per session. | -| Issue time and hard expiry | Authority horizon. Rotation does not extend it. | -| Rotation chain | Reuse detection. | +AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, and to session `A42`, with a hard expiry that rotation does not extend. Every ID-JAG issued from it names that subject. The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. In v1 the refresh token is not tied to Alice's AS-1 login session: disabling her company account takes effect at the hard expiry at the latest. Binding to the login session is later work. The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. -**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less ToolHive work but still requires the AS-1 record for revocation and one-conversion checks, does not rotate, and is an extension to the ID-JAG profile. See the [rework record](credential-flow-rework.md#d3-login-and-session-admission). +**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, does not rotate, and is an extension to the ID-JAG profile. See the [rework record](credential-flow-rework.md#d3-login-and-session-admission). **Evidence:** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, source token `jti`, session `A42`, and the hard expiry. @@ -217,7 +190,7 @@ The existing broker session record under `A42` keeps the workload and host-sessi For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The agent-instance issuer constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). Registration records identity, not permission to spawn, run, or call tools. -The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. Mecatl's harness holds it outside model history and presents it to the broker for invocation. The broker forwards it to AS-1 as `actor_token`; AS-2 receives only the actor claim in the ID-JAG. The configured broker audience is accepted by both the broker invocation validator and AS-1's actor validator. The agent issuer retains its private signing key. This fictional decoded example deliberately uses only required JWT-SVID claims: +The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. Mecatl's harness holds it outside model history and presents it to the broker for invocation. The broker forwards it to AS-1 as `actor_token`; AS-2 receives only the actor claim in the ID-JAG. The agent issuer retains its private signing key. This fictional decoded example deliberately uses only required JWT-SVID claims: ```json { @@ -227,7 +200,7 @@ The broker's agent issuer signs each registered instance's JWT-SVID. This payloa } ``` -Before issuance, the issuer checks the workload and reviewer instance against the registration; a definition's existence alone is insufficient. Mecatl checks that the returned subject is the registered reviewer instance, then validates its signature, allowed algorithm, expiry, and shared broker audience against the configured agent trust-domain bundle. The broker and AS-1 each validate the SVID for their configured audience and permitted presenter. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and trust domain are distinct from OAuth AS-1 and AS-2; AS-1 uses the validated registered identity to form the ID-JAG's `act` claim. +Before issuance, the issuer checks the workload and reviewer instance against the registration; a definition's existence alone is insufficient. Mecatl checks that the returned subject is the registered reviewer instance. The broker and AS-1 each validate the SVID against the configured agent trust bundle. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and trust domain are distinct from OAuth AS-1 and AS-2; AS-1 uses the validated registered identity to form the ID-JAG's `act` claim. See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. @@ -270,25 +243,13 @@ Before any exchange or dispatch, the broker checks: A cache hit skips the token exchanges, never these checks. In v1, user, run, and delegation are not Cedar inputs; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). -User and connection authorization are enforced downstream: AS-2 redeems only for a mapped (and, in external mode, linked) user, and vMCP dispatches only with Alice's stored credential. Whether the broker additionally needs its own connection evidence before dispatch remains open. +The broker does not check provider connections. It authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. **Evidence:** `op-pr42-001` passed the effective-ceiling policy and session, workload, and actor checks. This decision proves neither dispatch nor success. The [audit chapter](audit.md#illustrative-authorization-evidence) owns the full evidence shape. ### (7) ID-JAG from AS-1 -On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)): - -```text -POST https://broker.example.com/as/oauth/token -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token=RT-A42 -subject_token_type=urn:ietf:params:oauth:token-type:refresh_token -actor_token=reviewerSVID -actor_token_type=urn:ietf:params:oauth:token-type:jwt -requested_token_type=urn:ietf:params:oauth:token-type:id-jag -audience=https://gateway.example.com -client_id=mecatl + client assertion -``` +On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with an RFC 8693 token exchange: `RT-A42` is the subject token, `reviewerSVID` the actor token, and AS-2 the audience. The broker authenticates as `mecatl`. AS-1 resolves `RT-A42` to its record and checks that the session is open, the hard expiry has not passed, and the token is bound to `mecatl`. It validates the SVID and checks that the reviewer is registered to `A42`. It authorizes the combination of client, subject, actor, and target AS-2, then issues: @@ -299,14 +260,14 @@ AS-1 resolves `RT-A42` to its record and checks that the session is open, the ha "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "act": {"sub": "agent:reviewer-gh56"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 } ``` -- `act` names the acting instance with an opaque, stable `agent:` identifier, not the SPIFFE path, so internal structure does not cross into the gateway's domain. The nested `act` records the instance that delegated this task. Only the outer `act.sub` identifies the actor for vMCP session binding and gateway policy; inner levels serve audit. Depth is capped. The source of the delegating instance, the reported delegation context that `InvokeTool` does not carry today, remains to be specified. +- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path, no definition name, and no `act.iss`. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. - AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; the draft forbids an issuer redeeming its own ID-JAG (§9.3). @@ -332,7 +293,7 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): { "iss": "https://gateway.example.com", "sub": "usr-alice-7c1d", - "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "act": {"sub": "agent:reviewer-gh56"}, "client_id": "mecatl", "aud": "https://gateway.example.com/mcp", "iat": 1800000005, @@ -341,23 +302,23 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): } ``` -The broker caches `V-reviewer` for `(A42, reviewer, gateway)` until it expires, then obtains a new ID-JAG. vMCP binds its session (`Mcp-Session-Id`) to the user and the outer `act.sub`, so each agent instance has its own vMCP session. +The broker caches `V-reviewer` for `(A42, reviewer, gateway)` until it expires, then obtains a new ID-JAG. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, so each agent instance has its own vMCP session. **Evidence:** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. ### (9) vMCP decision and upstream credential -vMCP validates `V-reviewer` against AS-2's keys, `typ`, audience, and expiry, and authorizes the operation. It then loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. One record per user and provider exists, refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. - -Open-source ToolHive keys stored credentials by `tsid`, a per-interactive-login session ID, and a delegated `V` carries none. A per-user lookup for delegated tokens is required in standalone deployments. +vMCP validates `V-reviewer` against AS-2's keys, `typ`, audience, and expiry, and authorizes the operation. It then loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. One record exists per user and provider. A delegated `V` comes from no interactive gateway login, so every gateway, including a standalone one, must find the credential from the user alone. If no credential is stored, vMCP answers "needs authorization" and the user connects GitHub through the existing authorization flow. A later call after connection passes every check again. -**Evidence:** vMCP records its decision with the access-token reference `(iss, jti)`, user, outer actor, tool, and outcome. Neither a successful MCP response nor an authorization record proves provider-side effects. Persistence ordering and authenticated cross-service propagation of `op-pr42-001` remain unspecified. +**Evidence:** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, and outcome. Neither a successful MCP response nor an authorization record proves provider-side effects. + +**Correlation:** a cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. To tie a gateway record to a single call, the broker must propagate the operation ID, here `op-pr42-001`, with each MCP request, and vMCP logs it as reported, unverified trace context. This propagation is required for correlation. Because `act` names only the reviewer, this operation ID is what lets an investigator reach delegation `D42` from a gateway log. ## User identity -AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s uses the AS-1 identity for API authentication and session ownership. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. +AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s authenticates the API request with the AS-1 token and takes the session owner from its `sub_id`, the company identity. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. Compared with a harness report, a compromised mecak8s can misuse tokens of users currently passing through it but cannot name arbitrary users. A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. @@ -402,7 +363,7 @@ An agent instance keeps its identity across runs. These lifetimes are independen | Consent link | Revocation at AS-2. | The user links again. | | Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | -The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The value of `RT-A42`'s hard expiry is not selected; it is the authority horizon for runs without Alice present and should be known to the company IdP administrator. +The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The values of `RT-A42`'s hard expiry and of a schedule's authority horizon are not selected. They bound how long Mecatl acts without a fresh sign-in, and the company IdP administrator should know them. Closing or revoking the session revokes `RT-A42` and invalidates the broker's `V` cache for that session. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. @@ -424,7 +385,15 @@ An operation that may have been dispatched but has no recorded completion has an A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -A scheduled run uses the broker refresh token of the session it runs in. That refresh token's hard expiry is the limit of unattended authority: after it, or after revocation, the run's tool calls fail closed until Alice signs in again. A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. What a scheduled run does when consent or a connection is missing, fail and notify or wait, remains open. +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, in the same one-time conversion as at session open, but bound to the schedule rather than a session. It has an explicit horizon, and deleting the schedule revokes it. Each run draws on that authority. + +When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. + +At run time nothing is interactive. Suppose something has lapsed since setup: a revoked link, a revoked provider credential, or authority past its horizon. Then the call fails closed, the run ends with a "needs owner action" outcome, and Alice is notified. The notification links into Mecatl, which starts a fresh fix flow. It does not carry a connect URL, because those can be single-use and short-lived. A provider token that merely expired but can be refreshed is not a lapse: the gateway refreshes it. + +Pre-authorization is renewable without recreating the schedule. Renewing extends the authority horizon and reconnects anything that lapsed. Alice is notified before the horizon is reached. + +A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. ## Workload authentication @@ -448,7 +417,7 @@ For SPIRE authentication, audit evidence identifies the configured trust domain/ A workload credential alone cannot distinguish `code-reviewer` from `deployer` when both run in the same Mecatl service. A self-declared agent name does not establish that distinction for broker policy. -A future option is to authenticate the broker as OAuth client `mecatl` with its SPIFFE workload identity instead of a separate private key; ToolHive's authorization server already accepts SPIFFE client authentication. +A future option is to authenticate the broker as OAuth client `mecatl` with its SPIFFE workload identity instead of a separate private key. ## AS-2 access-token use and actor history @@ -456,7 +425,7 @@ The broker redeems the ID-JAG as described in [step (8)](#8-as-2-access-token). The ID-JAG `jti`, AS-2 token `(iss, jti)`, and operation ID are separate references. A token can be reused for multiple authorized calls, so its `jti` cannot replace a per-operation reference. Each recipient records only what it verifies or observes. -The outer `act` identifies the current actor ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)). The nested `act` records the instance that delegated the current task, a chain of prior actors in RFC 8693's sense; it does not record creation ancestry, which stays in the SPIFFE path inside Mecatl. Until the agent ceiling is also carried as scope or `authorization_details`, the gateway enforces user and connection, and the broker alone enforces the agent ceiling. +`act` identifies the current actor only ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)) and has one level. Actor history stays inside Mecatl: delegation in the harness and broker records, creation ancestry in the SPIFFE path. RFC 8693 treats prior actors in a nested `act` as informational only, so leaving them out removes nothing the gateway enforces. A later [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) would let AS-1 nest the delegating instance as a prior actor. To reach the delegation from a gateway record, follow the propagated operation ID (see [step (9)](#9-vmcp-decision-and-upstream-credential)). Until the agent ceiling is also carried as scope or `authorization_details`, the gateway enforces user and connection, and the broker alone enforces the agent ceiling. ## Provider-connection selection @@ -498,6 +467,6 @@ These IDs describe instance identity and creation ancestry. The initiating user ## Sender binding -`RT-A42`, the ID-JAG, and `V` are all presented only by the broker. `RT-A42` and the ID-JAG are usable only together with the broker's client authentication, so they are sender-constrained through `mecatl`'s private key. `V` remains a bearer token presented to vMCP; its signature protects the claims but does not prove the holder. DPoP-binding `V` to a broker key would close that gap; ToolHive supports DPoP in neither the authorization server nor vMCP today. +`RT-A42`, the ID-JAG, and `V` are all presented only by the broker. `RT-A42` and the ID-JAG are usable only together with the broker's client authentication, so they are sender-constrained through `mecatl`'s private key. `V` remains a bearer token presented to vMCP; its signature protects the claims but does not prove the holder. DPoP-binding `V` to a broker key would close that gap; it is later work. Binding a credential to a worker's key would not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent-instance identity and tool-call authorization remain separate checks. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 390325a7f8..152d7fb3d3 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -6,7 +6,11 @@ This companion records implementation and verification requirements for the [age Verify that session enumeration, session access, and event replay enforce the authenticated caller's ownership, including on shared storage. Include negative tests proving another user cannot obtain session titles, prompts, or archived conversation content. -Session ownership is keyed by the AS-1 `(iss, sub)` of the validated access token. Existing sessions owned by the company issuer need a migration; during the transition, mecak8s may match existing owners through the company identity in the AS-1 token's `sub_id`. The migration details remain open. Test that a `sub_id` match never grants access to a session owned by a different AS-1 subject once that session has migrated. +Session ownership is keyed by the company identity in the validated AS-1 access token's `sub_id`, not by the token's own `(iss, sub)`, so existing owners need no migration. Test the following: + +- mecak8s rejects an AS-1 token without `sub_id`. +- It accepts `sub_id` only from tokens AS-1 issued. +- Two AS-1 subjects can never carry the same company identity. Verify that scheduled invocations preserve owner attribution without treating saved ownership as fresh user authentication or permission to use a provider connection. @@ -26,11 +30,36 @@ The flow has four integration points: AS-1 sign-in, the session-open exchange, t **Session open.** At `OpenSession`, mecak8s passes the user's current AS-1 access token to the broker once, alongside workload authentication. It replaces the harness-reported `human {issuer, subject}`. The broker exchanges it at AS-1 with RFC 8693: `subject_token_type=urn:ietf:params:oauth:token-type:access_token`, `requested_token_type=urn:ietf:params:oauth:token-type:refresh_token`. AS-1 accepts the token only if: -- AS-1 issued it, its `aud` is mecak8s, it is still active, and its `jti` has not been converted before; +- AS-1 issued it, its `aud` is the Mecatl API, it is still active, and its `jti` has not been converted before; - the presenter is the authenticated client `mecatl`; - the request asks for no wider scope than the original grant. -Accepting a token whose `aud` is mecak8s from a different client is an explicit, narrow AS-1 policy for client `mecatl`, not a general audience bypass. AS-1 issues a broker refresh token and creates its record: client `mecatl`, AS-1 `sub`, broker session reference, host session and incarnation, source token `jti`, issue time and hard expiry, and rotation chain. The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. In v1 the refresh token is not tied to the user's AS-1 login session. +Client `mecatl` may convert tokens issued to the TUI and Studio only. A session-open request, using the [main flow](credentials-and-lifecycle.md) values: + +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:access_token +requested_token_type=urn:ietf:params:oauth:token-type:refresh_token +client_id=mecatl +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` + +Accepting a token whose `aud` is the Mecatl API from a different client is an explicit, narrow AS-1 policy for client `mecatl`, not a general audience bypass. AS-1 issues a broker refresh token, such as `RT-A42`, which is opaque to the broker. Its meaning lives in an AS-1 record: + +| Field | Example | Purpose | +| --- | --- | --- | +| Client | `mecatl` | Only the authenticated broker can use it. | +| AS-1 `sub` | `u-alice-91` | Subject of every ID-JAG issued from it. | +| Broker session reference | `A42` | The session binding. | +| Host session and incarnation | `hostS42` | Restart and recovery continuity. | +| Source access-token `jti` | | One conversion per session. | +| Issue time and hard expiry | | Authority horizon. | +| Rotation chain | | Reuse detection. | + +The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. In v1 the refresh token is not tied to the user's AS-1 login session. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: @@ -39,7 +68,21 @@ Accepting a token whose `aud` is mecak8s from a different client is an explicit, 3. authorizes the combination of client, subject, actor, and target AS-2; 4. issues the ID-JAG. -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and nested `act`. The outer `act.sub` is an opaque, stable `agent:` identifier for the acting instance. The inner `act` names the instance that delegated the current task. Every level uses an opaque `agent:` identifier, never a SPIFFE path or definition name, and omits `act.iss`. Depth is capped. Where AS-1 gets the delegating instance is open: `InvokeTool` does not carry that delegation context today. `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). +The request, for the reviewer instance in the main flow: + +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token=RT-A42 +subject_token_type=urn:ietf:params:oauth:token-type:refresh_token +actor_token=reviewerSVID +actor_token_type=urn:ietf:params:oauth:token-type:jwt +requested_token_type=urn:ietf:params:oauth:token-type:id-jag +audience=https://gateway.example.com +client_id=mecatl + client assertion +``` + +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is omitted. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). **AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then maps the AS-1 subject to a native user: @@ -50,11 +93,13 @@ The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from th AS-2 never resolves a user through `sub_id` (§9.5), email, or display name. Unknown subjects fail closed. A native-user match does not authorize use of a provider connection. -AS-2 issues `V`: `typ=at+jwt`, `sub` = native user, the ID-JAG's nested `act` unchanged, `client_id=mecatl`, `aud` = the vMCP resource, and `jti`. It issues no refresh token (§4.4.3); after expiry the broker obtains a new ID-JAG from the broker refresh token. If the refresh token has expired or been revoked, fail closed until a new session open supplies a fresh AS-1 access token. +AS-2 issues `V`: `typ=at+jwt`, `sub` = native user, the ID-JAG's `act` unchanged, `client_id=mecatl`, `aud` = the vMCP resource, and `jti`. It issues no refresh token (§4.4.3); after expiry the broker obtains a new ID-JAG from the broker refresh token. If the refresh token has expired or been revoked, fail closed until a new session open supplies a fresh AS-1 access token. + +**vMCP and the upstream credential.** vMCP validates `V` under RFC 9068 and, if the request uses a `resource` parameter, RFC 8707. It binds its session to the user and `act.sub`. It loads the upstream credential keyed by (gateway, native user, provider). A missing credential answers "needs authorization", which the broker returns as the existing `mcp_authorization_pending` outcome. Consent is checked at redemption, not here. There is one credential record per (user, provider), refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. -**vMCP and the upstream credential.** vMCP validates `V` under RFC 9068 and, if the request uses a `resource` parameter, RFC 8707. It binds its session to the user and the outer `act.sub`. It loads the upstream credential keyed by (gateway, native user, provider). A missing credential answers "needs authorization", which the broker returns as the existing `mcp_authorization_pending` outcome. Consent is checked at redemption, not here. +The broker must propagate the operation ID, such as `op-pr42-001`, with each MCP request, and vMCP must log it with its decision as reported, unverified trace context. A cached `V` serves several calls, so its `(iss, jti)` cannot identify one operation; with a single-level `act`, the operation ID is also what leads from a gateway record to the delegation in Mecatl's records. The mechanism, a trace header or MCP request `_meta`, is open, and so is the persistence ordering of these correlation records. -**Open.** Whether the broker needs its own authorized-connection evidence before dispatch, beyond AS-2's mapping and vMCP's credential lookup, remains open. So does how an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome. No callback or private mapping-response fields are specified. +**Open.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome. No callback or private mapping-response fields are specified. Candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. ## Credential verification @@ -68,27 +113,28 @@ Test client authentication at both servers: Test the session-open exchange: - AS-1 rejects access tokens it did not issue, tokens for another audience, expired or revoked tokens, a second conversion of the same `jti`, and requests for wider scope. -- AS-1 accepts a mecak8s-audience token only from client `mecatl`; another client presenting the same token is rejected. +- AS-1 accepts a Mecatl-API-audience token only from client `mecatl`; another client presenting the same token is rejected. - The refresh token record names the session; rotation keeps the hard expiry; reuse of a rotated token is detected; session close revokes it. Test ID-JAG issuance: - AS-1 rejects an expired, revoked, or foreign-client refresh token, and a refresh token whose session is closed or past its hard expiry. - AS-1 rejects an untrusted or expired SVID, an SVID with an audience not accepted by both AS-1 and the broker, and an instance not registered to the refresh token's session. -- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and nested opaque `act` within the depth cap. A caller cannot change `sub` or `act` through request parameters. +- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `agent:` identifier of the validated actor and which has no nested `act` and no `act.iss`. A caller cannot change `sub` or `act` through request parameters. Test AS-2 redemption: - AS-2 rejects a wrong `typ`, untrusted issuer, wrong `aud`, expired ID-JAG, reused `jti`, and `client_id` that does not match the authenticated client. - In external mode, AS-2 rejects a subject with no consent link and a revoked link. A link for another AS-1 issuer or client does not match. - AS-2 never resolves through `sub_id` or email; a matching `sub_id` without a mapping fails closed. -- `V` carries the native user, the ID-JAG's nested `act` unchanged, `client_id=mecatl`, and the vMCP audience, with no refresh token. +- `V` carries the native user, the ID-JAG's single-level `act` unchanged, `client_id=mecatl`, and the vMCP audience, with no refresh token. Test vMCP and the upstream credential: - vMCP rejects a `V` with the wrong issuer, audience, `typ`, or expiry. - vMCP loads the credential for (gateway, native user, provider) of a delegated `V` that carries no `tsid`, and returns "needs authorization" when none exists. One user's `V` never loads another user's credential. - Two agent instances acting for the same user get separate vMCP sessions. +- vMCP records the operation ID the broker propagated with each request, marked as reported. Two calls served by one cached `V` log different operation IDs. Verify that preliminary broker denial, AS-1 denial, AS-2 denial, and vMCP denial each prevent provider dispatch, and that a missing consent link or upstream credential surfaces as `mcp_authorization_pending`. Verify that AS-1 access tokens, broker refresh tokens, ID-JAGs, `V`, and client assertions never reach model or tool content, transcripts, logs, environment variables, or audit records, including errors and diagnostics. The server side never stores the AS-1 access token durably. An expired or revoked broker refresh token denies new ID-JAG issuance; it must not be replaced by a reported issuer/subject pair or a fabricated continuation. @@ -104,28 +150,60 @@ AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive' | Capability | Today | Size | When | | --- | --- | --- | --- | -| Exchange of a self-issued access token presented by another confidential client (the mecak8s-audience exception) | Mostly; the requested `aud` must be a subset of the subject token's `aud` | Small to medium | v1 | +| Exchange of a self-issued access token presented by another confidential client (the Mecatl-API-audience exception) | Mostly; the requested `aud` must be a subset of the subject token's `aud` | Small to medium | v1 | | Refresh token in a token-exchange response, with the session-bound record | No; exchange issues access tokens only | Medium to large | v1 | -| Issue ID-JAGs (`typ=oauth-id-jag+jwt`, audience = another AS) | No; redemption and an outbound XAA client only | Large | v1 | +| Issue ID-JAGs (`typ=oauth-id-jag+jwt`, audience = another AS) | No; redemption and an outbound XAA client only. The ID-JAG redemption handler binds the client, consumes `jti`, and copies `act`; the plain JWT-bearer handler drops `act` and replaces the client | Large | v1 | | Refresh token as `subject_token` | No | Large | v1 | -| `act` from the registered actor input, nested | Partly; `buildActClaim` nests from a client or self-issued actor token | Medium | v1 | -| ID-JAG redemption keeps nested `act` | Yes, copied unchanged | None; verify only | v1 | +| Single-level `act` from the registered actor input (nesting later, with the chained exchange) | Partly; `buildActClaim` builds `act` from a client or self-issued actor token and nests it | Medium | v1 | +| ID-JAG redemption keeps `act` | Yes, copied unchanged | None; verify only | v1 | | AS-2 maps the AS-1 subject to a native user after ID-JAG redemption, and stops marking the token as having no upstream session | No; emits `iss#sub` and marks no upstream session | Large | v1 | -| Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, enterprise decorator only | Medium | v1 | +| Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, a per-interactive-login session ID that a delegated `V` lacks, and vMCP loads nothing for a token without one (`pkg/auth/token.go:1253`); the enterprise per-user decorator is build-tagged | Medium | v1 | | Consent link at AS-2's own login (external mode only) | No | Not sized | v1 | | `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | v1 | +| SPIFFE client authentication at the authorization server | Yes | None; verify only | v1 | +| AS-1 and AS-2 [audit records](audit.md) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | v1 | | Refresh token bound to the AS-1 login session (`sid`), so logout revokes it | No; only `tsid` exists | Large | Later | | DPoP issuance and validation for `V` | No | Large | Later | | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | | Cedar at the gateway reading `act` | No | Part of the narrowing work | Later | -The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. +The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is under [external consent link](#external-consent-link); its requirement is in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. Until the narrowing work lands, the broker alone enforces the agent ceiling; the gateway enforces user and connection. +## External consent link + +In external mode the consent link maps AS-1's subject to AS-2's native user and is the user's first-hand statement at the gateway that Mecatl may act for them. The mechanisms below are candidates, not decisions. + +**Record:** (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, granted at, scope or providers). AS-2 redeems an ID-JAG only when its `iss`, `sub`, and `client_id` match a link; the link supplies the native user. + +**Creation, once per user and gateway:** + +1. The first call fails with "link required", returned through the existing `mcp_authorization_pending` path. +2. Mecatl starts an authorization request at AS-2 carrying an AS-1-signed hint that names the AS-1 subject, such as a `login_hint_token` or a signed request object. +3. The user authenticates through AS-2's own login and approves "Allow Mecatl (as alice@acme) to use your connections". +4. AS-2 records the link and redirects back; the run resumes. + +**Link swapping.** An attacker starts a link flow with their own AS-1 subject and tricks the victim into completing it. The victim's gateway account is then linked to the attacker's AS-1 subject, and the attacker's Mecatl sessions use the victim's upstream credentials. Defenses: + +- PKCE and `state` bind the flow to the browser that started it; +- the consent screen shows which Mecatl account is being linked; +- where possible, the identity consistency check below. + +**Shared upstream IdP.** When AS-1 and AS-2 federate to the same company IdP: + +- **Consistency check:** at link time, AS-2 compares the IdP identity the user just authenticated with against the upstream IdP identity in AS-1's signed hint, and refuses on mismatch. This closes link swapping. +- **Silent step:** AS-2's login is silent through single sign-on, and the gateway administrator may pre-approve the consent screen for client `mecatl`. The link becomes a one-time redirect bounce without UI, which Mecatl can run right after the user's first sign-in. Pre-approval is safe here, unlike blanket admin pre-consent, because the IdP and AS-2 still witness the identity first-hand. +- **The bounce is still required.** A shared IdP does not let AS-2 accept AS-1's `sub_id = (IdP, user)` directly: AS-1, not the IdP, signs that claim (§9.5), so a compromised AS-1 could name any user. + +**Removing the link entirely** has two routes, each with a stated cost: + +- **Risk decision:** the gateway administrator trusts AS-1 to assert IdP identities for all users, optionally limited to a group. A compromised AS-1 then reaches every such user's gateway credentials, which is embedded-mode trust applied to an external gateway. +- **Future enterprise path:** the company IdP itself issues the ID-JAG for AS-2 (XAA at the IdP), with AS-1 as the IdP's client using its stored upstream refresh token. It needs IdP support and loses `act` at the gateway. + ## Definition provenance -- Mecatl: `AgentDefSource` resolution returns source evidence; the trusted resolver produces the [provenance record](agent-definitions.md#definition-provenance) with both digests, rejects profiles the operator has not allowed, and registration carries the record. Prove a locally modified file yields a source digest that does not match its claimed commit's content. +- Mecatl: `AgentDefSource` stays storage-independent: provenance profiles travel as metadata, not as additions to the core interface. Each profile defines required fields, schema, hashing rules, and revision semantics. `AgentDefSource` resolution returns source evidence; the trusted resolver produces the [provenance record](agent-definitions.md#definition-provenance) with both digests, rejects profiles the operator has not allowed, and registration carries the record. Prove a locally modified file yields a source digest that does not match its claimed commit's content. - Broker: validate the record against its profile contract and the operator's evidence requirements, retain it with the instance record, and mark verified versus asserted facts in audit. ## Workload-specific verification diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 30d036af7e..54e4f616bd 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -18,7 +18,7 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report: mecak8s validates her AS-1 access token and enforces session ownership, then passes the token to the broker once at `OpenSession`. The broker exchanges it at AS-1 for a broker refresh token bound to that session, and every later ID-JAG names the user from that refresh token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). In v1, user, run, and delegation are not Cedar inputs. Run and delegation context remain trusted harness reports. Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). In v1, user, run, and delegation are not Cedar inputs. Run and delegation context remain trusted harness reports. Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority @@ -30,7 +30,7 @@ For example, a read-only reviewer spawns a child from a `deployer` definition wh The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. -The gateway sees delegation only as identity, not as authority. AS-1 writes the acting instance as the outer `act` in the ID-JAG, and the instance that delegated the task as a nested `act`, each as an opaque `agent:` identifier; AS-2 copies the chain into `V`. vMCP binds its session to the outer `act.sub`; inner levels serve audit. Neither level carries the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). +The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `agent:` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). ## Operations and resources @@ -38,13 +38,23 @@ V1 distinguishes registered MCP servers and tools, but does not evaluate resourc For example, a reviewer whose resolved ceiling includes `pull_request_read` on the registered payments server can pass the ceiling check for a read, but cannot pass it for `pull_request_merge` if merge is excluded. Reading PR 42 and reading PR 43 with that same server/tool pair produce the same ceiling result. The broker's v1 Cedar check cannot establish that the reviewer stayed within Alice's PR-42-only request. Existing connection and downstream checks still apply to either call. +Future broker policies decide on what the broker knows first-hand: + +- the user and the identity-provider attributes AS-1 carries for them; +- the agent instance, with its definition and provenance; +- the MCP server and tool; +- the resources the request names in its arguments; +- the session and delegation context. + +They do not decide on provider connections or provider-side permissions; the gateway and the provider own those. Argument-based policy checks what was requested, not what the backend does. A tool whose effects its arguments do not bound, such as a code search across every repository the user can see, is restricted through the ceiling instead. Resource policy also needs a per-tool mapping from arguments to resources, such as which argument names the repository. + Agent-instance identity does not prevent a prompt-injected agent from misusing operations within its permitted authority. The broker constrains protected MCP calls through independent authorization checks; it does not establish that an allowed call faithfully serves the user's intent or protect the entire agent loop. ## User token and the authorized request -Mecatl validates Alice's AS-1 access token for its API, checks current session ownership, and passes the token to the broker once at `OpenSession`. The broker converts it at AS-1 into a session-bound broker refresh token; no company IdP token is forwarded to AS-1 or the broker. On a cache miss, the broker presents that refresh token as `subject_token` and the acting instance's SVID as `actor_token`, authenticating as OAuth client `mecatl` with `private_key_jwt`. AS-1 checks that the session is open and the instance is registered to it, then issues an ID-JAG naming Alice and the actor. The broker redeems the ID-JAG at AS-2 as the same client and receives `V`. Neither the AS-1 access token, the refresh token, nor the SVID reaches AS-2. Model-generated arguments cannot choose or override identity evidence. See [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization) and the steps after it for token types, fields, and validation. +Mecatl validates Alice's AS-1 access token for its API and checks current session ownership. The broker turns it into a session-bound broker refresh token at AS-1; no company IdP token reaches AS-1 or the broker. AS-1 issues an ID-JAG naming Alice and the acting instance only while the session is open and the instance is registered to it, and the broker redeems that ID-JAG at AS-2 for `V`. Neither the AS-1 access token, the refresh token, nor the SVID reaches AS-2. Model-generated arguments cannot choose or override identity evidence. The exchange itself is described [below](#authorization-credentials-and-dispatch); see [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization) and the steps after it for token types, fields, and validation. -The broker checks the actual MCP server/tool pair against the effective ceiling before any exchange or dispatch. User and connection authorization are enforced downstream: AS-2 redeems the ID-JAG only for a mapped user (embedded) or a consent-linked user (external), and vMCP dispatches only with the user's stored credential, keyed by (gateway, native user, provider). Whether the broker also needs its own connection evidence before dispatch is open. The final request, including arguments, must remain bound to the checks and dispatch, but recording or binding arguments does not mean v1 Cedar authorizes their resource-specific meaning. +The broker checks the actual MCP server/tool pair against the effective ceiling before any exchange or dispatch. User and connection authorization are enforced downstream: AS-2 redeems the ID-JAG only for a mapped user (embedded) or a consent-linked user (external), and vMCP dispatches only with the user's stored credential, keyed by (gateway, native user, provider). The broker does not check provider connections itself: it could only copy the gateway's data, and the provider enforces its own permissions on the request. The final request, including arguments, must remain bound to the checks and dispatch, but recording or binding arguments does not mean v1 Cedar authorizes their resource-specific meaning. Request transformations must occur before the applicable checks, or the changed request must be checked again. Dispatch must use the checked request. @@ -59,7 +69,7 @@ In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execut Both requests also require valid credentials and execution-context checks, and must pass local checks and the downstream AS-2 and vMCP checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. -For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. Whether the broker should hold its own connection evidence before dispatch, in addition to these downstream checks, is open. See [provider-connection selection](credentials-and-lifecycle.md#provider-connection-selection). +For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. The broker holds no connection evidence of its own. See [provider-connection selection](credentials-and-lifecycle.md#provider-connection-selection). ## Authorization, credentials, and dispatch diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index b03c34d7cb..56d72a6de0 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -52,7 +52,7 @@ sequenceDiagram Note over B,P: Any denial before provider dispatch stops the request opt No cached V for (A42, reviewer, gateway) B->>A1: (1) RFC 8693 + private_key_jwt
subject_token = RT-A42, actor_token = reviewer SVID
requested_token_type = id-jag, audience = AS-2 - A1->>A1: Resolve RT-A42 record: session open, not expired
Validate SVID, reviewer registered to A42
Build nested act + A1->>A1: Resolve RT-A42 record: session open, not expired
Validate SVID, reviewer registered to A42
Build act A1-->>B: (2) ID-JAG (sub u-alice-91, act, client_id mecatl) B->>A2: (3) RFC 7523 assertion = ID-JAG + private_key_jwt A2->>A2: Check typ, aud, client_id, jti
Resolve native user
External mode: require consent link @@ -67,7 +67,7 @@ sequenceDiagram A cache hit skips steps (1) through (4), not the broker checks or the vMCP's own validation and credential lookup. -The broker checks run before any exchange. They establish that the session is open and bound to the authenticated workload, that the SVID names an instance registered to `A42`, and that the instance's effective ceiling allows this server and tool. They do not establish that AS-2 can resolve Alice or that a provider connection exists. AS-2 redeems only for a mapped user, and in external mode only for a linked one; the vMCP dispatches only with a stored credential for that user. Whether the broker also needs its own connection evidence before dispatch remains open. No denial at the broker, AS-1, AS-2, or vMCP permits a provider call. +The broker checks run before any exchange. They establish that the session is open and bound to the authenticated workload, that the SVID names an instance registered to `A42`, and that the instance's effective ceiling allows this server and tool. They do not establish that AS-2 can resolve Alice or that a provider connection exists. AS-2 redeems only for a mapped user, and in external mode only for a linked one; the vMCP dispatches only with a stored credential for that user. No denial at the broker, AS-1, AS-2, or vMCP permits a provider call. ### (1) RFC 8693 token exchange at AS-1 @@ -91,7 +91,7 @@ AS-1 checks: - **The client.** The assertion verifies against `mecatl`'s registered key. - **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user. -- **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. The SVID's audience is a configured shared broker audience accepted by both AS-1's actor validator and the broker's invocation validator. JWT-SVID does not require `iss` or `jti`. +- **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. - **The target.** `gateway.example.com` is an allowed AS-2 for this client. AS-1 authorizes the client, subject, actor, and target as one combination, not any set of valid signatures. Token validation alone does not prove agent registration, delegation, or run context; those depend on trusted lifecycle and request context. @@ -118,7 +118,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "act": {"sub": "agent:reviewer-gh56"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 @@ -127,7 +127,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. -`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). Each level carries an opaque, stable `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is omitted; the ID-JAG's own `iss` is AS-1. The outer level is the acting instance, and only it identifies the actor for vMCP session binding and gateway policy. The inner level is the instance that delegated this task; it serves audit. The chain records task delegation, not creation ancestry, and its depth is capped. Where AS-1 obtains the delegating instance remains open: `InvokeTool` does not carry delegation context today. +`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is omitted; the ID-JAG's own `iss` is AS-1. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. `aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). @@ -168,22 +168,20 @@ An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked "sub": "usr-alice-7c1d", "aud": "https://gateway.example.com/mcp", "client_id": "mecatl", - "act": {"sub": "agent:reviewer-gh56", "act": {"sub": "agent:main-cejr"}}, + "act": {"sub": "agent:reviewer-gh56"}, "iat": 1800000005, "exp": 1800000120, "jti": "access-pr42-001" } ``` -This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT access token omits its signature. AS-2 issues its own `iss`, puts its native user ID in `sub`, and copies the nested `act` unchanged. Its lifetime does not exceed the ID-JAG's. AS-2 issues no refresh token (ID-JAG §4.4.3); when `V` expires, the broker obtains a new ID-JAG from `RT-A42`. The broker caches `V` per session, instance, and gateway, and runs its checks before every use of a cached token. +This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT access token omits its signature. AS-2 issues its own `iss`, puts its native user ID in `sub`, and copies `act` unchanged. Its lifetime does not exceed the ID-JAG's. AS-2 issues no refresh token (ID-JAG §4.4.3); when `V` expires, the broker obtains a new ID-JAG from `RT-A42`. The broker caches `V` per session, instance, and gateway, and runs its checks before every use of a cached token. ### (5) Dispatch The broker sends the checked `pull_request_read` request with `V` to the vMCP. The vMCP validates `V` against AS-2's keys, `typ`, audience, and expiry, applies its own operation policy, and loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. GitHub sees Alice's GitHub token, not the ID-JAG, the SVID, or `V`. If no credential is stored, the vMCP answers "needs authorization" and the broker returns the existing `mcp_authorization_pending` outcome. The design does not claim that downstream credentials establish a backend-to-SaaS audit trail. -Open-source ToolHive keys stored credentials by `tsid`, a per-interactive-login session ID, and `V` carries none. Standalone deployments need a per-user lookup; the enterprise connector gateway already stores credentials per user. - -AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. Their audit references distinguish ID-JAG `jti` `jag-pr42-001` from access-token `jti` `access-pr42-001`, and neither replaces operation `op-pr42-001`. Audit requirements and evidence limits are described in [Audit](audit.md). +AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. Their audit references distinguish ID-JAG `jti` `jag-pr42-001` from access-token `jti` `access-pr42-001`, and neither replaces operation `op-pr42-001`. The broker propagates `op-pr42-001` with the MCP request, and the vMCP logs it as reported trace context; this is required for correlation, and the mechanism is still open. Audit requirements and evidence limits are described in [Audit](audit.md). ## Gateway administrator configuration @@ -202,7 +200,7 @@ Mecatl's operator configures AS-1 with the `mecatl` client, the accepted agent t Each agent instance has its own vMCP session (`Mcp-Session-Id`). The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. - Sessions are never shared between instances. -- The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and outer `act.sub`. A token naming another instance in its outer `act` cannot use the session. Inner `act` levels do not affect the binding. +- The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and `act.sub`. A token naming another instance in its `act` cannot use the session. - The run is not part of the session or token. The run ID travels in trace headers. - The broker stores each instance's session ID. After a restart it reattaches. When the vMCP reports the session gone (idle timeout or eviction), the broker creates a new one. A wedged session can be reset explicitly. - An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. @@ -217,16 +215,14 @@ This keeps one protocol path for both modes. It does not add isolation: anyone w - **Revocation** beyond short ID-JAG and access-token lifetimes, consent-link revocation, and `RT-A42`'s hard expiry. - **Structured gateway outcomes.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome remains open. So does whether the "Allow Mecatl" step and the provider's consent screen can be one redirect chain. -- **Scheduled runs** when consent or a connection is missing: whether they fail and notify or wait remains open. -- **Broker connection evidence.** Whether the broker needs its own evidence of a provider connection before dispatch remains open. - **Gateway authorization on `act`**, such as Cedar policies that consider the acting agent. Until the agent ceiling is carried in the ID-JAG and `V` as scope or `authorization_details` (RFC 9396), the broker alone enforces it; the gateway enforces user and connection. ## Integration requirements and evidence limits -AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state, hard expiry, and the actor's registration to the session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG with nested opaque `act`. +AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state, hard expiry, and the actor's registration to the session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `agent:` identifier. -AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves the nested `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. +AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. -The vMCP must validate `V`, bind its session to the user and outer `act.sub`, and load provider credentials keyed by (gateway, native user, provider). Do not add a callback or response fields that expose AS-2's private native mappings to the broker. The design does not claim a backend-to-SaaS audit trail. +The vMCP must validate `V`, bind its session to the user and `act.sub`, and load provider credentials keyed by (gateway, native user, provider). Do not add a callback or response fields that expose AS-2's private native mappings to the broker. The design does not claim a backend-to-SaaS audit trail. Embedded and external deployments implement the same protocol roles and token formats. Embedded wiring uses local configuration, separate issuer keys and storage namespaces, and implicit consent; external wiring uses gateway-administrator trust and the consent link. Co-location is not isolation. From 5b39578c14dc2215f681dbd85d8673c57a354900 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 12:44:05 +0200 Subject: [PATCH 15/50] docs: move D6 and D7 under Decisions in the rework record Co-Authored-By: mecatl --- .../agent-identity/credential-flow-rework.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md index 037248b57c..dc221084c9 100644 --- a/docs/drafts/agent-identity/credential-flow-rework.md +++ b/docs/drafts/agent-identity/credential-flow-rework.md @@ -193,6 +193,21 @@ Out of scope for this version: attesting per-agent identity, for example per-age Reviewed and not selected: an `InvokeTool` actor field accepting either an instance reference or an SVID. +### D6: the broker does not check provider connections + +The broker authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user through the mapping and, in external mode, the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions. + +The original design required the broker to hold its own authorized-connection evidence before dispatch. Nothing supplies that evidence. In external mode the mapping and connection records belong to the gateway operator, so every call would fail closed. The broker could only copy the gateway's data, which is not an independent check. Each party therefore decides what it knows first-hand. + +Future broker Cedar policies stay request-shaped: user and identity-provider attributes from AS-1, agent instance and definition provenance, server and tool, resources named in the arguments, and session and delegation context. If vMCP reports the connection it used, the broker records it as observed. A pre-run connection status query, if added later, is advisory and used for UX only, never as an allow or deny input. + +### D7: scheduled runs are pre-authorized + +- **Authority.** A schedule has its own delegated authority from the user: created at schedule setup, bound to the schedule rather than a session, with an explicit horizon, and revoked when the schedule is deleted. Session-bound authority cannot serve schedules, because it is revoked when the session closes. +- **Pre-authorization.** At setup, every connector the schedule may use is connected: the gateway link in external mode, and the provider connection in every mode. Gaps are fixed interactively then. +- **At run time,** nothing is interactive. A lapsed link, credential, or authority fails the call. The run ends with "needs owner action", and the owner is notified through a link into Mecatl, never a raw connect URL. +- **Renewal** extends the horizon and reconnects lapsed connectors without recreating the schedule. The owner is notified ahead of the horizon. + ## User-visible flow - **Sign in** once per login session, through AS-1 to the company IdP; usually silent with single sign-on. @@ -220,21 +235,6 @@ What each decision asks of ToolHive, and whether it exists today. Current behavi | Later | DPoP issuance and validation | No | | D4 (C) | Scope or `authorization_details` narrowing enforced per tool, including on `act` | Partly | -### D6: the broker does not check provider connections - -The broker authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user through the mapping and, in external mode, the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions. - -The original design required the broker to hold its own authorized-connection evidence before dispatch. Nothing supplies that evidence. In external mode the mapping and connection records belong to the gateway operator, so every call would fail closed. The broker could only copy the gateway's data, which is not an independent check. Each party therefore decides what it knows first-hand. - -Future broker Cedar policies stay request-shaped: user and identity-provider attributes from AS-1, agent instance and definition provenance, server and tool, resources named in the arguments, and session and delegation context. If vMCP reports the connection it used, the broker records it as observed. A pre-run connection status query, if added later, is advisory and used for UX only, never as an allow or deny input. - -### D7: scheduled runs are pre-authorized - -- **Authority.** A schedule has its own delegated authority from the user: created at schedule setup, bound to the schedule rather than a session, with an explicit horizon, and revoked when the schedule is deleted. Session-bound authority cannot serve schedules, because it is revoked when the session closes. -- **Pre-authorization.** At setup, every connector the schedule may use is connected: the gateway link in external mode, and the provider connection in every mode. Gaps are fixed interactively then. -- **At run time,** nothing is interactive. A lapsed link, credential, or authority fails the call. The run ends with "needs owner action", and the owner is notified through a link into Mecatl, never a raw connect URL. -- **Renewal** extends the horizon and reconnects lapsed connectors without recreating the schedule. The owner is notified ahead of the horizon. - ## Open - Whether to adopt either SVID strengthening listed in D5, the session claim or key binding. From c872f089cc52c3f6616781768d4a0b8cdff3179d Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:57:45 +0200 Subject: [PATCH 16/50] docs: illustrate agent identity relationships and lifetimes Add a Relationships and lifetimes section to agent-definitions.md with a diagram, a lifecycle table, and an explanation of creation ancestry versus execution delegation. Folded from #2347 (squashed from 5f3d3a83f7 and da695f731c). Co-Authored-By: Codex --- .../agent-identity/agent-definitions.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 989fc70d5d..a17e81ef1d 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -18,6 +18,52 @@ An agent definition describes how an agent should perform work. Mecatl represent The existing implementation persists a [session incarnation](../../../engine/session/incarnation.go) on each session aggregate, including child sessions. The proposal uses that durable incarnation for agent-instance identity; it is not the host session ID or a run ID. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). +## Relationships and lifetimes + +An agent definition can be reused by many agent instances. Each instance has its +own identity and persisted context, and can perform several runs over its lifetime. + +```mermaid +flowchart LR + D["AgentDef
Reusable definition"] + S["Session
Persisted context"] + I["Agent instance
One particular agent"] + R["Run
One unit of work"] + C["Child agent instance
Own identity, session, and runs"] + + D -->|"one definition, many instances"| I + S ---|"backs this instance"| I + I -->|"one instance, many runs"| R + I -->|"may create child instances"| C +``` + +Here, *Session* means the persisted context for one agent instance. A child has +its own child Session. The root session and its children form the wider session +tree. Each child follows the same relationships shown for the parent instance, +and can use a different selected definition. A root instance has no creation +parent; a child has one creation-parent link. + +Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in +separate runs. The selected definition stays fixed for the instance. Pausing for +approval and resuming continues the same run, including after restoration. + +| Event | Instance identity | Run | +| --- | --- | --- | +| Start a new root session | Create a new main instance. | New work starts a run. | +| Assign more work to an existing instance | Keep the same instance and selected definition. | Start another run. | +| Create a child agent | Create a distinct instance with its own Session and a creation-parent link. | The child's work has its own run. | +| Pause and resume unfinished work | Keep the same instance. | Continue the existing run. | +| Restore after a process restart | Recover the original instance, selected definition, and creation-parent link. | Continue a recoverable unfinished run; later work starts a new run. | + +Creation ancestry and execution delegation describe different relationships. The +creation-parent link records which instance created a child. A delegation links +the run assigning work to the run performing it. The child's identity remains +stable across its runs; each run supplies its own execution context. See +[registering instances](#registering-instances). + +The [domain model](../../architecture/mecatl.modelith.md) provides the broader +execution model; this view focuses on agent identity and continuity. + ## What AgentDef contains The existing [`AgentDef`](../../../engine/tool/agentsource.go) combines several concerns: From d830782b037c27af9bb90b3d04a24fae75521238 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 12:59:37 +0200 Subject: [PATCH 17/50] docs: separate host and broker sessions in the agent identity glossary Each instance has its own host session, children linked by ParentSessionID; the broker session covers the whole session tree. Say so in the glossary, and call A42 the broker session where AS-1 checks instance registration. In the relationships section, add the run-to-run delegation edge to the diagram, note that an unrecoverable run fails closed on restore, and unwrap the prose to match the file. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 2 +- .../agent-identity/agent-definitions.md | 38 ++++++++----------- docs/drafts/agent-identity/audit.md | 2 +- .../agent-identity/mcp-authorization.md | 2 +- .../agent-identity/mcp-gateway-modes.md | 2 +- 5 files changed, 19 insertions(+), 27 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 8a933c0abb..e43abd5e94 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -78,7 +78,7 @@ The broker contains the agent trust issuer, authorization and audit logic, and A Alice signs in through AS-1, which redirects her to the company IdP. Her client then calls mecak8s with an AS-1 access token. mecak8s validates that token and current session ownership. At session open it passes the token to the broker once, and the broker exchanges it at AS-1 for a refresh token bound to that broker session. On each tool call the broker presents that refresh token and the acting agent's JWT-SVID to AS-1, which returns an ID-JAG naming Alice and the agent. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. The broker is a single OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`. AS-2 maps the AS-1 subject to its native user; an external gateway also requires a one-time consent link created at AS-2's own login. AS-2 receives neither Alice's AS-1 token nor the agent SVID. See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. The tools themselves execute the operations. -AS-1 witnessed Alice's sign-in itself, checks that the acting instance is registered to the session, and authorizes the client, subject, actor, and target. It does not prove that the agent registration, ancestry, run, or delegation reported by trusted lifecycle code is genuine. The agent loop must not choose those facts, and distinct agent credentials do not isolate agents sharing one `mecak8s` process. +AS-1 witnessed Alice's sign-in itself, checks that the acting instance is registered to the broker session, and authorizes the client, subject, actor, and target. It does not prove that the agent registration, ancestry, run, or delegation reported by trusted lifecycle code is genuine. The agent loop must not choose those facts, and distinct agent credentials do not isolate agents sharing one `mecak8s` process. ## Following an action through the architecture diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index a17e81ef1d..fe9f301400 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -9,10 +9,11 @@ An agent definition describes how an agent should perform work. Mecatl represent | Term | Meaning | | --- | --- | | Definition | A kind of agent, identified with its source and admitted revision, not just its name. | -| Session | The harness's persisted context for work, identified by a host session ID such as `S42`. | -| Agent instance | One use of a selected definition in a session, with its own identity and any creation-parent association. A delegated child has its own instance. | +| Host session | The harness's persisted context for one agent instance, identified by a host session ID such as `hostS42`. A child instance has its own child session, linked to its parent's by `ParentSessionID`; a root session and its children form a session tree. | +| Broker session | The broker's session for one session tree, opened once for the root session, such as `A42`. Every instance in the tree registers to it. | +| Agent instance | One use of a selected definition, backed by its own host session, with its own identity and any creation-parent association. A delegated child has its own instance. | | Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | -| Execution context | The workload, acting instance, session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the session. Run and delegation context remain mecak8s-reported in v1. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | +| Execution context | The workload, acting instance, broker session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the broker session. Run and delegation context remain mecak8s-reported in v1. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | | Agent-instance incarnation | The durable identity allocated by mecak8s for that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | | Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | @@ -20,32 +21,28 @@ The existing implementation persists a [session incarnation](../../../engine/ses ## Relationships and lifetimes -An agent definition can be reused by many agent instances. Each instance has its -own identity and persisted context, and can perform several runs over its lifetime. +An agent definition can be reused by many agent instances. Each instance has its own identity and persisted context, and can perform several runs over its lifetime. ```mermaid flowchart LR D["AgentDef
Reusable definition"] - S["Session
Persisted context"] + S["Host session
Persisted context"] I["Agent instance
One particular agent"] R["Run
One unit of work"] C["Child agent instance
Own identity, session, and runs"] + CR["Child run"] D -->|"one definition, many instances"| I S ---|"backs this instance"| I I -->|"one instance, many runs"| R I -->|"may create child instances"| C + C -->|"one instance, many runs"| CR + R -.->|"delegates work to"| CR ``` -Here, *Session* means the persisted context for one agent instance. A child has -its own child Session. The root session and its children form the wider session -tree. Each child follows the same relationships shown for the parent instance, -and can use a different selected definition. A root instance has no creation -parent; a child has one creation-parent link. +Here, the host session is the persisted context for one agent instance. A child has its own child session. The root session and its children form the session tree, which shares one broker session. Each child follows the same relationships shown for the parent instance, and can use a different selected definition. A root instance has no creation parent; a child has one creation-parent link. -Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in -separate runs. The selected definition stays fixed for the instance. Pausing for -approval and resuming continues the same run, including after restoration. +Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in separate runs. The selected definition stays fixed for the instance. Pausing for approval and resuming continues the same run, including after restoration. | Event | Instance identity | Run | | --- | --- | --- | @@ -53,16 +50,11 @@ approval and resuming continues the same run, including after restoration. | Assign more work to an existing instance | Keep the same instance and selected definition. | Start another run. | | Create a child agent | Create a distinct instance with its own Session and a creation-parent link. | The child's work has its own run. | | Pause and resume unfinished work | Keep the same instance. | Continue the existing run. | -| Restore after a process restart | Recover the original instance, selected definition, and creation-parent link. | Continue a recoverable unfinished run; later work starts a new run. | +| Restore after a process restart | Recover the original instance, selected definition, and creation-parent link. | Continue a recoverable unfinished run, or fail closed; later work starts a new run. | -Creation ancestry and execution delegation describe different relationships. The -creation-parent link records which instance created a child. A delegation links -the run assigning work to the run performing it. The child's identity remains -stable across its runs; each run supplies its own execution context. See -[registering instances](#registering-instances). +Creation ancestry and execution delegation describe different relationships. The creation-parent link records which instance created a child. A delegation links the run assigning work to the run performing it. The child's identity remains stable across its runs; each run supplies its own execution context. See [registering instances](#registering-instances). -The [domain model](../../architecture/mecatl.modelith.md) provides the broader -execution model; this view focuses on agent identity and continuity. +The [domain model](../../architecture/mecatl.modelith.md) provides the broader execution model; this view focuses on agent identity and continuity. ## What AgentDef contains @@ -94,7 +86,7 @@ The broker ceiling is the set of permitted registered MCP server/tool pairs reso In the proposed lifecycle, mecak8s's trusted session lifecycle code resolves the session's definitions from configured sources, such as administrator-managed files, repositories, or APIs. It persists the fixed selection, source associations, revisions, and authority ceilings, and supplies execution configuration to the harness. This is a responsibility within mecak8s, not a new registrar component. -For Alice's session `S42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. +For Alice's session `hostS42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition, and the default anonymous child uses `system/explorer`; neither requires a definition file. Built-in definitions are registered and ceiling-resolved like any other, with the Mecatl release that ships them as their source and revision. `system/main` grants no name-based privilege: Alice's and Bob's default mains share the definition but not instances, users, or authority. A session started with a named definition, such as `project/code-reviewer`, registers that definition as its main instead. Whether the harness constructs the main loop from a real `AgentDef` or a built-in equivalent is an implementation choice. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 954891e4f7..2fb050693a 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -18,7 +18,7 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | | Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. The delegation lives in Mecatl's harness and broker records only; the ID-JAG and `V` name the current actor, not the instance that delegated the task. | | Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, and actor checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. An allow proves neither dispatch nor downstream success. | -| ID-JAG issuance | AS-1 records each issuance: user from the broker refresh token's session record, session, validated actor, target AS-2, and ID-JAG `jti`. AS-1 validated the SVID and checked that the instance is registered to the session. | AS-1 does not see the user again per call; the user comes from the session record. Instance membership comes from the broker registry. | +| ID-JAG issuance | AS-1 records each issuance: user from the broker refresh token's session record, session, validated actor, target AS-2, and ID-JAG `jti`. AS-1 validated the SVID and checked that the instance is registered to the broker session. | AS-1 does not see the user again per call; the user comes from the session record. Instance membership comes from the broker registry. | | AS-2 redemption and consent | AS-2 records each redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. In external mode, AS-2 also records creation and revocation of the consent link. | AS-2 trusts AS-1's signature and receives neither the AS-1 access token nor the SVID. In external mode the consent link is AS-2's own evidence that the user chose Mecatl; in embedded mode consent is implicit. | | vMCP decision | vMCP records its decision with the access-token reference `(iss, jti)`, native user, actor, tool, outcome, and the propagated operation ID | The gateway sees only the current actor in `act.sub`, not who delegated the task. The operation ID is reported, unverified trace context, as is other forwarded context unless independently verified. | | Observed result and provider correlation | The response received by the recording component and safe provider references, when available | A broker-observed MCP response is not independent proof of provider-side effects. | diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 54e4f616bd..4d6ca80565 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -60,7 +60,7 @@ Request transformations must occur before the applicable checks, or the changed ## Two requests, two authorization decisions -In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), Alice owns session `S42`. The main instance lists pull requests, then delegates review of PR 42 to a reviewer. The tool names and arguments are illustrative, not a selected MCP server contract. +In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), Alice owns session `hostS42`. The main instance lists pull requests, then delegates review of PR 42 to a reviewer. The tool names and arguments are illustrative, not a selected MCP server contract. | Request | V1 effective-ceiling check | | --- | --- | diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 56d72a6de0..e3fed7c3c4 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -219,7 +219,7 @@ This keeps one protocol path for both modes. It does not add isolation: anyone w ## Integration requirements and evidence limits -AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state, hard expiry, and the actor's registration to the session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `agent:` identifier. +AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state, hard expiry, and the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `agent:` identifier. AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. From 54812e3c16d88732170f2002a37e492d8db6e414 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 13:00:56 +0200 Subject: [PATCH 18/50] docs: describe the credential flow rework as a decision record The header still said the other chapters stay unchanged until the rework is complete. They have been reconciled; the record now holds the decisions and their rationale. Co-Authored-By: mecatl --- docs/drafts/agent-identity/credential-flow-rework.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md index dc221084c9..3329d2d90f 100644 --- a/docs/drafts/agent-identity/credential-flow-rework.md +++ b/docs/drafts/agent-identity/credential-flow-rework.md @@ -1,6 +1,6 @@ # Credential flow rework -*Work record, not current behavior. It collects decisions taken while reworking the credential flow in [credentials and lifecycle](credentials-and-lifecycle.md). The existing chapters stay unchanged until the rework is complete; then their diagrams and prose are replaced in one pass.* +*Decision record, not current behavior. It records the decisions behind the credential flow in [credentials and lifecycle](credentials-and-lifecycle.md) and their rationale. The other chapters describe the resulting design.* The flow was reviewed right to left, from the vMCP call to a SaaS provider back to the user's login. For each credential the review asked what it must contain, what it proves, and who issues and verifies it. The guiding question is provenance: who first established each fact, and how many signers relayed it before it reached the party that relies on it. From d9fb3c0df8533cb113911f273596eb0dc9795abf Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 13:43:54 +0200 Subject: [PATCH 19/50] docs: restore the definition loader as a broker module The loader had been folded into mecak8s lifecycle code. It is a broker module: it runs inside mecak8s while the broker is a modulith and moves with the broker when it becomes a separate service. At startup it loads the global agent definitions and records their sources, revisions, and ceilings in the broker; loading of other definitions is undecided. mecak8s now names definitions by reference when it registers instances instead of supplying their content and ceilings. Show the loader in the architecture and walkthrough diagrams, put the agent-instance issuer inside the broker box, and split the audit evidence row so loaded definitions and mecak8s-asserted selection and ancestry are distinct. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 14 ++++++++------ docs/drafts/agent-identity/agent-definitions.md | 8 +++++--- docs/drafts/agent-identity/audit.md | 3 ++- .../agent-identity/credentials-and-lifecycle.md | 9 +++++++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index e43abd5e94..a9dfba3dcf 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -33,7 +33,7 @@ The user is the person on whose behalf the agents act. The workload is the Mecat ## Establishing an agent's identity -The [agent-definition loader](agent-definitions.md#loading-definitions-for-a-session) resolves definitions from configured sources, such as administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. +The [agent-definition loader](agent-definitions.md#loading-definitions) resolves definitions from configured sources, such as administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. The existing `AgentDef` structure currently combines instructions, execution settings, and tool configuration. This design proposes a split that gives the harness the configuration needed to execute the agent and the broker the definition records and authority ceilings needed to authorize MCP calls. Source and revision records still need a representation. Changing a turn limit should not redefine the agent's identity; loading a definition should not grant access to a user's accounts. @@ -41,7 +41,7 @@ The existing `AgentDef` structure currently combines instructions, execution set The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. -The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. An **agent-definition loader** supplies harness execution configuration and broker definition records and authority ceilings. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. +The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. An **agent-definition loader**, a broker module, loads definitions from configured sources into the broker and supplies the harness with execution configuration. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. @@ -49,11 +49,13 @@ This design scopes broker authorization to MCP tools. Extending that boundary to flowchart LR U["Client"] -->|"Sign-in"| A1 U -->|"Session creation and requests, AS-1 access token"| H["mecak8s"] - F["Agent-definition sources"] --> R["Agent-definition loader (mecak8s lifecycle)"] + F["Agent-definition sources"] -->|"Global definitions at startup"| R R -->|"Execution configuration"| H subgraph B["Identity broker"] + R["Agent-definition loader"] I["Authorization and audit
OAuth client mecatl"] A1["AS-1: login and ID-JAG issuer"] + R -->|"Definition records and ceilings"| I end A1 -->|"Redirect to company sign-in"| IdP["Company IdP"] subgraph W["vMCP gateway (embedded or external)"] @@ -61,7 +63,7 @@ flowchart LR V["vMCP"] T --- V end - R -->|"Workload-authenticated definition and instance registration"| I + H -->|"Workload-authenticated instance registration by definition reference"| I H <-->|"Workload-authenticated MCP calls; AS-1 access token once at session open; agent SVID per call"| I I -->|"RFC 8693: session refresh token, then ID-JAG per call"| A1 I -->|"RFC 7523: redeem ID-JAG for V"| T @@ -72,9 +74,9 @@ flowchart LR The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. -Before the agent loop starts, mecak8s's trusted session lifecycle code [loads and persists the fixed definition selection](agent-definitions.md#loading-definitions-for-a-session), supplies execution configuration, and [registers definitions and the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication. This loader responsibility is outside the untrusted loop, not a separate registrar or an isolation boundary. The registration API, durable schema, verified per-operation binding for reported context, and protection from same-process substitution remain open. +At startup, the broker's [definition loader](agent-definitions.md#loading-definitions) loads the global agent definitions from configured sources and records their sources, revisions, and ceilings in the broker. How other definitions, such as repository definitions, are loaded is not yet decided. Before the agent loop starts, mecak8s's trusted session lifecycle code persists the session's fixed definition selection and [registers the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication, naming its definition by reference. The registration API, durable schema, verified per-operation binding for reported context, and protection from same-process substitution remain open. -The broker contains the agent trust issuer, authorization and audit logic, and AS-1, Mecatl's login authorization server. The vMCP gateway contains AS-2, provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or as an external gateway. +The broker contains the definition loader, the agent trust issuer, authorization and audit logic, and AS-1, Mecatl's login authorization server. The vMCP gateway contains AS-2, provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or as an external gateway. Alice signs in through AS-1, which redirects her to the company IdP. Her client then calls mecak8s with an AS-1 access token. mecak8s validates that token and current session ownership. At session open it passes the token to the broker once, and the broker exchanges it at AS-1 for a refresh token bound to that broker session. On each tool call the broker presents that refresh token and the acting agent's JWT-SVID to AS-1, which returns an ID-JAG naming Alice and the agent. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. The broker is a single OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`. AS-2 maps the AS-1 subject to its native user; an external gateway also requires a one-time consent link created at AS-2's own login. AS-2 receives neither Alice's AS-1 token nor the agent SVID. See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. The tools themselves execute the operations. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index fe9f301400..86ec4c66e9 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -82,15 +82,17 @@ The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the a The broker ceiling is the set of permitted registered MCP server/tool pairs resolved under the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. At registration, the broker narrows it to each instance's [effective ceiling](mcp-authorization.md#delegated-authority). The existing definition-source snapshot contract is not, by itself, broker initialization. -## Loading definitions for a session +## Loading definitions -In the proposed lifecycle, mecak8s's trusted session lifecycle code resolves the session's definitions from configured sources, such as administrator-managed files, repositories, or APIs. It persists the fixed selection, source associations, revisions, and authority ceilings, and supplies execution configuration to the harness. This is a responsibility within mecak8s, not a new registrar component. +The **definition loader** is a broker module. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, resolves their ceilings, and records each definition's source, revision, and ceiling in the broker. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. + +Each session uses a fixed selection of loaded definitions. mecak8s's trusted session lifecycle code persists that selection and names definitions to the broker by reference; it does not supply their content or ceilings. For Alice's session `hostS42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition, and the default anonymous child uses `system/explorer`; neither requires a definition file. Built-in definitions are registered and ceiling-resolved like any other, with the Mecatl release that ships them as their source and revision. `system/main` grants no name-based privilege: Alice's and Bob's default mains share the definition but not instances, users, or authority. A session started with a named definition, such as `project/code-reviewer`, registers that definition as its main instead. Whether the harness constructs the main loop from a real `AgentDef` or a built-in equivalent is an implementation choice. -Before the main loop starts, mecak8s allocates and persists the main's durable agent-instance incarnation, then authenticates to the broker with its workload ServiceAccount to register the selected definitions and main instance with no parent. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. +Before the main loop starts, mecak8s allocates and persists the main's durable agent-instance incarnation, then authenticates to the broker with its workload ServiceAccount to register the main instance with no parent, naming its definition by reference. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. ## Registering instances diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 2fb050693a..1eab1f0716 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -13,7 +13,8 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | Evidence | Source and what it establishes | Limit | | --- | --- | --- | | Authenticated workload and instance | Broker validation of workload credentials and the instance JWT-SVID, with the registered session association | Does not authenticate the human or prove a task-specific delegation. | -| Definition source, admitted revision, ceilings, and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | +| Definition source, admitted revision, and ceilings | For global definitions, the broker's definition loader, which read them from configured sources at startup. How other definitions are loaded is not yet decided. | Records what the loader read and resolved. It does not show which loaded definition mecak8s selected for a session. | +| Definition selection and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | | Initiating user and session ownership | AS-1 ran the sign-in through the company IdP and issued an access token for the Mecatl API. mecak8s records that it validated the token for its API audience and checked that the company identity in the token's `sub_id` owns the host session. The record keeps both the owner (company identity) and the AS-1 subject. | Establishes that AS-1 authenticated the user at sign-in and that the user currently owns the session. It does not establish fresh human action for each call, task-specific delegation, or reported run context. | | Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | | Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. The delegation lives in Mecatl's harness and broker records only; the ID-JAG and `V` name the current actor, not the instance that delegated the task. | diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index d49e061ef9..3bc7beb8e6 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -47,8 +47,9 @@ The gateway in this example is external, at `https://gateway.example.com`. Embed sequenceDiagram actor U as Alice (TUI / Studio) participant H as mecak8s - participant I as Agent-instance issuer box Broker + participant L as Definition loader + participant I as Agent-instance issuer participant B as Broker core participant A1 as AS-1 end @@ -59,6 +60,10 @@ sequenceDiagram end participant G as GitHub + Note over L,B: (0) Broker startup + L->>L: Load global definitions from configured sources + L->>B: Definition records, sources, revisions, ceilings + Note over U,IdP: (1) Sign-in, once per login session U->>A1: Authorization code + PKCE
(issuer from mecak8s protected-resource metadata) A1->>IdP: Redirect to company sign-in
(AS-1 is an ordinary OIDC client there) @@ -75,7 +80,7 @@ sequenceDiagram rect rgb(245, 245, 245) Note over H,I: (4) Main bootstrap, before R41 - H->>I: registerDefinitions, registerInstance(main), workload JWT + H->>I: registerInstance(main, definition reference), workload JWT I-->>H: mainRef + canonical main subject H->>I: requestSVID(mainRef), workload JWT I-->>H: mainSVID From e4b91536bd521bd58cfac770e00df90c7f06aeb2 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 13:56:54 +0200 Subject: [PATCH 20/50] docs: show the definition loader in the relationships diagram Co-Authored-By: mecatl --- docs/drafts/agent-identity/agent-definitions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 86ec4c66e9..f7c3a6144c 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -21,10 +21,11 @@ The existing implementation persists a [session incarnation](../../../engine/ses ## Relationships and lifetimes -An agent definition can be reused by many agent instances. Each instance has its own identity and persisted context, and can perform several runs over its lifetime. +The broker's [definition loader](#loading-definitions) loads definitions up front. An agent definition can be reused by many agent instances. Each instance has its own identity and persisted context, and can perform several runs over its lifetime. ```mermaid flowchart LR + L["Definition loader
Broker module"] D["AgentDef
Reusable definition"] S["Host session
Persisted context"] I["Agent instance
One particular agent"] @@ -32,6 +33,7 @@ flowchart LR C["Child agent instance
Own identity, session, and runs"] CR["Child run"] + L -->|"loads at startup"| D D -->|"one definition, many instances"| I S ---|"backs this instance"| I I -->|"one instance, many runs"| R From eede8bd72cae314188b6cbb966c7b71ba75afdc5 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 14:04:11 +0200 Subject: [PATCH 21/50] docs: sequence identity work before authority work Add an Order of work section to the README. The identity work comes first: broker modules, definitions with provenance, instance registration, agent credentials, the credential flow, the broker's identity checks, and audit. The authority work follows: ceiling resolution, effective ceilings, Cedar generation and per-call checks, and delegation, user, resource, and argument policy. Until then the harness's existing delegation restrictions are the only ceiling checks. Replace every "v1" with plain language: the ceiling check belongs to the authority work, and the ToolHive work list marks identity-work items. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 25 +++++++++++++++- .../agent-identity/agent-definitions.md | 8 ++--- docs/drafts/agent-identity/audit.md | 4 +-- .../agent-identity/credential-flow-rework.md | 6 ++-- .../credentials-and-lifecycle.md | 8 ++--- docs/drafts/agent-identity/implementation.md | 30 +++++++++---------- .../agent-identity/mcp-authorization.md | 16 +++++----- 7 files changed, 60 insertions(+), 37 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index a9dfba3dcf..81c8b5f94b 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -86,12 +86,35 @@ AS-1 witnessed Alice's sign-in itself, checks that the acting instance is regist Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. -The broker checks the reviewer's effective server/tool ceiling, its definition ceiling narrowed by the main's, with Cedar. User and connection are enforced downstream: AS-2 redeems only for a mapped, and in external mode linked, user, and vMCP dispatches only with Alice's stored credential. V1 does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call through the authorized connection using its native provider credential. That credential stays outside the harness and model; in embedded mode it can reside inside the Mecatl deployment. If denied, no request reaches GitHub. +The broker checks the reviewer's effective server/tool ceiling, its definition ceiling narrowed by the main's, with Cedar. User and connection are enforced downstream: AS-2 redeems only for a mapped, and in external mode linked, user, and vMCP dispatches only with Alice's stored credential. This ceiling check is part of the [authority work](#order-of-work), which follows the identity work. Even then, the design does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call through the authorized connection using its native provider credential. That credential stays outside the harness and model; in embedded mode it can reside inside the Mecatl deployment. If denied, no request reaches GitHub. GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed credentials sequence. +## Order of work + +Mecatl builds this design in two steps. The identity work comes first, and the authority work follows once the identity work is in place. + +The **identity work** establishes who acts and records it: + +- the broker modules: definition loader, agent-instance issuer, AS-1, and broker core; +- definitions loaded with their provenance; +- instance registration, with the incarnation, definition reference, and creation parent; +- agent-instance credentials; +- the credential flow: sign-in at AS-1, the session-bound refresh token, ID-JAG issuance, AS-2 redemption, and per-user provider credentials at the gateway; +- the broker's identity checks: session, workload, and that the acting instance is registered to the broker session; +- audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. + +The **authority work** decides what each agent may do: + +- resolving each definition's ceiling into permitted MCP server/tool pairs; +- each instance's effective ceiling, its definition ceiling narrowed by its parent's; +- Cedar policy generation and a ceiling check on every call; +- task-specific delegation bounds, and user, resource, and argument policy. + +Until the authority work is in place, the harness's existing delegation restrictions are the only ceiling checks; the broker adds none. Registration already records the definition reference and creation parent, so the authority work adds computation, not a migration. The chapters describe the whole design and say which parts belong to the authority work. + ## Detailed chapters The proposed chapters develop each part of the architecture. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index f7c3a6144c..8c04b1e9b1 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -13,7 +13,7 @@ An agent definition describes how an agent should perform work. Mecatl represent | Broker session | The broker's session for one session tree, opened once for the root session, such as `A42`. Every instance in the tree registers to it. | | Agent instance | One use of a selected definition, backed by its own host session, with its own identity and any creation-parent association. A delegated child has its own instance. | | Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | -| Execution context | The workload, acting instance, broker session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the broker session. Run and delegation context remain mecak8s-reported in v1. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | +| Execution context | The workload, acting instance, broker session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the broker session. Run and delegation context remain mecak8s-reported. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | | Agent-instance incarnation | The durable identity allocated by mecak8s for that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | | Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | @@ -86,7 +86,7 @@ The broker ceiling is the set of permitted registered MCP server/tool pairs reso ## Loading definitions -The **definition loader** is a broker module. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, resolves their ceilings, and records each definition's source, revision, and ceiling in the broker. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. +The **definition loader** is a broker module. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. Once the [authority work](README.md#order-of-work) is in place, it also resolves and records each definition's ceiling. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. Each session uses a fixed selection of loaded definitions. mecak8s's trusted session lifecycle code persists that selection and names definitions to the broker by reference; it does not supply their content or ceilings. @@ -135,10 +135,10 @@ The record carries two digests: Parsing and normalization mean these differ. A commit plus a digest does not prove the bytes came from that commit; a locally modified file is the obvious counterexample. -The broker records provenance as a mecak8s assertion unless it independently verified a fact, and audit marks which. "mecak8s reports loading these bytes from commit X" is distinct from "the broker verified these bytes against commit X"; v1 requires only the former. Provenance lives in the broker's per-instance registration record, not in instance credentials. It grants no permission by itself; later policy may require approved revisions or particular source authorities. +The broker records provenance as a mecak8s assertion unless it independently verified a fact, and audit marks which. "mecak8s reports loading these bytes from commit X" is distinct from "the broker verified these bytes against commit X"; the design requires only the former for now. Provenance lives in the broker's per-instance registration record, not in instance credentials. It grants no permission by itself; later policy may require approved revisions or particular source authorities. ## Source records and policy representation The proposal adopts workload-authenticated mecak8s lifecycle assertions, host-allocated durable incarnations, and broker validation and subject construction as its semantic mechanism. The exact registration wire API, durable record schema, and verified per-operation binding for reported context remain unresolved, as does protection from same-process substitution. These are proposed semantics, not implemented contracts. -Cedar is selected for the effective server/tool ceiling. The representation of definition-source and revision records, registration API, and Cedar policies remains an implementation choice. V1 generates policy only from resolved definition facts; separately managed user, resource, argument, and dynamic delegation policies are deferred. Existing user/connection authorization checks remain required. +Cedar is selected for the effective server/tool ceiling. The representation of definition-source and revision records, registration API, and Cedar policies remains an implementation choice. The authority work generates policy only from resolved definition facts; separately managed user, resource, argument, and dynamic delegation policies are deferred. Existing user/connection authorization checks remain required. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 1eab1f0716..0d4b3e9d69 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status. The audit requirement applies to tool calls generally; the current design scope is broker-mediated MCP calls.* -The audit trail must explain which registered instance requested a call, on whose behalf, which effective ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. A v1 Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. +The audit trail must explain which registered instance requested a call, on whose behalf, which effective ceiling the broker evaluated, whether dispatch was attempted, and what result each observer received. Once the authority work is in place, a Cedar permit establishes only the [server/tool ceiling check](mcp-authorization.md#definition-derived-ceiling), not PR-specific delegated authority or user entitlement. The human's identity traces back to one sign-in that AS-1 witnessed through the company IdP. Later records about the user rely on that sign-in and AS-1's session record, not on a report from the harness. @@ -17,7 +17,7 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | Definition selection and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | | Initiating user and session ownership | AS-1 ran the sign-in through the company IdP and issued an access token for the Mecatl API. mecak8s records that it validated the token for its API audience and checked that the company identity in the token's `sub_id` owns the host session. The record keeps both the owner (company identity) and the AS-1 subject. | Establishes that AS-1 authenticated the user at sign-in and that the user currently owns the session. It does not establish fresh human action for each call, task-specific delegation, or reported run context. | | Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | -| Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that v1 Cedar enforced delegated resource bounds. Creation ancestry is separate. The delegation lives in Mecatl's harness and broker records only; the ID-JAG and `V` name the current actor, not the instance that delegated the task. | +| Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that the broker enforced delegated resource bounds. Creation ancestry is separate. The delegation lives in Mecatl's harness and broker records only; the ID-JAG and `V` name the current actor, not the instance that delegated the task. | | Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, and actor checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. An allow proves neither dispatch nor downstream success. | | ID-JAG issuance | AS-1 records each issuance: user from the broker refresh token's session record, session, validated actor, target AS-2, and ID-JAG `jti`. AS-1 validated the SVID and checked that the instance is registered to the broker session. | AS-1 does not see the user again per call; the user comes from the session record. Instance membership comes from the broker registry. | | AS-2 redemption and consent | AS-2 records each redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. In external mode, AS-2 also records creation and revocation of the consent link. | AS-2 trusts AS-1's signature and receives neither the AS-1 access token nor the SVID. In external mode the consent link is AS-2's own evidence that the user chose Mecatl; in embedded mode consent is implicit. | diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md index 3329d2d90f..de4fb6b7c8 100644 --- a/docs/drafts/agent-identity/credential-flow-rework.md +++ b/docs/drafts/agent-identity/credential-flow-rework.md @@ -123,7 +123,7 @@ When no credential or consent exists, vMCP or AS-2 refuses and the broker return client_assertion= ``` - This makes "accept only from the broker" enforceable. A leaked access token or broker refresh token is useless without the private key, which never leaves the broker and can live in a KMS or HSM. A refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5), so DPoP on it is redundant. The same client and key are registered at AS-2; in external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected because it is shared, replayable once leaked, and rotated by hand. mTLS (RFC 8705) is an equivalent but harder-to-deploy alternative. ToolHive already supports `private_key_jwt`, so v1 needs only configuration. + This makes "accept only from the broker" enforceable. A leaked access token or broker refresh token is useless without the private key, which never leaves the broker and can live in a KMS or HSM. A refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5), so DPoP on it is redundant. The same client and key are registered at AS-2; in external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected because it is shared, replayable once leaked, and rotated by hand. mTLS (RFC 8705) is an equivalent but harder-to-deploy alternative. ToolHive already supports `private_key_jwt`, so it needs only configuration. - **Future option: SPIFFE client authentication.** ToolHive's authserver already accepts a SPIFFE JWT-SVID as client authentication. The broker's workload identity could then serve as its OAuth client credential, with no separate key to manage. It is a place where SPIFFE authenticates a real workload, the broker, rather than an in-process agent. - What AS-1 issues where: an **access token** only at login (`typ=at+jwt`, `aud` = the Mecatl API), as clients and protected-resource-metadata discovery already expect. An `id_token` is not usable there: its audience is the client, not mecak8s or the broker. Per call AS-1 issues the ID-JAG, which is a grant rather than an access token. AS-1 never issues an access token for AS-2's audience. - **Alternative to the broker refresh token:** a signed session assertion JWT (`sub` = Alice, `aud` = AS-1, bound to the broker client and session, `jti`), presented as `subject_token_type=jwt`. It needs less ToolHive work, because exchange already issues and accepts JWTs. It is not stateless: one conversion per session, a maximum lifetime, and revocation still require an AS-1 session record looked up by `jti` on every exchange. It does not rotate, and it is a profile extension, since ID-JAG §4.3 lists identity assertions and refresh tokens as subject tokens. Like the refresh token, and unlike `E`, AS-1 issues it only after validating a real login token. @@ -150,7 +150,7 @@ When no credential or consent exists, vMCP or AS-2 refuses and the broker return A token format that carries these claims, a signed session assertion or a refresh token that AS-1 encodes internally as a signed JWT, adds readable copies of the same data. AS-1 must still look up the record for revocation and the one-conversion rule, and must still read the registry for instance membership. That format pays off only if AS-1 must validate without access to the store. - The broker refresh token is the subject of each ID-JAG exchange (ID-JAG §4.3 allows a refresh token as `subject_token`). It is the standard form of the human authority in `A42`. `A42` keeps only the workload and host-session binding. -- **v1 simplification:** the broker refresh token is not tied to the user's AS-1 login session. It has a hard maximum lifetime and is revoked when the broker session closes. Disabling the company account therefore takes effect at that maximum lifetime at the latest. Binding to the login session (`sid`), so logout revokes it, is later work. +- **Simplification for now:** the broker refresh token is not tied to the user's AS-1 login session. It has a hard maximum lifetime and is revoked when the broker session closes. Disabling the company account therefore takes effect at that maximum lifetime at the latest. Binding to the login session (`sid`), so logout revokes it, is later work. - **Owner identity.** The session owner stays the company identity `(company iss, company sub)`. mecak8s reads it from the validated AS-1 access token's `sub_id`, not from the token's own `iss`/`sub`. Existing owners keep matching, so no migration is needed. The owner remains the human's stable identity across AS-1 user-store rebuilds, and it is the identity the run owner will use later. AS-1 must always set `sub_id`. mecak8s rejects an AS-1 token without it and trusts it because AS-1 ran that sign-in. ### D4: delegation visibility at the gateway @@ -184,7 +184,7 @@ Known limits, recorded so the documents do not overclaim: - AS-1 can also place ceiling metadata and `authorization_details` in the ID-JAG from its registry. The gateway never sees the SVID. - Recent SPIFFE work does not change this for in-process agents. The [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340) (SPIRE 1.15.2, July 2026, experimental) lets a trusted broker obtain SVIDs for a referenced PID or Kubernetes object. With a `KubernetesObjectReference`, per-agent SVIDs would be attested if each agent instance were its own Kubernetes object, such as a CRD or sandbox pod, created by a controller the harness cannot impersonate. -Possible strengthenings, not selected for v1: +Possible strengthenings, not selected for now: - **Session claim.** The SVID names the session it was issued for. AS-1 can then check that the SVID and the subject token belong to the same session, without relying on the broker's pairing. It is still issued on the harness's word. - **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession. A stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 3bc7beb8e6..7c95dd6f56 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status. Its decisions are recorded in the [credential flow rework](credential-flow-rework.md).* -An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. In v1, the broker checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar, alongside session and execution-context checks. User, resource, argument, and dynamic delegation policies are deferred. Renewing the instance credential does not itself extend or widen permission to call tools. +An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. The broker checks the session and execution context. Once the [authority work](README.md#order-of-work) is in place, it also checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar. User, resource, argument, and dynamic delegation policies are deferred. Renewing the instance credential does not itself extend or widen permission to call tools. The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that sign-in itself, so every later statement about the human traces back to a login AS-1 witnessed, not to a report from the harness. @@ -183,7 +183,7 @@ The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authent AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, and to session `A42`, with a hard expiry that rotation does not extend. Every ID-JAG issued from it names that subject. -The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. In v1 the refresh token is not tied to Alice's AS-1 login session: disabling her company account takes effect at the hard expiry at the latest. Binding to the login session is later work. +The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. For now, the refresh token is not tied to Alice's AS-1 login session: disabling her company account takes effect at the hard expiry at the latest. Binding to the login session is later work. The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. @@ -246,7 +246,7 @@ Before any exchange or dispatch, the broker checks: - the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors; - the route to the gateway. -A cache hit skips the token exchanges, never these checks. In v1, user, run, and delegation are not Cedar inputs; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). +A cache hit skips the token exchanges, never these checks. User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). The broker does not check provider connections. It authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. @@ -382,7 +382,7 @@ An approval wait or provider-connection pause leaves the current run unfinished. After restart, recovering credentials is not enough to continue safely. Recovery must restore `A42` and its workload and host binding, find `RT-A42`'s AS-1 record still valid, and reestablish the registered actors and unfinished execution. If `RT-A42` has expired or been revoked, tool calls fail closed until a new session open supplies fresh authority. The harness decides whether execution resumes; the broker authorizes subsequent calls. -Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. In v1, the broker rechecks the stored effective ceiling; the harness retains its run and delegation restrictions. Credential renewal does not widen recovered authority. +Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. With the authority work in place, the broker rechecks the stored effective ceiling; the harness retains its run and delegation restrictions. Credential renewal does not widen recovered authority. An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an idempotency mechanism. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 152d7fb3d3..62e3e085f6 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -59,7 +59,7 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Issue time and hard expiry | | Authority horizon. | | Rotation chain | | Reuse detection. | -The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. In v1 the refresh token is not tied to the user's AS-1 login session. +The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. For now, the refresh token is not tied to the user's AS-1 login session. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: @@ -150,18 +150,18 @@ AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive' | Capability | Today | Size | When | | --- | --- | --- | --- | -| Exchange of a self-issued access token presented by another confidential client (the Mecatl-API-audience exception) | Mostly; the requested `aud` must be a subset of the subject token's `aud` | Small to medium | v1 | -| Refresh token in a token-exchange response, with the session-bound record | No; exchange issues access tokens only | Medium to large | v1 | -| Issue ID-JAGs (`typ=oauth-id-jag+jwt`, audience = another AS) | No; redemption and an outbound XAA client only. The ID-JAG redemption handler binds the client, consumes `jti`, and copies `act`; the plain JWT-bearer handler drops `act` and replaces the client | Large | v1 | -| Refresh token as `subject_token` | No | Large | v1 | -| Single-level `act` from the registered actor input (nesting later, with the chained exchange) | Partly; `buildActClaim` builds `act` from a client or self-issued actor token and nests it | Medium | v1 | -| ID-JAG redemption keeps `act` | Yes, copied unchanged | None; verify only | v1 | -| AS-2 maps the AS-1 subject to a native user after ID-JAG redemption, and stops marking the token as having no upstream session | No; emits `iss#sub` and marks no upstream session | Large | v1 | -| Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, a per-interactive-login session ID that a delegated `V` lacks, and vMCP loads nothing for a token without one (`pkg/auth/token.go:1253`); the enterprise per-user decorator is build-tagged | Medium | v1 | -| Consent link at AS-2's own login (external mode only) | No | Not sized | v1 | -| `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | v1 | -| SPIFFE client authentication at the authorization server | Yes | None; verify only | v1 | -| AS-1 and AS-2 [audit records](audit.md) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | v1 | +| Exchange of a self-issued access token presented by another confidential client (the Mecatl-API-audience exception) | Mostly; the requested `aud` must be a subset of the subject token's `aud` | Small to medium | Identity work | +| Refresh token in a token-exchange response, with the session-bound record | No; exchange issues access tokens only | Medium to large | Identity work | +| Issue ID-JAGs (`typ=oauth-id-jag+jwt`, audience = another AS) | No; redemption and an outbound XAA client only. The ID-JAG redemption handler binds the client, consumes `jti`, and copies `act`; the plain JWT-bearer handler drops `act` and replaces the client | Large | Identity work | +| Refresh token as `subject_token` | No | Large | Identity work | +| Single-level `act` from the registered actor input (nesting later, with the chained exchange) | Partly; `buildActClaim` builds `act` from a client or self-issued actor token and nests it | Medium | Identity work | +| ID-JAG redemption keeps `act` | Yes, copied unchanged | None; verify only | Identity work | +| AS-2 maps the AS-1 subject to a native user after ID-JAG redemption, and stops marking the token as having no upstream session | No; emits `iss#sub` and marks no upstream session | Large | Identity work | +| Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, a per-interactive-login session ID that a delegated `V` lacks, and vMCP loads nothing for a token without one (`pkg/auth/token.go:1253`); the enterprise per-user decorator is build-tagged | Medium | Identity work | +| Consent link at AS-2's own login (external mode only) | No | Not sized | Identity work | +| `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | Identity work | +| SPIFFE client authentication at the authorization server | Yes | None; verify only | Identity work | +| AS-1 and AS-2 [audit records](audit.md) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | Identity work | | Refresh token bound to the AS-1 login session (`sid`), so logout revokes it | No; only `tsid` exists | Large | Later | | DPoP issuance and validation for `V` | No | Large | Later | | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | @@ -224,7 +224,7 @@ Workload mTLS does not bind an OAuth access token to its certificate, prove user ## Cedar authorization and audit verification -Cedar is the selected engine for the v1 effective MCP server/tool ceiling. Policy representation is an implementation choice; user, resource, argument, and dynamic delegation policies are deferred. Required focused checks are: +The ceiling checks below belong to the [authority work](README.md#order-of-work), which follows the identity work; the audit checks belong to the identity work. Cedar is the selected engine for the effective MCP server/tool ceiling. Policy representation is an implementation choice; user, resource, argument, and dynamic delegation policies are deferred. Required focused checks are: - Resolve `Tools`, `DisallowedTools`, and `MCPServers` with existing defaults, selection, and exclusion semantics into registered server/tool pairs. Reject ambiguous or unresolved grants; the same tool name on another server must not inherit access. - Prove deterministic generation from fixed admitted definition inputs, with retained source, definition revision, and generated policy revision. Prose and model output must not affect grants; source edits and restoration must not silently replace the admitted ceiling. @@ -232,7 +232,7 @@ Cedar is the selected engine for the v1 effective MCP server/tool ceiling. Polic - Compute each instance's effective ceiling at registration as its definition ceiling intersected with its parent's effective ceiling, and store it with the incarnation. Property-test `effective(child) ⊆ definition(child)` and `effective(child) ⊆ effective(parent)`, and cross-check against the harness's delegated-authority intersection. Prove a `deployer` child of a read-only reviewer is denied merge. Reject registration under an unregistered, revoked, or other-session parent; an empty intersection registers an instance whose MCP calls all deny. Revoking an instance revokes its descendants. Renewal and restoration reload the stored set and never recompute or widen it. - Evaluate each call, including calls using cached credentials and retries after enrollment. Default deny; generation/loading failures, evaluation failures, and any evaluation diagnostic errors deny, even if the nominal Cedar result is allow. - Check the exact dispatched server/tool pair and recheck changed requests. Prove the reviewer read passes its ceiling while excluded merge fails; PR 42 and PR 43 reads on the same pair have the same ceiling result. Do not claim this proves PR-specific delegation. -- Preserve existing local checks, AS-1 and AS-2 decisions, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. The gateway does not evaluate the agent ceiling in v1; Cedar reading `act` there is later work. +- Preserve existing local checks, AS-1 and AS-2 decisions, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. The gateway does not evaluate the agent ceiling; Cedar reading `act` there is later work. - Verify audit provenance, definition and policy revision correlation, safe reasons and policy IDs, and distinct decision/dispatch/outcome evidence per operation and attempt. Diagnostic errors must yield an effective deny in the record. Redaction must exclude secrets and unrestricted payloads. Test unknown outcomes without automatic replay and durability across restart once storage, ordering, and failure handling are specified; no transactional ledger is selected here. ## Parking and recovery: implementation status diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 4d6ca80565..7a6886254b 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -1,8 +1,8 @@ # MCP authorization -*This chapter describes the intended architecture, not current implementation status.* +*This chapter describes the intended architecture, not current implementation status. The ceiling check it describes belongs to the [authority work](README.md#order-of-work), which follows the identity work.* -The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. V1 evaluates only the acting instance's effective MCP server/tool ceiling: its definition ceiling narrowed by its parent's.** It supplements existing local permissions and the user and connection checks that AS-2 and vMCP enforce downstream; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. +The proposal extends the existing broker's MCP invocation responsibility with agent-instance identity and authority checks; it does not introduce a separate authorization service. **Cedar is the selected policy engine. It evaluates only the acting instance's effective MCP server/tool ceiling: its definition ceiling narrowed by its parent's. This check is part of the [authority work](README.md#order-of-work), which follows the identity work.** It supplements existing local permissions and the user and connection checks that AS-2 and vMCP enforce downstream; a Cedar permit is not proof of user entitlement. Obtaining an OAuth token is a separate credential step, not a substitute for authorization. ## Definition-derived ceiling @@ -18,7 +18,7 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). In v1, user, run, and delegation are not Cedar inputs. Run and delegation context remain trusted harness reports. Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority @@ -34,9 +34,9 @@ The gateway sees delegation only as identity, not as authority. AS-1 writes only ## Operations and resources -V1 distinguishes registered MCP servers and tools, but does not evaluate resource or argument policies. Permission to call a tool is not evidence of authorization for a particular repository or PR. User, resource, argument, and dynamic delegation policies are deferred; v1 does not provide comprehensive enterprise user-policy enforcement. +The ceiling check distinguishes registered MCP servers and tools, but does not evaluate resource or argument policies. Permission to call a tool is not evidence of authorization for a particular repository or PR. User, resource, argument, and dynamic delegation policies are deferred, and the design does not yet provide comprehensive enterprise user-policy enforcement. -For example, a reviewer whose resolved ceiling includes `pull_request_read` on the registered payments server can pass the ceiling check for a read, but cannot pass it for `pull_request_merge` if merge is excluded. Reading PR 42 and reading PR 43 with that same server/tool pair produce the same ceiling result. The broker's v1 Cedar check cannot establish that the reviewer stayed within Alice's PR-42-only request. Existing connection and downstream checks still apply to either call. +For example, a reviewer whose resolved ceiling includes `pull_request_read` on the registered payments server can pass the ceiling check for a read, but cannot pass it for `pull_request_merge` if merge is excluded. Reading PR 42 and reading PR 43 with that same server/tool pair produce the same ceiling result. The broker's ceiling check cannot establish that the reviewer stayed within Alice's PR-42-only request. Existing connection and downstream checks still apply to either call. Future broker policies decide on what the broker knows first-hand: @@ -54,7 +54,7 @@ Agent-instance identity does not prevent a prompt-injected agent from misusing o Mecatl validates Alice's AS-1 access token for its API and checks current session ownership. The broker turns it into a session-bound broker refresh token at AS-1; no company IdP token reaches AS-1 or the broker. AS-1 issues an ID-JAG naming Alice and the acting instance only while the session is open and the instance is registered to it, and the broker redeems that ID-JAG at AS-2 for `V`. Neither the AS-1 access token, the refresh token, nor the SVID reaches AS-2. Model-generated arguments cannot choose or override identity evidence. The exchange itself is described [below](#authorization-credentials-and-dispatch); see [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization) and the steps after it for token types, fields, and validation. -The broker checks the actual MCP server/tool pair against the effective ceiling before any exchange or dispatch. User and connection authorization are enforced downstream: AS-2 redeems the ID-JAG only for a mapped user (embedded) or a consent-linked user (external), and vMCP dispatches only with the user's stored credential, keyed by (gateway, native user, provider). The broker does not check provider connections itself: it could only copy the gateway's data, and the provider enforces its own permissions on the request. The final request, including arguments, must remain bound to the checks and dispatch, but recording or binding arguments does not mean v1 Cedar authorizes their resource-specific meaning. +The broker checks the actual MCP server/tool pair against the effective ceiling before any exchange or dispatch. User and connection authorization are enforced downstream: AS-2 redeems the ID-JAG only for a mapped user (embedded) or a consent-linked user (external), and vMCP dispatches only with the user's stored credential, keyed by (gateway, native user, provider). The broker does not check provider connections itself: it could only copy the gateway's data, and the provider enforces its own permissions on the request. The final request, including arguments, must remain bound to the checks and dispatch, but recording or binding arguments does not mean the ceiling check authorizes their resource-specific meaning. Request transformations must occur before the applicable checks, or the changed request must be checked again. Dispatch must use the checked request. @@ -62,10 +62,10 @@ Request transformations must occur before the applicable checks, or the changed In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), Alice owns session `hostS42`. The main instance lists pull requests, then delegates review of PR 42 to a reviewer. The tool names and arguments are illustrative, not a selected MCP server contract. -| Request | V1 effective-ceiling check | +| Request | Effective-ceiling check | | --- | --- | | Main run `R41`: `pull_request_list(repository=acme/payments)` | The main's effective ceiling, which is its definition ceiling, must permit the resolved server and list tool. Main has no implicit exemption. | -| Reviewer run `R42` via `D42`: `pull_request_read(repository=acme/payments, number=42)` | The reviewer's effective ceiling, its definition ceiling intersected with the main's, must permit the resolved server and read tool. `D42` records the delegated task but is not a v1 Cedar policy input. | +| Reviewer run `R42` via `D42`: `pull_request_read(repository=acme/payments, number=42)` | The reviewer's effective ceiling, its definition ceiling intersected with the main's, must permit the resolved server and read tool. `D42` records the delegated task but is not an input to the ceiling check. | Both requests also require valid credentials and execution-context checks, and must pass local checks and the downstream AS-2 and vMCP checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. From 81eae3844bbe3cc92b7f5e25fdd97e3787618475 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 15:01:12 +0200 Subject: [PATCH 22/50] docs: state that the broker does not own run admission The broker governs protected tool calls. It does not decide whether agents start or runs execute, and registration grants no permission to run. Qualify the all-tool-mediation goal so authorizing a spawn request does not read as owning agent lifecycle. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 2 ++ docs/drafts/agent-identity/all-tool-mediation.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 81c8b5f94b..bd23529b03 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -45,6 +45,8 @@ The harness hosts the API and agent loop and validates the initiating user's AS- This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. +The broker does not decide whether agents start or whether runs execute; the harness owns agent lifecycle and run execution. Registering an instance with the broker records its identity and grants no permission to run. + ```mermaid flowchart LR U["Client"] -->|"Sign-in"| A1 diff --git a/docs/drafts/agent-identity/all-tool-mediation.md b/docs/drafts/agent-identity/all-tool-mediation.md index 0aa16a9a62..673cbbbc7d 100644 --- a/docs/drafts/agent-identity/all-tool-mediation.md +++ b/docs/drafts/agent-identity/all-tool-mediation.md @@ -2,7 +2,7 @@ *This chapter describes a future direction, not current implementation status. It is outside the current MCP authorization and audit design scope.* -The goal in broker-backed remote deployments is for the broker to authorize all tools and spawning or delegation operations, including filesystem tools such as `Read`, `Write`, `Grep`, and `Glob`, and `Shell`. Local direct deployments can retain direct tool access. This direction does not require an immediate migration. +The goal in broker-backed remote deployments is for the broker to authorize all tools and spawning or delegation operations, including filesystem tools such as `Read`, `Write`, `Grep`, and `Glob`, and `Shell`. Local direct deployments can retain direct tool access. This direction does not require an immediate migration. Authorizing a spawn or delegation here means checking that request like any other protected operation. It does not make the broker the owner of agent lifecycle or run execution, and the identity work does not depend on it. The [MCP authorization](mcp-authorization.md) chapter covers checks on protected MCP calls. That boundary alone does not govern a file read or a Shell command performed through another execution path. For example, authorizing an MCP repository read says nothing about whether the same agent may read a checkout through `Read`; the broader goal requires a decision for that operation too. From adae397fa26a25777d167564158c4771b058d482 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 15:09:30 +0200 Subject: [PATCH 23/50] docs: describe broker deployment and the threats it addresses The broker starts as modules inside mecak8s and later becomes a separate service. Add a Deployment and threats section to the README: what holds in both deployments (executor Pods, credentials hidden from the model, the user from an AS-1 sign-in), what a compromised mecak8s can do in each, what stays mecak8s's word in both, and what the split needs to be real. Qualify the claims that assumed a separate broker, and add the modulith constraints that keep the later move a deployment change. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 32 ++++++++++++++++++- .../agent-identity/agent-definitions.md | 2 +- .../credentials-and-lifecycle.md | 2 +- docs/drafts/agent-identity/implementation.md | 11 +++++++ .../agent-identity/mcp-gateway-modes.md | 2 +- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index bd23529b03..07252ef6db 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -41,7 +41,7 @@ The existing `AgentDef` structure currently combines instructions, execution set The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. -The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. An **agent-definition loader**, a broker module, loads definitions from configured sources into the broker and supplies the harness with execution configuration. An **identity broker** is a separate trusted service that governs access to protected tools. It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. +The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. An **agent-definition loader**, a broker module, loads definitions from configured sources into the broker and supplies the harness with execution configuration. An **identity broker** is a trusted component that governs access to protected tools. It starts as modules inside the mecak8s process and later becomes a separate service; see [deployment and threats](#deployment-and-threats). It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. @@ -84,6 +84,36 @@ Alice signs in through AS-1, which redirects her to the company IdP. Her client AS-1 witnessed Alice's sign-in itself, checks that the acting instance is registered to the broker session, and authorizes the client, subject, actor, and target. It does not prove that the agent registration, ancestry, run, or delegation reported by trusted lifecycle code is genuine. The agent loop must not choose those facts, and distinct agent credentials do not isolate agents sharing one `mecak8s` process. +## Deployment and threats + +The broker starts as modules inside the mecak8s process: broker core, AS-1, the agent-instance issuer, and the definition loader. Later these modules move together into one separate broker service. The protocol and token formats are the same in both deployments; what changes is which process holds the broker's keys, refresh tokens, and records. + +Some protections do not depend on where the broker runs: + +- **Hostile workspace code** runs in executor Pods, behind the execution provider. In both deployments, executors get no broker credentials, no database credentials, and no network path to mecak8s internals or the KMS. +- **A prompt-injected agent** never sees or chooses credentials. Harness code attaches them, and the model supplies only tool names and arguments. +- **The user comes from a sign-in at AS-1**, not from a harness report. mecak8s passes on the AS-1-signed access token once, AS-1 converts each token at most once, and every ID-JAG names the user from AS-1's record. No mecak8s code path can name a user who did not sign in. + +Where the broker runs matters against one attacker: someone with code execution in the mecak8s process. + +| Threat from a compromised mecak8s | Broker inside mecak8s | Separate broker service | +| --- | --- | --- | +| Act as any user, by signing an ID-JAG for a user who is not present | Open. The process holds AS-1's signing key and the broker's client key. In embedded mode this reaches every user connected to the gateway; in external mode, only users with a consent link. | Narrowed. mecak8s holds no keys. It can act only in open broker sessions bound to its workload, until each closes or its refresh token's hard expiry passes, and it can open sessions with fresh tokens from users currently using it. | +| Steal refresh tokens or signing keys | Open. A KMS prevents copying a key out, not signing with it while the attacker is inside. | Closed, if the broker has its own database credentials and only the broker's service account may sign with its KMS keys. | +| Read users' provider tokens in embedded mode | Open, if the gateway store shares storage credentials with mecak8s. | Closed, if the embedded gateway is its own deployment with its own credentials. | +| Tamper with broker and AS-1 audit records | Open. One process writes them all. | Closed, if audit goes to an append-only sink that mecak8s cannot write. | + +In both deployments, the broker takes these facts on mecak8s's word: which agent instance made a call, which loaded definition a session uses as its root, which instance created which child, the run and delegation context, and which open session a call belongs to. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark the facts mecak8s reported. + +A separate broker service delivers its protections only if the split is real: + +- the broker has its own database, KMS permissions, and namespace; +- mecak8s cannot create pods, read secrets, or change configuration in the broker's namespace; +- mecak8s cannot write the broker's workload allowlist, trust bundle, or policy inputs; +- the embedded gateway is its own deployment. + +While the broker runs inside mecak8s, the [modulith constraints](implementation.md#modulith-constraints) keep the later move a deployment change. + ## Following an action through the architecture Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 8c04b1e9b1..ab95da9889 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -104,7 +104,7 @@ When the main creates the reviewer, mecak8s allocates and persists the child's i Creation ancestry records who created an instance. Execution delegation separately links parent run `R41`, reviewer run `R42`, and the bounds of delegation `D42`. Registration establishes identity records; it does not authorize spawning, run execution, or a tool call. The harness manages execution, and the broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). -The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The user side does not rest on mecak8s: AS-1 witnessed Alice's sign-in and names her from the session's broker refresh token, but that does not prove run or delegation context. Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances; mecak8s is a trusted attestor, not an independently verified one. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. +The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The user side does not rest on mecak8s's reports: AS-1 witnessed Alice's sign-in and names her from the session's broker refresh token, but that does not prove run or delegation context. While the broker runs inside mecak8s, an attacker with code execution in that process also holds AS-1's keys; see [deployment and threats](README.md#deployment-and-threats). Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances; mecak8s is a trusted attestor, not an independently verified one. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. ## Identity continuity across runs and restarts diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 7c95dd6f56..2c524a087e 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -325,7 +325,7 @@ If no credential is stored, vMCP answers "needs authorization" and the user conn AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s authenticates the API request with the AS-1 token and takes the session owner from its `sub_id`, the company identity. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. -Compared with a harness report, a compromised mecak8s can misuse tokens of users currently passing through it but cannot name arbitrary users. A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. +No mecak8s code path can name a user who did not sign in at AS-1. An attacker with code execution in mecak8s is another matter. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act for any user AS-1 knows. Once the broker is a separate service, the attacker can act only in open broker sessions bound to its workload and open new ones with fresh tokens from users currently using it. See [deployment and threats](README.md#deployment-and-threats). A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 62e3e085f6..3241c757d2 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -235,6 +235,17 @@ The ceiling checks below belong to the [authority work](README.md#order-of-work) - Preserve existing local checks, AS-1 and AS-2 decisions, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. The gateway does not evaluate the agent ceiling; Cedar reading `act` there is later work. - Verify audit provenance, definition and policy revision correlation, safe reasons and policy IDs, and distinct decision/dispatch/outcome evidence per operation and attempt. Diagnostic errors must yield an effective deny in the record. Redaction must exclude secrets and unrestricted payloads. Test unknown outcomes without automatic replay and durability across restart once storage, ordering, and failure handling are specified; no transactional ledger is selected here. +## Modulith constraints + +While the broker runs as modules inside mecak8s, these constraints keep the later move to a separate service a deployment change rather than a redesign: + +- **Fixed issuer URLs.** AS-1 uses its final issuer URL, such as `https://broker.example.com/as`, from the start, and the broker audience is fixed the same way. External consent links are keyed on the AS-1 issuer, so changing it later would break every link. +- **The same contract in-process.** mecak8s calls the broker over the same RPC contract it will use across processes, with real workload authentication, and the broker identifies the caller from authenticated metadata. No in-process shortcut may skip a check. +- **Separate storage.** The broker's records, including refresh tokens, the instance registry, and audit, use their own schema and database credentials, with no joins or shared transactions with mecak8s's session store. +- **One key per role.** AS-1, the broker client `mecatl`, and the agent-instance issuer each use their own key behind a signer interface, so moving a key means moving its permission, not changing code. +- **Real workload identity.** Sessions bind to the real mecak8s workload identity from the start, so the move needs no rebinding of open sessions. +- **No executor access.** Executor Pods have no route to the broker's listener, database, or KMS. + ## Parking and recovery: implementation status Protected MCP calls can park while awaiting provider reauthorization and resume with account, connection, catalogue, and permission checks. Offline fixtures cover credential and enrolled-connection recovery after host and broker replacement. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index e3fed7c3c4..da61f812cf 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -90,7 +90,7 @@ client_assertion= AS-1 checks: - **The client.** The assertion verifies against `mecatl`'s registered key. -- **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user. +- **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user through this request. AS-1 runs in the same process as the broker core, so this guards against broker bugs, not against a compromised broker. - **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. - **The target.** `gateway.example.com` is an allowed AS-2 for this client. From 5aec36dc3fcd36b831cf4296483d2888370ae573 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 15:28:13 +0200 Subject: [PATCH 24/50] docs: add sign-out and revocation to the identity work Signing out of one client and stopping agents are different actions. "Sign out" revokes only that client's tokens. "Sign out everywhere" stops every agent acting for the user: live runs, parked and in-flight tool calls, broker sessions and their refresh tokens, and every schedule's authority. AS-1 also re-checks the company-IdP refresh token behind each grant when it issues an ID-JAG, at most once per interval. A refusal revokes all the user's grants, so a disabled account stops acting at any IdP that issues refresh tokens, without IdP events. Record the decision as D8, size the ToolHive work, add the checks, and list IdP events, login-session binding, and Shared Signals as later work. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 1 + .../agent-identity/credential-flow-rework.md | 14 +++++++--- .../credentials-and-lifecycle.md | 26 ++++++++++++++++--- docs/drafts/agent-identity/implementation.md | 18 +++++++++++-- .../agent-identity/mcp-gateway-modes.md | 2 +- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 07252ef6db..c9f81fcbb5 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -136,6 +136,7 @@ The **identity work** establishes who acts and records it: - agent-instance credentials; - the credential flow: sign-in at AS-1, the session-bound refresh token, ID-JAG issuance, AS-2 redemption, and per-user provider credentials at the gateway; - the broker's identity checks: session, workload, and that the acting instance is registered to the broker session; +- sign-out and revocation: signing out everywhere stops every agent and schedule acting for the user, and AS-1's upstream re-check does the same when the company IdP disables the account; - audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. The **authority work** decides what each agent may do: diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md index de4fb6b7c8..a99e7bdf6e 100644 --- a/docs/drafts/agent-identity/credential-flow-rework.md +++ b/docs/drafts/agent-identity/credential-flow-rework.md @@ -150,7 +150,7 @@ When no credential or consent exists, vMCP or AS-2 refuses and the broker return A token format that carries these claims, a signed session assertion or a refresh token that AS-1 encodes internally as a signed JWT, adds readable copies of the same data. AS-1 must still look up the record for revocation and the one-conversion rule, and must still read the registry for instance membership. That format pays off only if AS-1 must validate without access to the store. - The broker refresh token is the subject of each ID-JAG exchange (ID-JAG §4.3 allows a refresh token as `subject_token`). It is the standard form of the human authority in `A42`. `A42` keeps only the workload and host-session binding. -- **Simplification for now:** the broker refresh token is not tied to the user's AS-1 login session. It has a hard maximum lifetime and is revoked when the broker session closes. Disabling the company account therefore takes effect at that maximum lifetime at the latest. Binding to the login session (`sid`), so logout revokes it, is later work. +- **Session binding:** the broker refresh token is not tied to the user's AS-1 login session, so signing out of one client does not end it. Sign out everywhere, session close, the hard maximum lifetime, and the upstream re-check end it (D8). Binding to the login session (`sid`), so that signing out at AS-1 revokes it, is later work. - **Owner identity.** The session owner stays the company identity `(company iss, company sub)`. mecak8s reads it from the validated AS-1 access token's `sub_id`, not from the token's own `iss`/`sub`. Existing owners keep matching, so no migration is needed. The owner remains the human's stable identity across AS-1 user-store rebuilds, and it is the identity the run owner will use later. AS-1 must always set `sub_id`. mecak8s rejects an AS-1 token without it and trusts it because AS-1 ran that sign-in. ### D4: delegation visibility at the gateway @@ -203,17 +203,23 @@ Future broker Cedar policies stay request-shaped: user and identity-provider att ### D7: scheduled runs are pre-authorized -- **Authority.** A schedule has its own delegated authority from the user: created at schedule setup, bound to the schedule rather than a session, with an explicit horizon, and revoked when the schedule is deleted. Session-bound authority cannot serve schedules, because it is revoked when the session closes. +- **Authority.** A schedule has its own delegated authority from the user: created at schedule setup, bound to the schedule rather than a session, with an explicit horizon, and revoked when the schedule is deleted, on sign out everywhere, or by a failed upstream re-check (D8). Session-bound authority cannot serve schedules, because it is revoked when the session closes. - **Pre-authorization.** At setup, every connector the schedule may use is connected: the gateway link in external mode, and the provider connection in every mode. Gaps are fixed interactively then. - **At run time,** nothing is interactive. A lapsed link, credential, or authority fails the call. The run ends with "needs owner action", and the owner is notified through a link into Mecatl, never a raw connect URL. - **Renewal** extends the horizon and reconnects lapsed connectors without recreating the schedule. The owner is notified ahead of the horizon. +### D8: sign-out and revocation + +- **Two user actions.** "Sign out" ends only that client's own tokens; agents and schedules keep running. "Sign out everywhere" stops every agent acting for the user: live runs, tool calls parked for authorization, broker sessions and their refresh tokens, and the authority of every schedule. +- **Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority, independent of any client's sign-out. When issuing an ID-JAG, it refreshes that upstream token at most once per re-check interval. A refusal revokes all the user's grants. This catches account disable and administrator revocation at any IdP that issues refresh tokens, without IdP events. +- **Later:** logout and account events from the company IdP (OIDC Back-Channel Logout, Okta event hooks), binding broker refresh tokens to the AS-1 login session, and Shared Signals. + ## User-visible flow - **Sign in** once per login session, through AS-1 to the company IdP; usually silent with single sign-on. - **Connect a provider** once, through the existing `mcp_authorization_pending` path. In external mode the same path first shows the gateway's "Allow Mecatl" step. - **Everyday use and scheduled runs** need nothing from the user until the broker refresh token's maximum lifetime or a revocation. -- **Revoke** in Mecatl, or, for external gateways, in the gateway UI. +- **Sign out** ends one client's tokens. **Sign out everywhere** stops every agent and schedule acting for the user. For external gateways, the consent link can also be revoked in the gateway UI. ## ToolHive gaps @@ -232,6 +238,8 @@ What each decision asks of ToolHive, and whether it exists today. Current behavi | D2 | Consent link at AS-2's own login (external mode) | No | | D1 | Per-user credential lookup in open-source ToolHive | No | | D3 | `private_key_jwt` for the broker | Yes | +| D8 | Revoke a broker refresh token and all of a user's grants | No | +| D8 | Upstream re-check when issuing an ID-JAG | No | | Later | DPoP issuance and validation | No | | D4 (C) | Scope or `authorization_details` narrowing enforced per tool, including on `act` | Partly | diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 2c524a087e..bbc7cd3579 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -183,7 +183,7 @@ The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authent AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, and to session `A42`, with a hard expiry that rotation does not extend. Every ID-JAG issued from it names that subject. -The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. For now, the refresh token is not tied to Alice's AS-1 login session: disabling her company account takes effect at the hard expiry at the latest. Binding to the login session is later work. +The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, and the hard expiry end it, and AS-1's upstream re-check ends it if the company IdP disables her account; see [sign-out and revocation](#sign-out-and-revocation). The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. @@ -361,10 +361,11 @@ An agent instance keeps its identity across runs. These lifetimes are independen | Client refresh token | AS-1 policy and upstream sign-in status. | Sign in again. | | Workload JWT | Expired credentials are rejected. | Platform rotation for the same workload. | | `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the ceiling does not widen. | -| `RT-A42` | Hard expiry, session close, revocation, or reuse detection. Rotation does not extend the hard expiry. | A new session open with a fresh AS-1 access token. | +| `RT-A42` | Hard expiry, session close, sign out everywhere, a failed upstream re-check, revocation, or reuse detection. Rotation does not extend the hard expiry. | A new session open with a fresh AS-1 access token. | | ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | | `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | | Broker private key | Operator rotation. | New key published through `jwks_uri`. | +| Schedule authority | Its horizon, schedule deletion, sign out everywhere, or a failed upstream re-check. | The owner renews it. | | Consent link | Revocation at AS-2. | The user links again. | | Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | @@ -372,6 +373,25 @@ The example times are illustrative, not selected defaults: `J-reviewer` is valid Closing or revoking the session revokes `RT-A42` and invalidates the broker's `V` cache for that session. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. +## Sign-out and revocation + +Signing out of one client and stopping Mecatl's agents are different actions. + +| User action or event | What ends | What remains | +| --- | --- | --- | +| **Sign out** in the TUI or Studio | That client's own AS-1 tokens, revoked at AS-1. | Running agents and schedules. Their authority is held by the broker, not by the client. | +| **Sign out everywhere** | Every agent acting for Alice: live runs stop, tool calls parked for authorization are cancelled, broker sessions and their refresh tokens are revoked, and the authority of each of her schedules is revoked. | Transcripts. Each schedule stays configured; its next run ends with "needs owner action" until Alice renews it. | +| **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval. | Transcripts. | +| **Closing a session** | That session's refresh token. | Other sessions and schedules. | + +**Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker deletes the broker session, which cancels in-flight tool calls, drops the session's cached ID-JAGs and `V`, and revokes its refresh token at AS-1. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. + +**Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes that upstream token if it has not done so within the re-check interval. If the IdP refuses, because the account is disabled or its grants were revoked, AS-1 revokes every grant it holds for Alice, including schedule authority, and the exchange fails. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. + +**What remains after revocation.** Issued ID-JAGs and AS-2 access tokens cannot be revoked. The broker stops using them at once, but a `V` already issued stays valid at the gateway until it expires, and the vMCP does not check for revocation. Keep both lifetimes short. + +**Later.** Two steps would make revocation faster or broader. AS-1 could receive logout and account events from the company IdP: OIDC Back-Channel Logout from Keycloak, or event hooks from Okta. Entra and Google Workspace send neither to third-party applications. And each broker refresh token could be bound to Alice's AS-1 login session, so that signing out at AS-1 itself ends her agents. Shared Signals (SSF, CAEP, RISC) carry the same events but have little IdP support today. + ## Pauses and restart The broker's session reference and broker session incarnation are separate from the host session ID and [agent-instance incarnation](agent-definitions.md#definitions-and-executing-instances). An existing stable broker session can recover a new process-local attachment through continuity checks. Those references do not combine user, agent, definition, and authority into one record. @@ -390,7 +410,7 @@ An operation that may have been dispatched but has no recorded completion has an A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, in the same one-time conversion as at session open, but bound to the schedule rather than a session. It has an explicit horizon, and deleting the schedule revokes it. Each run draws on that authority. +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, in the same one-time conversion as at session open, but bound to the schedule rather than a session. It has an explicit horizon. Deleting the schedule, signing out everywhere, and a failed upstream re-check revoke it. Each run draws on that authority. When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 3241c757d2..b43a022ecc 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -59,7 +59,7 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Issue time and hard expiry | | Authority horizon. | | Rotation chain | | Reuse detection. | -The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. For now, the refresh token is not tied to the user's AS-1 login session. +The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: @@ -162,7 +162,11 @@ AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive' | `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | Identity work | | SPIFFE client authentication at the authorization server | Yes | None; verify only | Identity work | | AS-1 and AS-2 [audit records](audit.md) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | Identity work | -| Refresh token bound to the AS-1 login session (`sid`), so logout revokes it | No; only `tsid` exists | Large | Later | +| Revoke a broker refresh token and its record, by an RFC 7009 endpoint or, while the broker runs inside mecak8s, in process | No; storage revokes by refresh-token family, but no endpoint is wired | Small | Identity work | +| Revoke all of a user's grants, including schedule authority | No; no index from user to refresh-token families | Medium | Identity work | +| Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | +| Refresh token bound to the AS-1 login session (`sid`), so that signing out at AS-1 revokes it | No; `tsid` is per authorization request, not per login | Large | Later | +| Receive logout and account events from the company IdP (OIDC Back-Channel Logout, Okta event hooks, Shared Signals) | No | Large | Later | | DPoP issuance and validation for `V` | No | Large | Later | | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | | Cedar at the gateway reading `act` | No | Part of the narrowing work | Later | @@ -235,6 +239,16 @@ The ceiling checks below belong to the [authority work](README.md#order-of-work) - Preserve existing local checks, AS-1 and AS-2 decisions, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. The gateway does not evaluate the agent ceiling; Cedar reading `act` there is later work. - Verify audit provenance, definition and policy revision correlation, safe reasons and policy IDs, and distinct decision/dispatch/outcome evidence per operation and attempt. Diagnostic errors must yield an effective deny in the record. Redaction must exclude secrets and unrestricted payloads. Test unknown outcomes without automatic replay and durability across restart once storage, ordering, and failure handling are specified; no transactional ledger is selected here. +## Sign-out and revocation checks + +Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation): + +- Signing out of one client revokes only that client's tokens; running agents and schedules continue. +- Signing out everywhere cancels live runs, parked tool calls, and in-flight tool calls; deletes the broker sessions; revokes their refresh tokens at AS-1; drops cached ID-JAGs and `V`; and revokes every schedule's authority. Transcripts remain. +- It reaches sessions served by another replica. +- After it, an ID-JAG exchange with any revoked refresh token fails, and each schedule's next run ends with "needs owner action". +- When the upstream IdP refuses a refresh, AS-1 revokes all the user's grants, including schedule authority, and the exchange fails. A client's own sign-out does not remove the upstream token AS-1 re-checks. Re-checks happen at most once per interval. + ## Modulith constraints While the broker runs as modules inside mecak8s, these constraints keep the later move to a separate service a deployment change rather than a redesign: diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index da61f812cf..d59b7aca75 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -213,7 +213,7 @@ This keeps one protocol path for both modes. It does not add isolation: anyone w ## Outside this chapter -- **Revocation** beyond short ID-JAG and access-token lifetimes, consent-link revocation, and `RT-A42`'s hard expiry. +- **Revocation.** Sign-out and account revocation are covered in [credentials and lifecycle](credentials-and-lifecycle.md#sign-out-and-revocation). Issued ID-JAGs and access tokens are not revoked; they expire. - **Structured gateway outcomes.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome remains open. So does whether the "Allow Mecatl" step and the provider's consent screen can be one redirect chain. - **Gateway authorization on `act`**, such as Cedar policies that consider the acting agent. Until the agent ceiling is carried in the ID-JAG and `V` as scope or `authorization_details` (RFC 9396), the broker alone enforces it; the gateway enforces user and connection. From 1a56af258529ec0cdc5350b4de10f13220182aa3 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 15:48:26 +0200 Subject: [PATCH 25/50] docs: define the audit evidence as an AuditEvent profile Replace the illustrative evidence table with a profile of toolhive-core audit.AuditEvent: what goes in each field, a fixed outcome set per event type, issuer-qualified credential references, provenance marks for each identity, operation and attempt correlation, creation and delegation lineage kept apart from the act chain, and the event families the identity work needs. Interim keys use the mecatl. namespace until toolhive-core#341 and #342 land. Add round-trip checks to the implementation companion. AS-1 now sets act.iss to its own issuer, so agent: identifiers stay attributable to AS-1 after AS-2 copies act into V (D4). Mark the ToolHive work list as the ledger of ToolHive changes, none filed yet, and add the operation-ID recording, missing-upstream-record, and vMCP session revocation rows. Co-Authored-By: mecatl --- docs/drafts/agent-identity/audit.md | 63 ++++++++++++++----- .../agent-identity/credential-flow-rework.md | 6 +- .../credentials-and-lifecycle.md | 8 +-- docs/drafts/agent-identity/implementation.md | 29 +++++++-- .../agent-identity/mcp-gateway-modes.md | 6 +- 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 0d4b3e9d69..4f0d99c2a9 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -24,24 +24,59 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | vMCP decision | vMCP records its decision with the access-token reference `(iss, jti)`, native user, actor, tool, outcome, and the propagated operation ID | The gateway sees only the current actor in `act.sub`, not who delegated the task. The operation ID is reported, unverified trace context, as is other forwarded context unless independently verified. | | Observed result and provider correlation | The response received by the recording component and safe provider references, when available | A broker-observed MCP response is not independent proof of provider-side effects. | -AS-1 and AS-2 must produce the records above; ToolHive's authorization server records no audit events today, so this is new work. +AS-1 and AS-2 must produce the records above, following the [AuditEvent profile](#auditevent-profile); ToolHive's authorization server records no audit events today, so this is new work. -## Illustrative authorization evidence +## AuditEvent profile -The following readable fields illustrate proposed evidence for the reviewer's call in the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution), including its broker authorization event and references to AS-1, AS-2, and vMCP records. They guide implementation; they are not an immutable event API or Cedar policy representation. +Every component records evidence as a [toolhive-core](https://github.com/stacklok/toolhive-core/tree/main/audit) `audit.AuditEvent`, so that Mecatl and ToolHive records can be joined in one investigation. This profile says what Mecatl puts in each field. Where toolhive-core does not yet define a convention, Mecatl uses namespaced `mecatl.` keys in `Metadata.Extra` until the shared conventions in toolhive-core [#341](https://github.com/stacklok/toolhive-core/issues/341) and [#342](https://github.com/stacklok/toolhive-core/issues/342) land. -| Field group | Illustrative values or required evidence | +| Question | Field | Content | +| --- | --- | --- | +| Which event | `Metadata.AuditID` | Minted once and stored before the first send. A re-send reuses it, and ingestion removes duplicates by it. | +| What kind of event | `Type` | A `mecatl_`-prefixed snake_case type, such as `mecatl_tool_decision`. The vMCP keeps ToolHive's own types, such as `mcp_tool_call`. Whether an event is a decision, a dispatch, or an observed outcome is part of its type. | +| Result | `Outcome` | toolhive-core's `success`, `failure`, `error`, and `denied`, plus `unknown` once #341 defines it. Each type uses a fixed subset; see the next table. | +| Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `agent:` identifier), and `agent_instance_ref`. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | +| Verified or reported | `Metadata.Extra["mecatl.provenance"]` | A map keyed by the `Subjects` key: `verified`, `record:` when taken from a retained record, `reported:`, or `unavailable`. A missing fact is marked `unavailable`, never left out silently. | +| Where it originated | `Source` | The network origin, as in ToolHive: the peer address, plus `request_id` and `session_id` for joining. The calling workload's identity belongs in `Subjects`, not here. | +| Where it was recorded | `Component`, `Metadata.Extra` | `mecatl-broker`, `mecatl-as1`, or the ToolHive component name, plus the recording replica and trust domain. | +| Operation and target | `Target` | toolhive-core's keys: `type=tool`, `name`, `method=tools/call`, and the MCP server. For token-endpoint events, the requested audience and resource. Request metadata appears only if allowlisted for that type. | +| When | `LoggedAt`, `Metadata.Extra` | `LoggedAt` is the recorder's clock when it created the record, in UTC; a re-send keeps it. `mecatl.occurred_at` (RFC 3339 with offset) and `mecatl.clock` record when the event happened and whose clock said so. Events from different components are ordered by attempt and a per-component sequence number, never by timestamp alone. | +| Correlation | `Metadata.Extra` | `mecatl.operation_id` and `mecatl.attempt_id`. A retry is a new attempt. A result that arrives after an `unknown` outcome is a new event that references the earlier one; records are never edited. The broker passes the operation and attempt IDs to AS-1 and the vMCP, which record them as reported. | +| Evidence references | `Data` | Built from a typed structure per event type. Credential references are `(iss, jti)` or `(authorization server, record ID)`, never values. Definition revision and provenance. The broker does not inspect `V`, so its events reference AS-1's issuance event and AS-2's redemption event instead of `V`'s `jti`. | +| OAuth actor history | `DelegationChain` | Only an `act` chain the recorder validated in a token: AS-2 and the vMCP fill it, and broker events leave it empty. | +| Creation and delegation | `Data.lineage` | The run, the delegation (`D42` from run `R41` and its instance), and the creation parent, each marked as reported by mecak8s. They are never derived from the SPIFFE path or from `act`. | +| Profile version | `Metadata.Extra` | `mecatl.profile` names this profile and its version; each `Data` structure carries its own version. | + +Each event type uses a fixed set of outcomes: + +| Event | Outcomes | | --- | --- | -| Event and call references | Event `authz-42`, operation `op-pr42-001`, attempt `attempt-1`, recording component, and observation time. Distinguish another attempt from another event for the same attempt. | -| Actor and context | Verified reviewer subject from its registered instance; verified workload with trust qualifier; host session `hostS42`; broker session `A42`; reviewer's agent-instance incarnation; run `R42`; creation-parent reference; reported delegation `D42` from `R41`; request reference `req-pr42-001`. Alice's AS-1 subject `(https://broker.example.com/as, u-alice-91)` comes from the session's AS-1 record; her company identity `(https://login.example.com, alice-204)` comes from `sub_id`. Preserve each fact's provenance. | -| Chained credential references | Broker client `mecatl` at AS-1 and AS-2. Source AS-1 access-token `jti` `at-alice-7731`, recorded by AS-1 at conversion, never the token itself. The broker refresh token, referenced by its AS-1 record for `A42`, never by value. ID-JAG issuer `https://broker.example.com/as` and `jti` `jag-pr42-001`. Actor `agent:reviewer-gh56` in the single-level `act`; delegation `D42` from main is in the harness and broker records, not the token. AS-2 access token `(https://gateway.example.com, access-pr42-001)`. These references identify different credentials and do not replace the operation ID. | -| Definition and policy | Registered `project/code-reviewer`, source reference, provenance profile with source content and accepted definition digests, admitted revision `review-def-r17`, resolved definition ceiling reference, stored effective ceiling reference, generated policy revision `review-ceiling-r17-g1`. These come from registration, not request-selected policy. | -| Target | Registered payments MCP server reference and exact `pull_request_read` tool. AS-2 resolves native user `usr-alice-7c1d`, and vMCP loads the credential for `(gateway.example.com, usr-alice-7c1d, github)`; those records live with AS-2 and vMCP, not in the broker event. | -| Evaluation | Cedar nominal decision `allow`, evaluation status `completed-without-errors`, relevant policy IDs, and generated policy revision. | -| Effective decision | `allow` only after the session, workload, actor, ceiling, and route checks pass. Safe reason `effective-ceiling-permits-pair-and-required-checks-passed`. An evaluation diagnostic error instead requires effective `deny`, even with a nominal Cedar allow. Record a safe error category, not unrestricted diagnostics. | -| Safe request metadata | Repository `acme/payments` and PR number `42` only if explicitly allowlisted for audit. Their presence does not mean Cedar evaluated a PR-specific policy. | - -The policy revision and policy IDs must lead to retained generation inputs and policy evidence sufficient to explain the ceiling result. A definition name alone cannot explain which policy was used. Error and denial records need the same correlation and attribution where established; unavailable or unverified facts must remain marked as such. +| Decision | `success` for allow; `denied`; `error` when evaluation failed, which makes the effective decision deny. | +| Dispatch | `success` when the request was sent; `failure` when it was not, which proves the operation did not run. | +| Observed outcome | `success`, `failure`, `error`, or `unknown` when dispatch may have happened but no completion was observed. | + +The identity work needs these event families: + +- definition load, instance registration, and agent-instance credential issuance; +- broker session open and close, and AS-1's conversion record at session open; per-call events point to the session-open record for the user; +- sign-out everywhere, with each run stopped, broker session deleted, refresh token revoked, and schedule authority revoked; +- an upstream re-check failure and the revocation that follows; +- ID-JAG issuance and denial at AS-1; +- redemption and denial at AS-2, and consent-link creation and revocation in external mode; +- the broker's decision, dispatch, and observed outcome for each tool call; +- the vMCP's tool-call event; +- audit delivery failure. + +The authority work adds Cedar evaluation status, policy IDs, and policy and ceiling revisions. Until then, decision events record that no ceiling was evaluated. + +For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `agent:reviewer-gh56`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and delegation `D42` from `R41`, marked as reported. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `agent:reviewer-gh56` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. + +These records have limits: + +- They are unsigned, so they do not provide non-repudiation. +- In external mode, AS-2 and vMCP records belong to the gateway operator; the Mecatl-side trail ends at the broker's dispatch event. +- AS-2 may accept the same ID-JAG more than once (ID-JAG §4.4.3 allows re-submission), so one ID-JAG can lead to several redemption events. +- Reaching one operation from a gateway record depends on the propagated operation ID; the mechanism that carries it is not yet chosen. The `agent:` identifiers in `act` are opaque on purpose: the SPIFFE path, with its creation ancestry, stays inside Mecatl. AS-1's issuance record must link each `agent:` identifier it writes to the registered instance it validated, so an investigator can map a gateway record back to that instance. diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md index a99e7bdf6e..ef87344ebc 100644 --- a/docs/drafts/agent-identity/credential-flow-rework.md +++ b/docs/drafts/agent-identity/credential-flow-rework.md @@ -155,8 +155,8 @@ When no credential or consent exists, vMCP or AS-2 refuses and the broker return ### D4: delegation visibility at the gateway -- **Short term (B): single-level `act`.** The ID-JAG and `V` name only the current actor, for example `"act": {"sub": "agent:reviewer-gh56"}`. There is no nested `act`. - - The identifier is opaque, stable, and per instance: no SPIFFE path, no definition name, and no `act.iss`. The ID-JAG's own `iss` is AS-1. +- **Short term (B): single-level `act`.** The ID-JAG and `V` name only the current actor, for example `"act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}`. There is no nested `act`. + - The identifier is opaque, stable, and per instance: no SPIFFE path and no definition name. `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`, whose own `iss` is AS-2. - AS-1 derives it from the validated `actor_token`, the calling instance's JWT-SVID. The full SVID stops at AS-1. - The delegation, for example that main delegated `D42` to the reviewer, stays in Mecatl's harness and broker records, not in the token. - Why not nest now: RFC 8693 §4.1 says prior actors in a nested `act` are informational only and must not drive access decisions, and the gateway only ever used the outer `act.sub`, so dropping the inner level loses no enforcement. The protocol-clean way to get a nested `act` is the chained exchange below. Building the inner level from the registered parent would record creation, not delegation, which is wrong. Building it from a harness-reported delegation would make AS-1 sign a claim it never checked. @@ -166,7 +166,7 @@ When no credential or consent exists, vMCP or AS-2 refuses and the broker return 3. The reviewer's calls under `D42` use `subject_token=T-D42` and `actor_token=reviewerSVID`. 4. AS-1 wraps the existing actor: `act {reviewer, act {main}}` ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)). This matches the pattern in draft-mcguinness-oauth-actor-profile, an individual draft. - It costs a broker operation at delegation time. The broker holds `T-D42` per delegation and renews it from AS-1 server state. The `V` cache key becomes `(A42, D42, reviewer)`. `T-D42` is revoked when the task ends. `T-D42` is also where per-task narrowing, as scope or RFC 9396 `authorization_details`, would live. The McGuinness profile requires `act.iss`; D4 omits it, which conforms to RFC 8693. + It costs a broker operation at delegation time. The broker holds `T-D42` per delegation and renews it from AS-1 server state. The `V` cache key becomes `(A42, D42, reviewer)`. `T-D42` is revoked when the task ends. `T-D42` is also where per-task narrowing, as scope or RFC 9396 `authorization_details`, would live. The McGuinness profile requires `act.iss`, which D4 sets. - **Long term (C): rescope each token.** AS-1 writes the instance's effective ceiling into the ID-JAG as scope or `authorization_details` (RFC 9396), AS-2 carries it into `V`, and the gateway enforces it as a second enforcement point. The ID-JAG and `V` formats reserve that slot now. - Until C, the agent ceiling is enforced by the broker alone; the gateway enforces user and connection. The threat model states this. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index bbc7cd3579..472840ff80 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -250,7 +250,7 @@ A cache hit skips the token exchanges, never these checks. User, run, and delega The broker does not check provider connections. It authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. -**Evidence:** `op-pr42-001` passed the effective-ceiling policy and session, workload, and actor checks. This decision proves neither dispatch nor success. The [audit chapter](audit.md#illustrative-authorization-evidence) owns the full evidence shape. +**Evidence:** `op-pr42-001` passed the session, workload, and actor checks, and, once the authority work is in place, the effective-ceiling check. This decision proves neither dispatch nor success. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. ### (7) ID-JAG from AS-1 @@ -265,14 +265,14 @@ AS-1 resolves `RT-A42` to its record and checks that the session is open, the ha "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 } ``` -- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path, no definition name, and no `act.iss`. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. +- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name; `act.iss` names AS-1, which minted it. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. - AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; the draft forbids an issuer redeeming its own ID-JAG (§9.3). @@ -298,7 +298,7 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): { "iss": "https://gateway.example.com", "sub": "usr-alice-7c1d", - "act": {"sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, "client_id": "mecatl", "aud": "https://gateway.example.com/mcp", "iat": 1800000005, diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index b43a022ecc..cfb260cabf 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -82,7 +82,7 @@ audience=https://gateway.example.com client_id=mecatl + client assertion ``` -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is omitted. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). **AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then maps the AS-1 subject to a native user: @@ -120,7 +120,7 @@ Test ID-JAG issuance: - AS-1 rejects an expired, revoked, or foreign-client refresh token, and a refresh token whose session is closed or past its hard expiry. - AS-1 rejects an untrusted or expired SVID, an SVID with an audience not accepted by both AS-1 and the broker, and an instance not registered to the refresh token's session. -- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `agent:` identifier of the validated actor and which has no nested `act` and no `act.iss`. A caller cannot change `sub` or `act` through request parameters. +- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `agent:` identifier of the validated actor and which has no nested `act` and has `act.iss` set to AS-1's issuer. A caller cannot change `sub` or `act` through request parameters. Test AS-2 redemption: @@ -146,7 +146,7 @@ Test user resolution and connection entitlement separately: neither an OAuth suc ## ToolHive work list -AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive's. The rework checked these capabilities against ToolHive main `ce74cfa37` by reading, not running, the code. Sizes are the rework's estimates. +This table is the ledger of ToolHive changes the design needs. None is filed as a ToolHive issue yet; when one is, link it here. AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive's. The capabilities were checked against ToolHive main `ce74cfa37`, and the revocation rows against `9adad0abb`, by reading, not running, the code. Sizes are estimates. | Capability | Today | Size | When | | --- | --- | --- | --- | @@ -154,19 +154,22 @@ AS-1 and AS-2 are ToolHive authorization server instances, and vMCP is ToolHive' | Refresh token in a token-exchange response, with the session-bound record | No; exchange issues access tokens only | Medium to large | Identity work | | Issue ID-JAGs (`typ=oauth-id-jag+jwt`, audience = another AS) | No; redemption and an outbound XAA client only. The ID-JAG redemption handler binds the client, consumes `jti`, and copies `act`; the plain JWT-bearer handler drops `act` and replaces the client | Large | Identity work | | Refresh token as `subject_token` | No | Large | Identity work | -| Single-level `act` from the registered actor input (nesting later, with the chained exchange) | Partly; `buildActClaim` builds `act` from a client or self-issued actor token and nests it | Medium | Identity work | -| ID-JAG redemption keeps `act` | Yes, copied unchanged | None; verify only | Identity work | +| Single-level `act` from the registered actor input, with `act.iss` set to AS-1's issuer (nesting later, with the chained exchange) | Partly; `buildActClaim` builds `act` from a client or self-issued actor token and nests it | Medium | Identity work | +| ID-JAG redemption keeps `act`, including `act.iss` | Yes, copied unchanged | None; verify only | Identity work | | AS-2 maps the AS-1 subject to a native user after ID-JAG redemption, and stops marking the token as having no upstream session | No; emits `iss#sub` and marks no upstream session | Large | Identity work | | Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, a per-interactive-login session ID that a delegated `V` lacks, and vMCP loads nothing for a token without one (`pkg/auth/token.go:1253`); the enterprise per-user decorator is build-tagged | Medium | Identity work | | Consent link at AS-2's own login (external mode only) | No | Not sized | Identity work | | `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | Identity work | | SPIFFE client authentication at the authorization server | Yes | None; verify only | Identity work | -| AS-1 and AS-2 [audit records](audit.md) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | Identity work | +| AS-1 and AS-2 audit records following the [AuditEvent profile](audit.md#auditevent-profile) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | Identity work | | Revoke a broker refresh token and its record, by an RFC 7009 endpoint or, while the broker runs inside mecak8s, in process | No; storage revokes by refresh-token family, but no endpoint is wired | Small | Identity work | | Revoke all of a user's grants, including schedule authority | No; no index from user to refresh-token families | Medium | Identity work | | Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | +| AS-1 and the vMCP record the operation and attempt IDs the broker passes with each exchange and MCP request, marked as reported | No | Small | Identity work | +| Reject a token whose upstream session records were deleted | No; the lookup returns an empty credential map instead of failing | Small | Identity work | | Refresh token bound to the AS-1 login session (`sid`), so that signing out at AS-1 revokes it | No; `tsid` is per authorization request, not per login | Large | Later | | Receive logout and account events from the company IdP (OIDC Back-Channel Logout, Okta event hooks, Shared Signals) | No | Large | Later | +| End vMCP sessions when the user's grants are revoked | No; sessions bind issuer and subject with no revocation check | Not sized | Later | | DPoP issuance and validation for `V` | No | Large | Later | | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | | Cedar at the gateway reading `act` | No | Part of the narrowing work | Later | @@ -239,6 +242,20 @@ The ceiling checks below belong to the [authority work](README.md#order-of-work) - Preserve existing local checks, AS-1 and AS-2 decisions, and vMCP checks. A ceiling permit must not bypass their denials or substitute for user entitlement. The gateway does not evaluate the agent ceiling; Cedar reading `act` there is later work. - Verify audit provenance, definition and policy revision correlation, safe reasons and policy IDs, and distinct decision/dispatch/outcome evidence per operation and attempt. Diagnostic errors must yield an effective deny in the record. Redaction must exclude secrets and unrestricted payloads. Test unknown outcomes without automatic replay and durability across restart once storage, ordering, and failure handling are specified; no transactional ledger is selected here. +## Audit profile checks + +Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real toolhive-core logger, reading the configured destination: + +- Each field means the same in the JSON record and in the slog output, including `Metadata.Extra`, `Data`, and the occurrence time; the two outputs name some fields differently. +- A caller-supplied `AuditID` and `LoggedAt` are unchanged when the event is sent again. +- Records with a missing identity, a reported-only identity, and an unauthenticated denial carry the right provenance marks, and an unverified identity never appears as `user_id`. +- An unknown outcome, a retry, and a late result produce distinct attempts and events, and the late result references the earlier event. +- Each event type rejects outcomes outside its fixed set. +- `Data` is valid JSON matching its structure version, and `Subjects`, `Target`, and `Data` contain only keys allowlisted for the type. +- No token, assertion, refresh token, authorization header, or token-endpoint request body appears in any record, including errors. +- `DelegationChain` output matches what toolhive-core produces for the same `act` chain. +- A delivery failure is reported through the logger, as toolhive-core [#343](https://github.com/stacklok/toolhive-core/issues/343) proposes. + ## Sign-out and revocation checks Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation): diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index d59b7aca75..bc3b153e95 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -118,7 +118,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 @@ -127,7 +127,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. -`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is omitted; the ID-JAG's own `iss` is AS-1. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. +`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted the `agent:` identifier, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. `aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). @@ -168,7 +168,7 @@ An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked "sub": "usr-alice-7c1d", "aud": "https://gateway.example.com/mcp", "client_id": "mecatl", - "act": {"sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, "iat": 1800000005, "exp": 1800000120, "jti": "access-pr42-001" From 905f9e9f89991bf0a1fbb8d2a7bb5d383a81924a Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 21:54:01 +0200 Subject: [PATCH 26/50] docs: record delegation lineage as retained audit events Add registration and delegation events that link each instance to its creation parent and each delegation to its parent delegation by AuditID. They follow the audit retention period rather than the session, so an investigator can reconstruct main to reviewer to specialist after a restart or session cleanup, and an unresolved link is reported as an incomplete chain. Tool-call events now reference their delegation instead of inlining it. mecak8s reports each delegation to the broker when it happens. Add the reconstruction checks to the implementation companion. Co-Authored-By: mecatl --- docs/drafts/agent-identity/audit.md | 18 ++++++++++++++++-- docs/drafts/agent-identity/implementation.md | 3 +++ .../drafts/agent-identity/mcp-authorization.md | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 4f0d99c2a9..38fd4510c3 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -44,7 +44,7 @@ Every component records evidence as a [toolhive-core](https://github.com/stacklo | Correlation | `Metadata.Extra` | `mecatl.operation_id` and `mecatl.attempt_id`. A retry is a new attempt. A result that arrives after an `unknown` outcome is a new event that references the earlier one; records are never edited. The broker passes the operation and attempt IDs to AS-1 and the vMCP, which record them as reported. | | Evidence references | `Data` | Built from a typed structure per event type. Credential references are `(iss, jti)` or `(authorization server, record ID)`, never values. Definition revision and provenance. The broker does not inspect `V`, so its events reference AS-1's issuance event and AS-2's redemption event instead of `V`'s `jti`. | | OAuth actor history | `DelegationChain` | Only an `act` chain the recorder validated in a token: AS-2 and the vMCP fill it, and broker events leave it empty. | -| Creation and delegation | `Data.lineage` | The run, the delegation (`D42` from run `R41` and its instance), and the creation parent, each marked as reported by mecak8s. They are never derived from the SPIFFE path or from `act`. | +| Creation and delegation | `Data.lineage` | The run, plus references: the `AuditID` of the delegation event the call runs under and of the acting instance's registration event. The [lineage](#lineage) events hold the relationships, marked as reported by mecak8s. They are never derived from the SPIFFE path or from `act`. | | Profile version | `Metadata.Extra` | `mecatl.profile` names this profile and its version; each `Data` structure carries its own version. | Each event type uses a fixed set of outcomes: @@ -58,6 +58,7 @@ Each event type uses a fixed set of outcomes: The identity work needs these event families: - definition load, instance registration, and agent-instance credential issuance; +- each delegation from one run to another; - broker session open and close, and AS-1's conversion record at session open; per-call events point to the session-open record for the user; - sign-out everywhere, with each run stopped, broker session deleted, refresh token revoked, and schedule authority revoked; - an upstream re-check failure and the revocation that follows; @@ -69,7 +70,7 @@ The identity work needs these event families: The authority work adds Cedar evaluation status, policy IDs, and policy and ceiling revisions. Until then, decision events record that no ceiling was evaluated. -For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `agent:reviewer-gh56`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and delegation `D42` from `R41`, marked as reported. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `agent:reviewer-gh56` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. +For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `agent:reviewer-gh56`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and a reference to the delegation event for `D42`. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `agent:reviewer-gh56` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. These records have limits: @@ -78,6 +79,17 @@ These records have limits: - AS-2 may accept the same ID-JAG more than once (ID-JAG §4.4.3 allows re-submission), so one ID-JAG can lead to several redemption events. - Reaching one operation from a gateway record depends on the propagated operation ID; the mechanism that carries it is not yet chosen. +### Lineage + +Two event types record how instances relate. Both are marked as reported by mecak8s, because only mecak8s observes these relationships. + +- **Registration** (`mecatl_instance_registered`) records the instance, its definition, its session tree, and the `AuditID` of its creation parent's registration. +- **Delegation** (`mecatl_delegation`) records the delegation, such as `D42`; the delegating run and instance; the receiving run and instance; and, when the delegating run was itself delegated, the `AuditID` of that parent delegation. + +Lineage events are audit records. They are kept for the audit retention period, and session cleanup does not delete them. References between them are `AuditID`s in the append-only audit store, not live session state. + +From any tool call, an investigator follows the delegation reference up to the root to reconstruct execution delegation, and registration references to reconstruct creation ancestry. If a reference does not resolve, the reconstruction reports the chain as incomplete, names where it stopped, and gives the reason: unresolved, truncated, or malformed. It stops on a cycle. It never presents a partial chain as complete. + The `agent:` identifiers in `act` are opaque on purpose: the SPIFFE path, with its creation ancestry, stays inside Mecatl. AS-1's issuance record must link each `agent:` identifier it writes to the registered instance it validated, so an investigator can map a gateway record back to that instance. ## Decision, dispatch, and observed outcome @@ -92,6 +104,8 @@ Use operation and attempt references to connect the evidence; individual events Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. Session `A42` leads to AS-1's conversion record, which names user `u-alice-91`, the source access-token `jti`, and the hard expiry, and from there to mecak8s's record of the request and session ownership. On a cache miss, AS-1's issuance record links `A42` and the validated reviewer to ID-JAG `jag-pr42-001`; AS-2's redemption record links that `jti` to native user `usr-alice-7c1d` and access token `access-pr42-001`. In external mode, AS-2's consent-link record shows when Alice linked Mecatl. The vMCP record contains the access-token reference it validates and the reported operation ID `op-pr42-001`. A call served from the cache has no new AS-1 or AS-2 record; it links to the earlier issuance through the cached token, and to its own operation through the reported operation ID. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with AS-1-witnessed user identity distinguished from workload-reported run, delegation, and lifecycle facts. +Delegation can run deeper. Suppose the reviewer delegates a dependency check to a `security-reviewer` specialist, whose run calls a tool. The specialist's decision event references its delegation event, which names the reviewer's run `R42` and references the delegation event for `D42`. That event names main's run `R41`, which belongs to Alice's session. The registration events show separately that main created the reviewer and the reviewer created the specialist. Every link in that walk is reported by mecak8s; Alice's identity comes from AS-1's record. Two specialists from the same definition stay distinct because each has its own instance and registration. + The investigator then follows dispatch and observed-outcome records for that attempt, rather than inferring execution from an allow or token issuance. ToolHive records can explain downstream validation or denial. The native provider-credential path is not itself a backend-to-SaaS audit trail. Where the provider supports safe tracing or audit correlation, retain the references needed to reach its operation or audit record and trace it back to the actor. Mark where correlation ends if provider evidence or authenticated cross-service correlation is unavailable. A successful response describes what the observer received, not a guarantee of provider-side effects. ## Redaction and audit-store protection diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index cfb260cabf..d3f49408cc 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -254,6 +254,9 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - `Data` is valid JSON matching its structure version, and `Subjects`, `Target`, and `Data` contain only keys allowlisted for the type. - No token, assertion, refresh token, authorization header, or token-endpoint request body appears in any record, including errors. - `DelegationChain` output matches what toolhive-core produces for the same `act` chain. +- A three-level delegation chain, main to reviewer to specialist, can be reconstructed from lineage events after a broker restart and after session cleanup. +- Two instances of the same definition under different parents stay distinct in reconstruction. +- A missing or malformed lineage record makes reconstruction report an incomplete chain, not a shorter one. - A delivery failure is reported through the logger, as toolhive-core [#343](https://github.com/stacklok/toolhive-core/issues/343) proposes. ## Sign-out and revocation checks diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 7a6886254b..d320891f2b 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -18,7 +18,7 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. mecak8s reports each delegation to the broker when it happens, so the broker can record it as a [lineage event](audit.md#lineage). Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority From 3557e502d6f21224e4c9264e4cfb9fe46457d1ed Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 22:00:25 +0200 Subject: [PATCH 27/50] docs: specify audit delivery, protection, and reconciliation Every component records through the toolhive-core audit logger, whose sink the deployment points at a store mecak8s cannot write or delete; records delivered before a compromise survive even while the broker runs inside mecak8s. ToolHive's authorization server records nothing today, and the ledger row now includes sign-in and token issuance. Add the producers of each event family, the broker's write order (decision accepted by the sink before dispatch, otherwise no dispatch), contiguous per-stream sequence numbers, cross-store reconciliation checks, broker consistency checks on reported lineage, retention rules, and coverage. Add a provenance mark for a verified credential whose binding mecak8s reported. Hash chains and signed records stay later work. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 2 +- docs/drafts/agent-identity/audit.md | 51 ++++++++++++++++++-- docs/drafts/agent-identity/implementation.md | 6 ++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index c9f81fcbb5..e49cf1bf72 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -101,7 +101,7 @@ Where the broker runs matters against one attacker: someone with code execution | Act as any user, by signing an ID-JAG for a user who is not present | Open. The process holds AS-1's signing key and the broker's client key. In embedded mode this reaches every user connected to the gateway; in external mode, only users with a consent link. | Narrowed. mecak8s holds no keys. It can act only in open broker sessions bound to its workload, until each closes or its refresh token's hard expiry passes, and it can open sessions with fresh tokens from users currently using it. | | Steal refresh tokens or signing keys | Open. A KMS prevents copying a key out, not signing with it while the attacker is inside. | Closed, if the broker has its own database credentials and only the broker's service account may sign with its KMS keys. | | Read users' provider tokens in embedded mode | Open, if the gateway store shares storage credentials with mecak8s. | Closed, if the embedded gateway is its own deployment with its own credentials. | -| Tamper with broker and AS-1 audit records | Open. One process writes them all. | Closed, if audit goes to an append-only sink that mecak8s cannot write. | +| Tamper with broker and AS-1 audit records | Partly closed. Records already delivered to an external append-only store survive; records from the compromised process can be forged or suppressed from then on. | Closed for broker and AS-1 records, which mecak8s no longer writes, if they go to an append-only store that mecak8s cannot write. | In both deployments, the broker takes these facts on mecak8s's word: which agent instance made a call, which loaded definition a session uses as its root, which instance created which child, the run and delegation context, and which open session a call belongs to. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark the facts mecak8s reported. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 38fd4510c3..eb4464bc30 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -36,7 +36,7 @@ Every component records evidence as a [toolhive-core](https://github.com/stacklo | What kind of event | `Type` | A `mecatl_`-prefixed snake_case type, such as `mecatl_tool_decision`. The vMCP keeps ToolHive's own types, such as `mcp_tool_call`. Whether an event is a decision, a dispatch, or an observed outcome is part of its type. | | Result | `Outcome` | toolhive-core's `success`, `failure`, `error`, and `denied`, plus `unknown` once #341 defines it. Each type uses a fixed subset; see the next table. | | Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `agent:` identifier), and `agent_instance_ref`. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | -| Verified or reported | `Metadata.Extra["mecatl.provenance"]` | A map keyed by the `Subjects` key: `verified`, `record:` when taken from a retained record, `reported:`, or `unavailable`. A missing fact is marked `unavailable`, never left out silently. | +| Verified or reported | `Metadata.Extra["mecatl.provenance"]` | A map keyed by the `Subjects` key: `verified`, `credential-verified` when the recorder verified a credential but mecak8s reported which instance it belongs to, `record:` when taken from a retained record, `reported:`, or `unavailable`. A missing fact is marked `unavailable`, never left out silently. | | Where it originated | `Source` | The network origin, as in ToolHive: the peer address, plus `request_id` and `session_id` for joining. The calling workload's identity belongs in `Subjects`, not here. | | Where it was recorded | `Component`, `Metadata.Extra` | `mecatl-broker`, `mecatl-as1`, or the ToolHive component name, plus the recording replica and trust domain. | | Operation and target | `Target` | toolhive-core's keys: `type=tool`, `name`, `method=tools/call`, and the MCP server. For token-endpoint events, the requested audience and resource. Request metadata appears only if allowlisted for that type. | @@ -57,6 +57,8 @@ Each event type uses a fixed set of outcomes: The identity work needs these event families: +- sign-in and access-token issuance at AS-1; +- API requests and session ownership checks at mecak8s; - definition load, instance registration, and agent-instance credential issuance; - each delegation from one run to another; - broker session open and close, and AS-1's conversion record at session open; per-call events point to the session-open record for the user; @@ -66,7 +68,7 @@ The identity work needs these event families: - redemption and denial at AS-2, and consent-link creation and revocation in external mode; - the broker's decision, dispatch, and observed outcome for each tool call; - the vMCP's tool-call event; -- audit delivery failure. +- audit delivery failure, and the start and stop of each component's record stream. The authority work adds Cedar evaluation status, policy IDs, and policy and ceiling revisions. Until then, decision events record that no ceiling was evaluated. @@ -96,10 +98,51 @@ The `agent:` identifiers in `act` are opaque on purpose: the SPIFFE path, with i The broker records its authorization decision, whether it attempted dispatch, and the outcome it observed as separate facts. If vMCP reports which provider connection it used, the broker records that identifier as observed, not as something the broker authorized. An allow is not a dispatch; a dispatch attempt is not proof of receipt or success. A denial dispatches no backend operation. If completion is missing after possible dispatch, the outcome is unknown, not evidence of nonexecution. Credential recovery cannot justify automatic replay of an unknown operation. -Decision, dispatch, and observed-outcome evidence must be durable and survive broker restart. The storage mechanism, write ordering relative to dispatch, failure handling when evidence cannot be persisted, and cross-service delivery and authenticated correlation remain explicitly unresolved implementation requirements. This design does not select a transactional ledger or claim atomicity between an audit write and an external effect. +Decision, dispatch, and observed-outcome evidence must survive broker restart; [delivery, protection, and reconciliation](#delivery-protection-and-reconciliation) sets the write order and what happens when evidence cannot be delivered. This design does not claim atomicity between an audit write and an external effect. Use operation and attempt references to connect the evidence; individual events also need their own references. The operation ID, the ID-JAG `jti`, and the AS-2 access token `(iss, jti)` are separate references. A cached access token serves several calls, so its `jti` cannot replace a per-operation reference. To reach one operation from a gateway record, the broker must propagate the operation ID with each MCP request, and vMCP records it as reported, unverified trace context. This propagation is required for correlation; the mechanism is still to be chosen, and it is not an authenticated propagation contract. Because `act` names only the current actor, this operation ID is what keeps delegation `D42` reachable from a gateway log. +## Delivery, protection, and reconciliation + +Correctly shaped records are not enough if they can be lost, rewritten, or never compared. + +**Emission and destination.** Every component records through the toolhive-core audit logger: mecak8s, the broker, AS-1, AS-2, and the vMCP. The logger's sink already writes to standard output or an HTTP endpoint, so each deployment chooses where records go. ToolHive's authorization server records no audit events today; adding them, including sign-in and token issuance, is in the [ToolHive ledger](implementation.md#toolhive-work-list). + +**Protection.** The deployment points every component's sink at a store that mecak8s cannot write to or delete from, such as an append-only collector or object storage with retention locks. This protects records already delivered even while the broker runs inside mecak8s: a compromise can forge or suppress records from then on, but cannot rewrite earlier ones. Investigators get read-only access to the store. + +**Producers.** Each component records the event families it observes first-hand: + +| Component | Event families | +| --- | --- | +| mecak8s | API requests and session ownership checks | +| Broker | Definition load, instance registration, delegation, session open and close, sign-out everywhere, and decision, dispatch, and observed outcome for each tool call | +| AS-1 | Sign-in, including the company IdP session and authentication time; access-token issuance; the session-open conversion; ID-JAG issuance and denial; refresh-token revocation; upstream re-check failures | +| AS-2 | Redemption and denial; consent-link creation and revocation | +| vMCP | Tool calls | + +**Write order and failure.** The broker mints each attempt ID. Before it dispatches a tool call, its decision record must be accepted by the sink; if the broker cannot deliver it, the broker refuses to dispatch. This is the broker's choice, not a library default. A record being accepted by a sink is not the same as durable storage; the deployment's destination provides durability. Delivery failures are reported on a separate channel, as toolhive-core [#343](https://github.com/stacklok/toolhive-core/issues/343) proposes, so a failing destination does not hide its own failure. + +**Gap detection.** Each component numbers its records with a counter that is contiguous for each component, replica, and process start, and records a start and stop event for each stream. A missing number or stream end shows that records were lost or removed. All components use a synchronized time source, and each record names whose clock it used. + +**Reconciliation.** Records from different stores are checked against each other, and each mismatch is flagged: + +| Mismatch | What it can mean | +| --- | --- | +| An AS-2 redemption with no matching AS-1 ID-JAG issuance | An ID-JAG minted outside the recorded path | +| A vMCP tool call with no matching broker dispatch for the same user and actor | An access token used outside the broker | +| A broker dispatch with no matching vMCP tool call | A record suppressed on one side | +| A session-open conversion whose source token has no AS-1 issuance and sign-in | A user token minted outside the recorded path | + +In external mode, the gateway's records belong to its operator, so these checks need the operator's records. + +**Lineage consistency.** When mecak8s reports a delegation, the broker checks that the delegating and receiving instances are registered to the same session tree, and that the parent delegation's receiving run is the delegating run. A delegation that passes is marked as reported and consistency-checked. These checks catch mistakes, not a compromised mecak8s. + +**Retention.** A lineage event is kept at least as long as any event that references it. Each deployment names who owns retention and for how long. When the gateway is external, the deployment agrees with its operator on how long gateway records are kept and how investigators reach them. + +**Coverage.** These records cover broker-mediated MCP calls and the identity lifecycle around them. Filesystem tools, Shell, and spawning are not broker-mediated, so the harness's own logs are their only record. + +**Later.** After the broker becomes a separate service, each component can hash-chain its records and anchor signed checkpoints outside its own store. While the broker runs inside mecak8s, the signing key would sit with the attacker, so the external store already gives most of the value. Signed records for non-repudiation are also later work. + ## Following a call in an investigation Starting from `op-pr42-001`, an investigator follows each attempt to the broker decision, the registered actor and fixed definition revision, and the generated policy evidence. Session `A42` leads to AS-1's conversion record, which names user `u-alice-91`, the source access-token `jti`, and the hard expiry, and from there to mecak8s's record of the request and session ownership. On a cache miss, AS-1's issuance record links `A42` and the validated reviewer to ID-JAG `jag-pr42-001`; AS-2's redemption record links that `jti` to native user `usr-alice-7c1d` and access token `access-pr42-001`. In external mode, AS-2's consent-link record shows when Alice linked Mecatl. The vMCP record contains the access-token reference it validates and the reported operation ID `op-pr42-001`. A call served from the cache has no new AS-1 or AS-2 record; it links to the earlier issuance through the cached token, and to its own operation through the reported operation ID. Run `R42` and delegation `D42` lead back to main run `R41` and the API request, with AS-1-witnessed user identity distinguished from workload-reported run, delegation, and lifecycle facts. @@ -112,4 +155,4 @@ The investigator then follows dispatch and observed-outcome records for that att Record only allowlisted argument and result metadata needed for attribution, with redaction applied before persistence or logging. Safe status categories and nonsecret provider references usually suffice for outcomes. Exclude tokens, refresh tokens, ID-JAGs, client assertions, private keys, client secrets, raw headers, OAuth transaction secrets, and unrestricted argument or result payloads. Record credentials by issuer and `jti` or by record reference, never by value. Policy diagnostics and error text need the same treatment; dumping decoded credentials is not an audit strategy. -Protecting persisted evidence against unauthorized disclosure or modification requires audit-store access and integrity controls. Recording authenticated identities does not itself protect the store. The [credential walkthrough](credentials-and-lifecycle.md#interactive-execution) owns credential validation and OAuth examples; this chapter specifies the evidence needed to investigate their use. +Recording authenticated identities does not itself protect the store; [delivery, protection, and reconciliation](#delivery-protection-and-reconciliation) covers the destination and its access. The [credential walkthrough](credentials-and-lifecycle.md#interactive-execution) owns credential validation and OAuth examples; this chapter specifies the evidence needed to investigate their use. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index d3f49408cc..17b99c51e2 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -161,7 +161,7 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | Consent link at AS-2's own login (external mode only) | No | Not sized | Identity work | | `private_key_jwt` for client `mecatl` at AS-1 and AS-2 | Yes | Configuration | Identity work | | SPIFFE client authentication at the authorization server | Yes | None; verify only | Identity work | -| AS-1 and AS-2 audit records following the [AuditEvent profile](audit.md#auditevent-profile) | No; the authorization server records no audit events. The plan reuses ToolHive's audit event recorder library, which emits JSON records for collection with fluentd or fluent-bit | Not sized | Identity work | +| AS-1 and AS-2 audit records following the [AuditEvent profile](audit.md#auditevent-profile), including sign-in, access-token issuance, and refresh-token revocation at AS-1 | No; the authorization server records no audit events. The toolhive-core audit logger and its sink already exist | Not sized | Identity work | | Revoke a broker refresh token and its record, by an RFC 7009 endpoint or, while the broker runs inside mecak8s, in process | No; storage revokes by refresh-token family, but no endpoint is wired | Small | Identity work | | Revoke all of a user's grants, including schedule authority | No; no index from user to refresh-token families | Medium | Identity work | | Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | @@ -258,6 +258,10 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - Two instances of the same definition under different parents stay distinct in reconstruction. - A missing or malformed lineage record makes reconstruction report an incomplete chain, not a shorter one. - A delivery failure is reported through the logger, as toolhive-core [#343](https://github.com/stacklok/toolhive-core/issues/343) proposes. +- The broker refuses to dispatch when its decision record cannot be delivered, and dispatches only after the sink accepts it. +- Removing a record or ending a stream without its stop event shows as a sequence gap. +- Each reconciliation check flags an injected mismatch: an AS-2 redemption without AS-1 issuance, a vMCP call without broker dispatch, a dispatch without a vMCP call, and a session-open conversion without AS-1 issuance. +- The broker's lineage check rejects a delegation whose instances are in different session trees or whose parent delegation's receiving run differs. ## Sign-out and revocation checks From f7c5f58e501aea3d1c70c726aed033ca8b129565 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 22:02:18 +0200 Subject: [PATCH 28/50] docs: fold the credential flow rework into the chapters The rework record was an intermediate step that kept collecting decisions. Move what only it held into the owning chapters: the chained-exchange plan and why act is not nested now (MCP authorization), why the agent SVID stays and its possible strengthenings (credentials walkthrough), the pluggable roles for standalone and enterprise deployments (gateway modes), and what the user sees. Repoint its links and delete it; the ToolHive ledger in the implementation companion replaces its gaps table. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 1 - .../agent-identity/credential-flow-rework.md | 248 ------------------ .../credentials-and-lifecycle.md | 24 +- docs/drafts/agent-identity/implementation.md | 6 +- .../agent-identity/mcp-authorization.md | 13 + .../agent-identity/mcp-gateway-modes.md | 8 +- 6 files changed, 40 insertions(+), 260 deletions(-) delete mode 100644 docs/drafts/agent-identity/credential-flow-rework.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index e49cf1bf72..d87593f645 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -160,6 +160,5 @@ The proposed chapters develop each part of the architecture. | [MCP gateway modes](mcp-gateway-modes.md) | Chain the broker's grant issuer to the vMCP gateway's authorization server, so one protocol works with an embedded or an external gateway. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | | [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | -| [Credential flow rework](credential-flow-rework.md) | Record the decisions behind the current credential flow and their rationale, the ToolHive gaps, and open questions. | The [implementation and verification companion](implementation.md) records integration requirements and checks. This broker-centered architecture supersedes the earlier identity model's harness-owned signing and audit responsibilities; the donor documents are retained in Git history. diff --git a/docs/drafts/agent-identity/credential-flow-rework.md b/docs/drafts/agent-identity/credential-flow-rework.md deleted file mode 100644 index ef87344ebc..0000000000 --- a/docs/drafts/agent-identity/credential-flow-rework.md +++ /dev/null @@ -1,248 +0,0 @@ -# Credential flow rework - -*Decision record, not current behavior. It records the decisions behind the credential flow in [credentials and lifecycle](credentials-and-lifecycle.md) and their rationale. The other chapters describe the resulting design.* - -The flow was reviewed right to left, from the vMCP call to a SaaS provider back to the user's login. For each credential the review asked what it must contain, what it proves, and who issues and verifies it. The guiding question is provenance: who first established each fact, and how many signers relayed it before it reached the party that relies on it. - -The design must work in two deployments: - -- **Standalone Mecatl**, with no Stacklok Enterprise Platform (SEP), no Directory, and no connector gateway. -- **Enterprise**, integrating with Directory and the connector gateway. - -Roles are therefore pluggable. Where a role is backed by Directory in enterprise deployments, Mecatl provides a local implementation that can later delegate to SEP. - -## Summary of the converged flow - -```mermaid -sequenceDiagram - actor U as Alice (TUI / Studio) - participant H as mecak8s - box Broker - participant B as Broker core - participant A1 as AS-1 (login + ID-JAG issuer) - end - participant IdP as Company IdP - box Gateway - participant A2 as AS-2 - participant V as vMCP - end - participant G as GitHub - - U->>A1: Sign in: authorization code + PKCE
(issuer discovered from protected-resource metadata) - A1->>IdP: Redirect Alice to company IdP sign-in
(AS-1 is an ordinary OIDC client there) - IdP-->>A1: Alice authenticated - A1-->>U: AS-1 access + refresh token (client-held, as today) - U->>H: Request + AS-1 access token - H->>B: OpenSession: workload auth + AS-1 access token (once) - B->>A1: RFC 8693: subject_token = access token (aud = Mecatl API)
client mecatl, private_key_jwt - A1-->>B: Broker refresh token (session-bound, hard max lifetime) - Note over H,B: Every tool call - H->>B: InvokeTool: workload auth + sessionRef + agent JWT-SVID - opt No cached V for (session, instance, gateway) - B->>A1: RFC 8693: subject_token = broker refresh token
actor_token = agent JWT-SVID
requested_token_type = id-jag, audience = AS-2 - A1->>A1: Validate SVID, check instance belongs to session
Build act - A1-->>B: ID-JAG (sub = Alice at AS-1, act, client_id = mecatl) - B->>A2: RFC 7523: assertion = ID-JAG, client mecatl - A2->>A2: Map AS-1 subject to native user
External mode: require consent link - A2-->>B: V (sub = native user, act, aud = vMCP) - end - B->>V: tools/call + V - V->>V: Validate V, load credential by (gateway, user, github) - V->>G: API call + Alice's GitHub token -``` - -When no credential or consent exists, vMCP or AS-2 refuses and the broker returns the existing `mcp_authorization_pending` outcome. The client opens the authorization URL and rechecks, as both the TUI and Studio already do. - -## Decisions - -### D1: upstream credential lookup (vMCP to provider) - -- Upstream credentials are keyed by **(gateway, native user, provider)**: one record per user and provider, refreshed as a single row with compare-and-swap. The enterprise connector gateway already does this through its user-keyed storage decorator. -- Open-source ToolHive keys credentials by interactive login session, which a delegated `V` does not have, so vMCP would find nothing. Standalone Mecatl needs a per-user lookup in open-source ToolHive; the enterprise decorator is not available there. -- vMCP validates `V`, loads the credential for the route's provider, and calls the provider. A missing credential produces "needs authorization". -- Consent is not checked here. It is checked once, when AS-2 redeems the grant (D2). -- Roles: **identity resolver** (AS-1 subject to native user), **consent store**, **credential store**. Standalone Mecatl provides local implementations; enterprise backs them with Directory and the connector gateway. Nothing in the flow presumes Directory. - -### D2: grant redeemed at AS-2 - -- AS-1 issues an **ID-JAG** ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)). It replaces the bespoke grant `G` and the self-minted association assertion `E`. The broker redeems the ID-JAG at AS-2 with RFC 7523 and receives `V`. -- **Why ID-JAG rather than the original chaining grant `G`.** ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html) (ID-JAG §1, §2.2). It reuses that draft's exchange, its RFC 7523 redemption, its no-refresh-token rule, and its discovery metadata. On top, it fixes: - - an explicit `typ=oauth-id-jag+jwt`; - - required `client_id`, `jti`, `iat`, and `aud` = the resource AS issuer; - - redemption checks that bind the redeeming client (§4.4.1); - - security rules (§9). - - The original `G` would have had to re-specify all of that as a private profile. ToolHive already redeems ID-JAGs with these checks and keeps `act`; its plain JWT-bearer redemption does not. Issuance is new work for either format. One format is used in both modes: the modes differ in AS-2 policy, not in the token. -- **How each mode is described:** - - **Embedded:** ID-JAG as specified. AS-1 is the SSO IdP that AS-2 trusts (§4.1), and the subject is the shared AS-1 `sub` (§5). - - **External:** identity chaining across trust domains (chaining §2.1, §2.5) using the ID-JAG token profile. The §4.1 precondition, that the resource AS already trusts the IdP for SSO, does **not** hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer and resolves subjects only through the explicit consent/identity link (§2.2 deployment-specific resolution). There is no just-in-time provisioning. - - **Deliberate deviation:** `aud_sub` and `email`, both RECOMMENDED by §3.1, are omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. Never resolve through `sub_id` (§9.5). -- AS-1 and AS-2 stay **separate issuers with separate keys**, also in embedded mode. ID-JAG §9.3 forbids an IdP from redeeming an ID-JAG it issued itself. Separate issuers are not process isolation when both run in one process. -- One OAuth client ID, `mecatl`, at both AS-1 and AS-2. ID-JAG §4.4.1 requires the ID-JAG's `client_id` to match the client authenticating at AS-2. -- AS-2 does not issue refresh tokens (§4.4.3). When `V` expires, the broker obtains a new ID-JAG. -- **Consent**, so that a compromised AS-1 cannot use the credentials of a user who never chose Mecatl: - - | Mode | Consent | - | --- | --- | - | Embedded / standalone | Implicit. Users log in to AS-2 through AS-1, and AS-1, AS-2, and the credential store share one trust domain. | - | External gateway | A one-time "Allow Mecatl" step at **AS-2's own login**, generated by AS-2. It records the link (native user, AS-1 issuer + AS-1 `sub`, client `mecatl`). AS-2 redeems an ID-JAG only for a linked subject. Revocable in the gateway UI. | - - The link is keyed on the AS-1 `sub` that appears in the ID-JAG, not on `sub_id` (§9.5). Admin pre-consent at AS-2 without an independent witness of the user is rejected: it would let a compromised AS-1 name every user. - -- **Requirement (decided).** AS-2 trusts AS-1 as an ID-JAG issuer in every mode, and that trust covers signature, issuer, audience, client, and replay. Mapping an AS-1 subject to a native user is a separate requirement: - - **External mode:** AS-2 maps an AS-1 subject to a native user only through a link the user establishes once, witnessed by AS-2 itself. A mapping asserted only by AS-1 is not accepted unless an administrator explicitly decides to accept that risk. - - **Embedded mode:** users already sign in to AS-2 through AS-1, so the mapping exists. - - **Missing link or connection:** the gateway must give the broker enough to send the user to a URL where they can fix it, and the call can then be retried. - - **Ask of ToolHive:** redeem ID-JAGs to native users through such links, support creating the link, and signal "link or connection missing" with a URL. How the link is created and how the signal is carried are implementation choices. -- **The external consent link.** It does two jobs: it maps AS-1's subject to AS-2's native user, and it is the user's first-hand statement at the gateway that Mecatl may act for them. - - The link names the native user, the AS-1 issuer and `sub`, and client `mecatl`. AS-2 redeems an ID-JAG only when its `iss`, `sub`, and `client_id` match a link, and the link supplies the native user. - - The user creates it once per gateway, through AS-2's own login, when the first call comes back as "link required" through the existing `mcp_authorization_pending` path. - - The link flow must resist link swapping: an attacker must not be able to get a victim to link the victim's gateway account to the attacker's AS-1 subject. - - A shared company IdP behind AS-1 and AS-2 can make the step silent, but does not remove it. AS-1, not the IdP, signs `sub_id` (§9.5), so a compromised AS-1 could name any user. - - Dropping the link takes either an administrator's risk decision to trust AS-1 for all users, or a future path where the company IdP itself issues the ID-JAG for AS-2, which needs IdP support and loses `act` at the gateway. - -### D3: login and session admission - -- mecak8s's protected-resource metadata points at **AS-1**, a ToolHive authserver instance running inside the broker, separate from AS-2, that federates upstream to the company IdP. The TUI and Studio already discover the issuer from that metadata, so neither client changes. Both keep their own AS-1 tokens as today. -- At `OpenSession`, mecak8s passes the user's current AS-1 access token to the broker **once**, in place of the harness-attested `human {issuer, subject}`. The broker exchanges it at AS-1 (RFC 8693, option (i): AS-1 accepts a token with `aud` = the Mecatl API when presented by the broker) and receives a **broker-held refresh token** for that session. AS-1 policy: - - accept only tokens AS-1 issued, with `aud` = the Mecatl API, still active, with an unused `jti`; - - accept them only from the authenticated broker client (`private_key_jwt` or mTLS); - - grant no wider scope than the original; - - one conversion per session. -- **Broker client authentication.** The subject token is a bearer token and proves nothing about who presents it. The broker therefore authenticates as OAuth client `mecatl` with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion). On each token-endpoint request it signs a short-lived JWT (`iss` = `sub` = `mecatl`, `aud` = AS-1's issuer identifier, `exp` about 60 seconds, single-use `jti`). AS-1 verifies it against the broker's registered public key or `jwks_uri`. - - ```text - POST https://broker.example.com/as/oauth/token - grant_type=urn:ietf:params:oauth:grant-type:token-exchange - subject_token= - subject_token_type=urn:ietf:params:oauth:token-type:access_token - requested_token_type=urn:ietf:params:oauth:token-type:refresh_token - client_id=mecatl - client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer - client_assertion= - ``` - - This makes "accept only from the broker" enforceable. A leaked access token or broker refresh token is useless without the private key, which never leaves the broker and can live in a KMS or HSM. A refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5), so DPoP on it is redundant. The same client and key are registered at AS-2; in external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected because it is shared, replayable once leaked, and rotated by hand. mTLS (RFC 8705) is an equivalent but harder-to-deploy alternative. ToolHive already supports `private_key_jwt`, so it needs only configuration. -- **Future option: SPIFFE client authentication.** ToolHive's authserver already accepts a SPIFFE JWT-SVID as client authentication. The broker's workload identity could then serve as its OAuth client credential, with no separate key to manage. It is a place where SPIFFE authenticates a real workload, the broker, rather than an in-process agent. -- What AS-1 issues where: an **access token** only at login (`typ=at+jwt`, `aud` = the Mecatl API), as clients and protected-resource-metadata discovery already expect. An `id_token` is not usable there: its audience is the client, not mecak8s or the broker. Per call AS-1 issues the ID-JAG, which is a grant rather than an access token. AS-1 never issues an access token for AS-2's audience. -- **Alternative to the broker refresh token:** a signed session assertion JWT (`sub` = Alice, `aud` = AS-1, bound to the broker client and session, `jti`), presented as `subject_token_type=jwt`. It needs less ToolHive work, because exchange already issues and accepts JWTs. It is not stateless: one conversion per session, a maximum lifetime, and revocation still require an AS-1 session record looked up by `jti` on every exchange. It does not rotate, and it is a profile extension, since ID-JAG §4.3 lists identity assertions and refresh tokens as subject tokens. Like the refresh token, and unlike `E`, AS-1 issues it only after validating a real login token. -- **Session binding of the broker refresh token.** The refresh token is opaque to the broker. Its meaning lives in an AS-1 record: - - | Field | Purpose | - | --- | --- | - | Client `mecatl` | Only the authenticated broker can use it. | - | User (AS-1 `sub`) | Subject of every ID-JAG issued from it. | - | Broker session reference (`A42`) | The binding. | - | Host session and incarnation | Restart and recovery continuity checks. | - | Source access-token `jti` | One conversion per session. | - | Issue time and hard expiry | Authority horizon. Rotation does not extend it. | - | Rotation chain | Reuse detection. | - - Enforcement points: - - 1. **OpenSession.** AS-1 creates the record. The broker supplies its session reference, either as an extension token-request parameter or in-process. - 2. **Each ID-JAG exchange.** The refresh token resolves the record. AS-1 checks that the session is open, that the hard expiry has not passed, and that the acting instance belongs to this session in the registry. - 3. **Session close.** The broker revokes the refresh token and AS-1 deletes the record. - 4. **Rotation.** The new refresh token inherits the record. - - The session reference is broker-asserted. The binding contains bugs, such as cross-session confusion and stale sessions. It does not protect against a compromised broker. This fits ToolHive's stored-session model, the same mechanism that carries `tsid` today. - - A token format that carries these claims, a signed session assertion or a refresh token that AS-1 encodes internally as a signed JWT, adds readable copies of the same data. AS-1 must still look up the record for revocation and the one-conversion rule, and must still read the registry for instance membership. That format pays off only if AS-1 must validate without access to the store. -- The broker refresh token is the subject of each ID-JAG exchange (ID-JAG §4.3 allows a refresh token as `subject_token`). It is the standard form of the human authority in `A42`. `A42` keeps only the workload and host-session binding. -- **Session binding:** the broker refresh token is not tied to the user's AS-1 login session, so signing out of one client does not end it. Sign out everywhere, session close, the hard maximum lifetime, and the upstream re-check end it (D8). Binding to the login session (`sid`), so that signing out at AS-1 revokes it, is later work. -- **Owner identity.** The session owner stays the company identity `(company iss, company sub)`. mecak8s reads it from the validated AS-1 access token's `sub_id`, not from the token's own `iss`/`sub`. Existing owners keep matching, so no migration is needed. The owner remains the human's stable identity across AS-1 user-store rebuilds, and it is the identity the run owner will use later. AS-1 must always set `sub_id`. mecak8s rejects an AS-1 token without it and trusts it because AS-1 ran that sign-in. - -### D4: delegation visibility at the gateway - -- **Short term (B): single-level `act`.** The ID-JAG and `V` name only the current actor, for example `"act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}`. There is no nested `act`. - - The identifier is opaque, stable, and per instance: no SPIFFE path and no definition name. `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`, whose own `iss` is AS-2. - - AS-1 derives it from the validated `actor_token`, the calling instance's JWT-SVID. The full SVID stops at AS-1. - - The delegation, for example that main delegated `D42` to the reviewer, stays in Mecatl's harness and broker records, not in the token. - - Why not nest now: RFC 8693 §4.1 says prior actors in a nested `act` are informational only and must not drive access decisions, and the gateway only ever used the outer `act.sub`, so dropping the inner level loses no enforcement. The protocol-clean way to get a nested `act` is the chained exchange below. Building the inner level from the registered parent would record creation, not delegation, which is wrong. Building it from a harness-reported delegation would make AS-1 sign a claim it never checked. -- **Later: chained exchange, together with C.** Nesting comes from a second exchange at delegation time: - 1. When main delegates `D42`, the broker exchanges at AS-1 with `subject_token=RT-A42` and `actor_token=mainSVID`. - 2. AS-1 returns a delegation token `T-D42`: `sub` = Alice, `act` = main, narrowed to the task. - 3. The reviewer's calls under `D42` use `subject_token=T-D42` and `actor_token=reviewerSVID`. - 4. AS-1 wraps the existing actor: `act {reviewer, act {main}}` ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)). This matches the pattern in draft-mcguinness-oauth-actor-profile, an individual draft. - - It costs a broker operation at delegation time. The broker holds `T-D42` per delegation and renews it from AS-1 server state. The `V` cache key becomes `(A42, D42, reviewer)`. `T-D42` is revoked when the task ends. `T-D42` is also where per-task narrowing, as scope or RFC 9396 `authorization_details`, would live. The McGuinness profile requires `act.iss`, which D4 sets. -- **Long term (C): rescope each token.** AS-1 writes the instance's effective ceiling into the ID-JAG as scope or `authorization_details` (RFC 9396), AS-2 carries it into `V`, and the gateway enforces it as a second enforcement point. The ID-JAG and `V` formats reserve that slot now. -- Until C, the agent ceiling is enforced by the broker alone; the gateway enforces user and connection. The threat model states this. - -### D5: per-agent JWT-SVID stays the actor input - -The agent-instance JWT-SVID stays in scope. The harness presents it on `InvokeTool`, and the broker forwards it to AS-1 as `actor_token`. Reasons: - -- It gives agent identity its own issuer and trust domain, separate from the OAuth issuers. -- It is a place to carry agent metadata, such as ceiling provenance. -- It keeps open a path to RAR-shaped (RFC 9396) policy and to attestation. - -Known limits, recorded so the documents do not overclaim: - -- While agents share one harness process, the SVID asserts the agent rather than authenticating it. The issuer is in the broker, the broker presents the SVID, and AS-1 still checks in the registry that the instance belongs to the session. -- AS-1 can also place ceiling metadata and `authorization_details` in the ID-JAG from its registry. The gateway never sees the SVID. -- Recent SPIFFE work does not change this for in-process agents. The [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340) (SPIRE 1.15.2, July 2026, experimental) lets a trusted broker obtain SVIDs for a referenced PID or Kubernetes object. With a `KubernetesObjectReference`, per-agent SVIDs would be attested if each agent instance were its own Kubernetes object, such as a CRD or sandbox pod, created by a controller the harness cannot impersonate. - -Possible strengthenings, not selected for now: - -- **Session claim.** The SVID names the session it was issued for. AS-1 can then check that the SVID and the subject token belong to the same session, without relying on the broker's pairing. It is still issued on the harness's word. -- **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession. A stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. - -Out of scope for this version: attesting per-agent identity, for example per-agent Kubernetes objects attested through the SPIFFE Broker API. It requires agents to run in their own pod, sandbox, or object. - -Reviewed and not selected: an `InvokeTool` actor field accepting either an instance reference or an SVID. - -### D6: the broker does not check provider connections - -The broker authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user through the mapping and, in external mode, the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions. - -The original design required the broker to hold its own authorized-connection evidence before dispatch. Nothing supplies that evidence. In external mode the mapping and connection records belong to the gateway operator, so every call would fail closed. The broker could only copy the gateway's data, which is not an independent check. Each party therefore decides what it knows first-hand. - -Future broker Cedar policies stay request-shaped: user and identity-provider attributes from AS-1, agent instance and definition provenance, server and tool, resources named in the arguments, and session and delegation context. If vMCP reports the connection it used, the broker records it as observed. A pre-run connection status query, if added later, is advisory and used for UX only, never as an allow or deny input. - -### D7: scheduled runs are pre-authorized - -- **Authority.** A schedule has its own delegated authority from the user: created at schedule setup, bound to the schedule rather than a session, with an explicit horizon, and revoked when the schedule is deleted, on sign out everywhere, or by a failed upstream re-check (D8). Session-bound authority cannot serve schedules, because it is revoked when the session closes. -- **Pre-authorization.** At setup, every connector the schedule may use is connected: the gateway link in external mode, and the provider connection in every mode. Gaps are fixed interactively then. -- **At run time,** nothing is interactive. A lapsed link, credential, or authority fails the call. The run ends with "needs owner action", and the owner is notified through a link into Mecatl, never a raw connect URL. -- **Renewal** extends the horizon and reconnects lapsed connectors without recreating the schedule. The owner is notified ahead of the horizon. - -### D8: sign-out and revocation - -- **Two user actions.** "Sign out" ends only that client's own tokens; agents and schedules keep running. "Sign out everywhere" stops every agent acting for the user: live runs, tool calls parked for authorization, broker sessions and their refresh tokens, and the authority of every schedule. -- **Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority, independent of any client's sign-out. When issuing an ID-JAG, it refreshes that upstream token at most once per re-check interval. A refusal revokes all the user's grants. This catches account disable and administrator revocation at any IdP that issues refresh tokens, without IdP events. -- **Later:** logout and account events from the company IdP (OIDC Back-Channel Logout, Okta event hooks), binding broker refresh tokens to the AS-1 login session, and Shared Signals. - -## User-visible flow - -- **Sign in** once per login session, through AS-1 to the company IdP; usually silent with single sign-on. -- **Connect a provider** once, through the existing `mcp_authorization_pending` path. In external mode the same path first shows the gateway's "Allow Mecatl" step. -- **Everyday use and scheduled runs** need nothing from the user until the broker refresh token's maximum lifetime or a revocation. -- **Sign out** ends one client's tokens. **Sign out everywhere** stops every agent and schedule acting for the user. For external gateways, the consent link can also be revoked in the gateway UI. - -## ToolHive gaps - -What each decision asks of ToolHive, and whether it exists today. Current behavior and sizes are in the [ToolHive work list](implementation.md#toolhive-work-list). - -| Needed for | Capability | Today | -| --- | --- | --- | -| D3 | Exchange of a self-issued access token presented by another confidential client | Mostly | -| D3 | Refresh token in a token-exchange response | No | -| D3, later | Refresh token bound to the login session | No | -| D2 | Issue ID-JAGs for another AS | No | -| D2 | Refresh token as `subject_token` | No | -| D4 | Single-level `act` from the actor input; nesting later with the chained exchange | Partly | -| D4 | ID-JAG redemption keeps `act` | Yes | -| D2, D1 | Native user and stored credentials after ID-JAG redemption | No | -| D2 | Consent link at AS-2's own login (external mode) | No | -| D1 | Per-user credential lookup in open-source ToolHive | No | -| D3 | `private_key_jwt` for the broker | Yes | -| D8 | Revoke a broker refresh token and all of a user's grants | No | -| D8 | Upstream re-check when issuing an ID-JAG | No | -| Later | DPoP issuance and validation | No | -| D4 (C) | Scope or `authorization_details` narrowing enforced per tool, including on `act` | Partly | - -## Open - -- Whether to adopt either SVID strengthening listed in D5, the session claim or key binding. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 472840ff80..7008193683 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -1,11 +1,18 @@ # Credentials and lifecycle -*This chapter describes the intended architecture, not current implementation status. Its decisions are recorded in the [credential flow rework](credential-flow-rework.md).* +*This chapter describes the intended architecture, not current implementation status.* An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. The broker checks the session and execution context. Once the [authority work](README.md#order-of-work) is in place, it also checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar. User, resource, argument, and dynamic delegation policies are deferred. Renewing the instance credential does not itself extend or widen permission to call tools. The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that sign-in itself, so every later statement about the human traces back to a login AS-1 witnessed, not to a report from the harness. +## What the user sees + +- **Sign in** once per login session, through AS-1 to the company IdP; usually silent with single sign-on. +- **Connect a provider** once, through the existing `mcp_authorization_pending` path. In external mode, the same path first shows the gateway's "Allow Mecatl" step. +- **Everyday use** needs nothing more until the broker refresh token's hard expiry or a revocation. Scheduled runs draw on their own [authority](#scheduled-runs). +- **Sign out** ends one client's tokens; **sign out everywhere** stops every agent and schedule acting for the user. See [sign-out and revocation](#sign-out-and-revocation). + ## Credential roles | Credential | What it establishes | Issuer → holder → verifier | @@ -187,7 +194,7 @@ The broker supplies `A42` when the record is created, so the session binding is The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. -**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, does not rotate, and is an extension to the ID-JAG profile. See the [rework record](credential-flow-rework.md#d3-login-and-session-admission). +**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, does not rotate, and is an extension to the ID-JAG profile. **Evidence:** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, source token `jti`, session `A42`, and the hard expiry. @@ -209,6 +216,13 @@ Before issuance, the issuer checks the workload and reviewer instance against th See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. +The agent-instance JWT-SVID stays the actor input because it gives agent identity its own issuer and trust domain, separate from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. While agents share one harness process, it asserts the agent rather than authenticating it: the issuer is in the broker, the broker presents the SVID, and AS-1 still checks its registry that the instance belongs to the session. Two strengthenings are possible but not selected: + +- **Session claim.** The SVID names the session it was issued for, so AS-1 can check that the SVID and the subject token belong to the same session without relying on the broker's pairing. It is still issued on mecak8s's word. +- **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession, so a stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. + +Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the experimental [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. + ### (5) Broker client authentication The subject tokens the broker presents are bearer tokens: they prove whose authority is used, not who presents them. The broker therefore authenticates as OAuth client `mecatl` on every token-endpoint request, at AS-1 and at AS-2, with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion): @@ -272,7 +286,7 @@ AS-1 resolves `RT-A42` to its record and checks that the session is open, the ha } ``` -- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name; `act.iss` names AS-1, which minted it. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. +- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name; `act.iss` names AS-1, which minted it. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. - AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; the draft forbids an issuer redeeming its own ID-JAG (§9.3). @@ -289,7 +303,7 @@ The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https | Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit; AS-1, AS-2, and the credential store share one trust domain. | | External | Identity chaining across trust domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | -The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). AS-2 never resolves users through `sub_id` (§9.5). +The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). AS-2 never resolves users through `sub_id` (§9.5). AS-2 issues a short-lived access token and no refresh token (§4.4.3): @@ -450,7 +464,7 @@ The broker redeems the ID-JAG as described in [step (8)](#8-as-2-access-token). The ID-JAG `jti`, AS-2 token `(iss, jti)`, and operation ID are separate references. A token can be reused for multiple authorized calls, so its `jti` cannot replace a per-operation reference. Each recipient records only what it verifies or observes. -`act` identifies the current actor only ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)) and has one level. Actor history stays inside Mecatl: delegation in the harness and broker records, creation ancestry in the SPIFFE path. RFC 8693 treats prior actors in a nested `act` as informational only, so leaving them out removes nothing the gateway enforces. A later [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) would let AS-1 nest the delegating instance as a prior actor. To reach the delegation from a gateway record, follow the propagated operation ID (see [step (9)](#9-vmcp-decision-and-upstream-credential)). Until the agent ceiling is also carried as scope or `authorization_details`, the gateway enforces user and connection, and the broker alone enforces the agent ceiling. +`act` identifies the current actor only ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)) and has one level. Actor history stays inside Mecatl: delegation in the harness and broker records, creation ancestry in the SPIFFE path. RFC 8693 treats prior actors in a nested `act` as informational only, so leaving them out removes nothing the gateway enforces. A later [chained exchange](mcp-authorization.md#chained-exchange) would let AS-1 nest the delegating instance as a prior actor. To reach the delegation from a gateway record, follow the propagated operation ID (see [step (9)](#9-vmcp-decision-and-upstream-credential)). Until the agent ceiling is also carried as scope or `authorization_details`, the gateway enforces user and connection, and the broker alone enforces the agent ceiling. ## Provider-connection selection diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 17b99c51e2..03870dc321 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -1,6 +1,6 @@ # Implementation and verification -This companion records implementation and verification requirements for the [agent identity and authority design](README.md). Requirements here are not claims about current implementation status or known vulnerabilities. The credential flow follows [credentials and lifecycle](credentials-and-lifecycle.md); its decisions are recorded in the [credential flow rework](credential-flow-rework.md). +This companion records implementation and verification requirements for the [agent identity and authority design](README.md). Requirements here are not claims about current implementation status or known vulnerabilities. The credential flow follows [credentials and lifecycle](credentials-and-lifecycle.md). ## Session ownership and isolation @@ -82,7 +82,7 @@ audience=https://gateway.example.com client_id=mecatl + client assertion ``` -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). **AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then maps the AS-1 subject to a native user: @@ -174,7 +174,7 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | | Cedar at the gateway reading `act` | No | Part of the narrowing work | Later | -The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is under [external consent link](#external-consent-link); its requirement is in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. +The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is under [external consent link](#external-consent-link); its requirement is in [MCP gateway modes](mcp-gateway-modes.md#3-jwt-bearer-grant-at-as-2). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. Until the narrowing work lands, the broker alone enforces the agent ceiling; the gateway enforces user and connection. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index d320891f2b..393151ecfb 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -32,6 +32,19 @@ The intersection narrows a child's definition but trusts the registered parent l The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `agent:` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). +### Chained exchange + +Nesting the delegating instance in `act` is later work. RFC 8693 §4.1 says prior actors in a nested `act` are informational only and must not drive access decisions, and the gateway uses only the current actor, so leaving them out loses no enforcement. Building the inner level from the registered parent would record creation, not delegation. Building it from a delegation that mecak8s reports would make AS-1 sign a claim it never checked. + +The protocol-clean way to nest is a second exchange at delegation time: + +1. When main delegates `D42`, the broker exchanges at AS-1 with `subject_token=RT-A42` and `actor_token=mainSVID`. +2. AS-1 returns a delegation token `T-D42` naming Alice as `sub` and main as `act`, narrowed to the task. +3. The reviewer's calls under `D42` use `subject_token=T-D42` and `actor_token=reviewerSVID`. +4. AS-1 wraps the existing actor: `act {reviewer, act {main}}` ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)). This matches the pattern in draft-mcguinness-oauth-actor-profile, an individual Internet-Draft, which also requires `act.iss`. + +It costs a broker operation at each delegation. The broker holds `T-D42` for the delegation and renews it from AS-1's records, the `V` cache key becomes `(A42, D42, reviewer)`, and `T-D42` is revoked when the task ends. `T-D42` is also where per-task narrowing, as scope or RFC 9396 `authorization_details`, would live. Against a compromised mecak8s, which holds `mainSVID`, this proves nothing more about delegation; it records it in the token. + ## Operations and resources The ceiling check distinguishes registered MCP servers and tools, but does not evaluate resource or argument policies. Permission to call a tool is not evidence of authorization for a particular repository or PR. User, resource, argument, and dynamic delegation policies are deferred, and the design does not yet provide comprehensive enterprise user-policy enforcement. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index bc3b153e95..680e2df888 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -1,6 +1,6 @@ # MCP gateway modes -*This chapter describes the intended architecture, not current implementation status. Its decisions are recorded in the [credential flow rework](credential-flow-rework.md).* +*This chapter describes the intended architecture, not current implementation status.* Mecatl can reach protected MCP tools through a vMCP gateway in two ways. In **embedded mode**, the vMCP and its authorization server run inside the Mecatl deployment. In **external mode**, Mecatl points at an external gateway. Both modes use the same protocol, roles, and token formats; they differ in how AS-2 resolves and authorizes the user. Co-location does not create an isolation boundary. @@ -18,6 +18,8 @@ This chapter covers the protocol between AS-1 and AS-2 and how the two modes dif | Trust in AS-1 | Local configuration; AS-1 is the SSO IdP AS-2 trusts | Gateway administrator registers AS-1 as an assertion issuer only | | User consent | Implicit | One-time consent link at AS-2's own login | +The design works in two deployments: standalone Mecatl, with no Stacklok Enterprise Platform, Directory, or connector gateway, and enterprise deployments that integrate with them. Three roles are therefore pluggable: the **identity resolver**, which maps an AS-1 subject to a native user; the **consent store**; and the **credential store**. Standalone Mecatl provides local implementations, and enterprise deployments back them with Directory and the connector gateway. Nothing in the flow presumes Directory. + ## Two authorization servers **AS-1** is Mecatl's login authorization server, running inside the broker. Alice signs in through AS-1, which redirects her to the company IdP as an ordinary OIDC client. Her client receives an AS-1 access token for the Mecatl API. At `OpenSession`, mecak8s passes that token to the broker once. The broker exchanges it at AS-1 with RFC 8693 for a session-bound broker refresh token, `RT-A42`. The [session-open step](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token) describes that exchange and the AS-1 record behind `RT-A42`. @@ -127,7 +129,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. -`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted the `agent:` identifier, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](credential-flow-rework.md#d4-delegation-visibility-at-the-gateway) at delegation time. +`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted the `agent:` identifier, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. `aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). @@ -155,7 +157,7 @@ AS-2 then resolves the native user. This is where the two modes differ: | Embedded | ID-JAG as specified. Users log in to AS-2 through AS-1, so AS-1 is the SSO IdP AS-2 trusts (§4.1) and the subject is the shared AS-1 `sub` (§5). `u-alice-91` maps directly to native user `usr-alice-7c1d`. Consent is implicit: AS-1, AS-2, and the credential store share one trust domain. | | External | Identity chaining across trust domains ([chaining §§2.1, 2.5](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html#section-2.1)) using the ID-JAG token profile. AS-2's users log in through the gateway's own IdP, so the §4.1 precondition, that the resource AS already trusts the IdP for SSO, does not hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer. It redeems only when the ID-JAG's `(iss, sub)` and `client_id` match a consent link, which supplies the native user (chaining §2.2, deployment-specific resolution). No just-in-time provisioning. | -The external consent link records (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, grant time, and scope or providers). Alice creates it once per gateway, through AS-2's own login, by approving "Allow Mecatl" on a consent screen AS-2 generates. It is keyed on the AS-1 `sub` in the ID-JAG, never on a `sub_id` (§9.5). It stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Admin pre-consent without an independent witness of the user is rejected for the same reason. The link's creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [rework record](credential-flow-rework.md#d2-grant-redeemed-at-as-2). The gateway UI can revoke the link. +The external consent link records (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, grant time, and scope or providers). Alice creates it once per gateway, through AS-2's own login, by approving "Allow Mecatl" on a consent screen AS-2 generates. It is keyed on the AS-1 `sub` in the ID-JAG, never on a `sub_id` (§9.5). It stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Admin pre-consent without an independent witness of the user is rejected for the same reason. The link's creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). The gateway UI can revoke the link. An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked user as a side effect of lookup. Resolving the user does not establish that a provider connection exists; the vMCP's credential lookup remains a separate check. From ca5d9c189be9f084e59d563f701cce4df32ce0b1 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 22:07:14 +0200 Subject: [PATCH 29/50] docs: quote ID-JAG section 9.3 correctly Section 9.3 scopes the profile to parties in different trust domains; it does not just require separate issuers and keys. External mode fits that scope. Embedded mode shares one trust domain, reuses the ID-JAG format for one protocol path, still meets the requirement that AS-1 never issues an access token from its own ID-JAG, and bounds scope broadening because AS-2 issues V only for the vMCP resource. Co-Authored-By: mecatl --- docs/drafts/agent-identity/credentials-and-lifecycle.md | 2 +- docs/drafts/agent-identity/implementation.md | 2 +- docs/drafts/agent-identity/mcp-gateway-modes.md | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 7008193683..49c18ca45e 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -288,7 +288,7 @@ AS-1 resolves `RT-A42` to its record and checks that the session is open, the ha - `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name; `act.iss` names AS-1, which minted it. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. -- AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; the draft forbids an issuer redeeming its own ID-JAG (§9.3). +- AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different trust domains; external mode fits it, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both drafts are Internet-Drafts, not RFCs. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 03870dc321..32277e84d9 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -82,7 +82,7 @@ audience=https://gateway.example.com client_id=mecatl + client assertion ``` -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode (§9.3). +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. **AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then maps the AS-1 subject to a native user: diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 680e2df888..f092f73148 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -35,7 +35,7 @@ The broker authenticates at both servers as one OAuth client, `mecatl`, with `pr AS-2 receives neither Alice's AS-1 access token, `RT-A42`, nor the SVID. It receives only the ID-JAG. -AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode: ID-JAG §9.3 forbids an issuer from redeeming an ID-JAG it issued. The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across trust domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. +AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different trust domains, and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one trust domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across trust domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. ## Following one call @@ -211,7 +211,9 @@ Each agent instance has its own vMCP session (`Mcp-Session-Id`). The broker crea Embedded mode runs AS-1, AS-2, and the vMCP in one deployment. It uses the same ID-JAG exchange at AS-1 and RFC 7523 redemption at AS-2, with the same token formats, validation, and client authentication as external mode. Only AS-2's subject resolution differs: AS-1 is AS-2's SSO IdP, and consent is implicit. Implementations can co-locate protocol handlers or reuse them as libraries; the design does not require a separate network service or a private issuance interface. The two authorization servers keep separate issuer URLs, signing keys, and storage namespaces. -This keeps one protocol path for both modes. It does not add isolation: anyone who compromises the process holds both keys. Distinct issuers, audiences, and `typ` values keep the ID-JAG from being used as an AS-2 access token. +This keeps one protocol path for both modes. It does not add isolation: anyone who compromises the process holds both keys. + +Embedded mode is outside the cross-domain scope that ID-JAG §9.3 states, because all three parties share one trust domain. It reuses the ID-JAG format so both modes share one protocol, not to claim a trust boundary. The section's requirement still holds: AS-1 never issues an access token from its own ID-JAG, because AS-2 issues `V`. The scope broadening §9.3 guards against is bounded because AS-2 issues `V` only for the vMCP resource and issues no refresh token. Distinct issuers, audiences, and `typ` values keep the ID-JAG from being used as an AS-2 access token. ## Outside this chapter From 467060bde71437a5890bd1acbd07bf5bf92e56e1 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 22:20:59 +0200 Subject: [PATCH 30/50] docs: check that the token's user owns the session at open Only mecak8s checked that the user owns the session it opens, so a bug could pair one user's token with another user's session. AS-1 now converts the token only if its sub_id equals the owner mecak8s sends, records the owner at the first open, and refuses later opens for a different user; session IDs are never reused. These checks catch mistakes, not a compromised mecak8s. Record session-bound tokens (RFC 9396 authorization_details obtained by the client at session creation) as later work, with the client token handling in the TUI and Studio to check first, and add the ToolHive ledger rows. The ID-JAG and everything downstream are unchanged. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 2 +- docs/drafts/agent-identity/audit.md | 2 +- .../agent-identity/credentials-and-lifecycle.md | 11 ++++++++++- docs/drafts/agent-identity/implementation.md | 14 +++++++++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index d87593f645..524cf086f9 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -103,7 +103,7 @@ Where the broker runs matters against one attacker: someone with code execution | Read users' provider tokens in embedded mode | Open, if the gateway store shares storage credentials with mecak8s. | Closed, if the embedded gateway is its own deployment with its own credentials. | | Tamper with broker and AS-1 audit records | Partly closed. Records already delivered to an external append-only store survive; records from the compromised process can be forged or suppressed from then on. | Closed for broker and AS-1 records, which mecak8s no longer writes, if they go to an append-only store that mecak8s cannot write. | -In both deployments, the broker takes these facts on mecak8s's word: which agent instance made a call, which loaded definition a session uses as its root, which instance created which child, the run and delegation context, and which open session a call belongs to. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark the facts mecak8s reported. +In both deployments, the broker takes these facts on mecak8s's word: which agent instance made a call, which loaded definition a session uses as its root, which instance created which child, the run and delegation context, and which open session a call belongs to, which the broker checks against the user's token and the session's recorded owner to catch mistakes. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark the facts mecak8s reported. A separate broker service delivers its protections only if the split is real: diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index eb4464bc30..60bcf4df9f 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -61,7 +61,7 @@ The identity work needs these event families: - API requests and session ownership checks at mecak8s; - definition load, instance registration, and agent-instance credential issuance; - each delegation from one run to another; -- broker session open and close, and AS-1's conversion record at session open; per-call events point to the session-open record for the user; +- broker session open and close, AS-1's conversion record at session open, and any open refused because the token's user does not own the session; per-call events point to the session-open record for the user; - sign-out everywhere, with each run stopped, broker session deleted, refresh token revoked, and schedule authority revoked; - an upstream re-check failure and the revocation that follows; - ID-JAG issuance and denial at AS-1; diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 49c18ca45e..3d841f3a7e 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -190,7 +190,16 @@ The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authent AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, and to session `A42`, with a hard expiry that rotation does not extend. Every ID-JAG issued from it names that subject. -The broker supplies `A42` when the record is created, so the session binding is broker-asserted. It contains bugs such as cross-session confusion and stale sessions; it does not protect against a compromised broker. The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, and the hard expiry end it, and AS-1's upstream re-check ends it if the company IdP disables her account; see [sign-out and revocation](#sign-out-and-revocation). +The broker supplies `A42` when the record is created, so the session binding is broker-asserted. Only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. Two checks catch that kind of bug: + +- **Owner match.** With the token, mecak8s sends the owner it holds for `hostS42`. AS-1 converts the token only if the company identity in its `sub_id` equals that owner. +- **Owner fixed at first open.** AS-1 records the owner when `A42` is first opened and refuses any later open of `A42` for a different user. Session IDs are never reused, so a stale pairing cannot reach a new session. + +A mismatch refuses the open and is recorded. Both checks compare facts inside Mecatl, so they catch mistakes, not a compromised mecak8s, which could report a consistent lie. + +**Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with RFC 9396 `authorization_details` naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs RFC 9396 support at AS-1, a session-bound token per session in the TUI and Studio, and a two-step session open. Neither check changes the ID-JAG or anything downstream of `RT-A42`. + +The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, and the hard expiry end it, and AS-1's upstream re-check ends it if the company IdP disables her account; see [sign-out and revocation](#sign-out-and-revocation). The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 32277e84d9..886134275c 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -32,7 +32,9 @@ The flow has four integration points: AS-1 sign-in, the session-open exchange, t - AS-1 issued it, its `aud` is the Mecatl API, it is still active, and its `jti` has not been converted before; - the presenter is the authenticated client `mecatl`; -- the request asks for no wider scope than the original grant. +- the request asks for no wider scope than the original grant; +- the company identity in the token's `sub_id` equals the session owner that mecak8s sends with it; +- the broker session has no recorded owner yet, or its recorded owner is that same identity. AS-1 records the owner at the first open, and session IDs are never reused. Client `mecatl` may convert tokens issued to the TUI and Studio only. A session-open request, using the [main flow](credentials-and-lifecycle.md) values: @@ -166,9 +168,11 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | Revoke all of a user's grants, including schedule authority | No; no index from user to refresh-token families | Medium | Identity work | | Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | | AS-1 and the vMCP record the operation and attempt IDs the broker passes with each exchange and MCP request, marked as reported | No | Small | Identity work | +| Session-open owner check: compare the session owner the broker passes with the token's `sub_id`, and keep the owner on the refresh-token record | No | Small | Identity work | | Reject a token whose upstream session records were deleted | No; the lookup returns an empty credential map instead of failing | Small | Identity work | | Refresh token bound to the AS-1 login session (`sid`), so that signing out at AS-1 revokes it | No; `tsid` is per authorization request, not per login | Large | Later | | Receive logout and account events from the company IdP (OIDC Back-Channel Logout, Okta event hooks, Shared Signals) | No | Large | Later | +| RFC 9396 `authorization_details` on the refresh grant, embedded in the access token, and a session-bound check at conversion | Unknown | Not sized | Later | | End vMCP sessions when the user's grants are revoked | No; sessions bind issuer and subject with no revocation check | Not sized | Later | | DPoP issuance and validation for `V` | No | Large | Later | | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | @@ -263,6 +267,14 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - Each reconciliation check flags an injected mismatch: an AS-2 redemption without AS-1 issuance, a vMCP call without broker dispatch, a dispatch without a vMCP call, and a session-open conversion without AS-1 issuance. - The broker's lineage check rejects a delegation whose instances are in different session trees or whose parent delegation's receiving run differs. +## Session binding checks + +- AS-1 refuses a session-open conversion when the token's `sub_id` differs from the owner mecak8s sends, and records the refusal. +- After `A42` is opened for Alice, AS-1 refuses to open `A42` again with Bob's token, including after a broker restart. +- A session ID is never reused for a new session. + +**Later work for session-bound tokens.** Before adopting [session-bound tokens](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token), check how each client would hold them. The TUI keeps tokens in the OS keyring and Studio in sealed server-side cookies; both would need one session-bound token per open session, refreshed silently. Studio's server holds those tokens for the browser, so check how it stores and selects them per session and whether that changes its cookie and CSRF handling. + ## Sign-out and revocation checks Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation): From 27a5065f5569748a96ae420cdf847ad4052f2a77 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 22:26:44 +0200 Subject: [PATCH 31/50] docs: describe how the authority work contains a compromised mecak8s Ceilings set by the definition loader stop mecak8s from supplying fake definitions, but mecak8s still picks which definition is a session's root. The operator's loader configuration therefore names the root-eligible definitions, optionally per user or group, and caps the ceiling of definitions from other sources, so no reported parent or agent can exceed the widest root-eligible ceiling. Add run grants as authority work: a grant per run, minted from a fresh user access token, so sessions of absent users carry no authority. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 5 ++++- docs/drafts/agent-identity/agent-definitions.md | 2 +- docs/drafts/agent-identity/credentials-and-lifecycle.md | 2 ++ docs/drafts/agent-identity/mcp-authorization.md | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 524cf086f9..ed18579c7b 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -105,6 +105,8 @@ Where the broker runs matters against one attacker: someone with code execution In both deployments, the broker takes these facts on mecak8s's word: which agent instance made a call, which loaded definition a session uses as its root, which instance created which child, the run and delegation context, and which open session a call belongs to, which the broker checks against the user's token and the session's recorded owner to catch mistakes. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark the facts mecak8s reported. +The authority work limits what those reports can achieve. With ceilings set by the loader and the operator's list of root-eligible definitions, a compromised mecak8s cannot exceed the widest definition a session may use as its root, whichever agent it claims is acting. With run grants, it can act only during runs a user started. + A separate broker service delivers its protections only if the split is real: - the broker has its own database, KMS permissions, and namespace; @@ -141,9 +143,10 @@ The **identity work** establishes who acts and records it: The **authority work** decides what each agent may do: -- resolving each definition's ceiling into permitted MCP server/tool pairs; +- the definition loader resolving each definition's ceiling into permitted MCP server/tool pairs, and the operator's loader configuration naming which definitions may be a session's root and capping the ceiling of definitions from other sources; - each instance's effective ceiling, its definition ceiling narrowed by its parent's; - Cedar policy generation and a ceiling check on every call; +- grants scoped to a run the user started, replacing the session's standing refresh token, so that sessions of absent users carry no authority; - task-specific delegation bounds, and user, resource, and argument policy. Until the authority work is in place, the harness's existing delegation restrictions are the only ceiling checks; the broker adds none. Registration already records the definition reference and creation parent, so the authority work adds computation, not a migration. The chapters describe the whole design and say which parts belong to the authority work. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index ab95da9889..72cc0d76fe 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -86,7 +86,7 @@ The broker ceiling is the set of permitted registered MCP server/tool pairs reso ## Loading definitions -The **definition loader** is a broker module. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. Once the [authority work](README.md#order-of-work) is in place, it also resolves and records each definition's ceiling. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. +The **definition loader** is a broker module. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. Once the [authority work](README.md#order-of-work) is in place, it also resolves and records each definition's ceiling. The operator's loader configuration then also says which loaded definitions may serve as a session's root, optionally per user or group, and caps the ceiling of definitions from other sources; the broker refuses to register a root outside that set. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. Each session uses a fixed selection of loaded definitions. mecak8s's trusted session lifecycle code persists that selection and names definitions to the broker by reference; it does not supply their content or ceilings. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 3d841f3a7e..e7f98510e3 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -199,6 +199,8 @@ A mismatch refuses the open and is recorded. Both checks compare facts inside Me **Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with RFC 9396 `authorization_details` naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs RFC 9396 support at AS-1, a session-bound token per session in the TUI and Studio, and a two-step session open. Neither check changes the ID-JAG or anything downstream of `RT-A42`. +**Later: run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still act during a run the user started; only a key held by the client and signing each run's start would prevent that. + The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, and the hard expiry end it, and AS-1's upstream re-check ends it if the company IdP disables her account; see [sign-out and revocation](#sign-out-and-revocation). The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 393151ecfb..518942b412 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -28,7 +28,7 @@ Delegation history records how the acting instance came to act. An ancestor's pe For example, a read-only reviewer spawns a child from a `deployer` definition whose ceiling includes `pull_request_merge`. The child's effective ceiling is the deployer ceiling intersected with the reviewer's, so it excludes merge. The broker denies the child's merge call whatever the deployer definition allows and whatever another `deployer` instance may do. The harness performs the same intersection today; the broker computes it independently. Existing harness delegation restrictions remain in force. -The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. +The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Root eligibility bounds that inflation: every parent chain ends at a root the operator allows, so no child can exceed that root's ceiling, which the [definition loader](agent-definitions.md#loading-definitions) set. Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `agent:` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). From 4790ce81b6f1baf0831fd7c7e7994188bfa8b7c0 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 22:48:15 +0200 Subject: [PATCH 32/50] docs: record the run owner and keep shared sessions possible Add a Run owner chapter implementing the aligned decision to report the run owner rather than the session owner. mecak8s sends the run ID and run owner with every call; the broker checks it against the subject of the grant in use and never lets it select credentials. Grants, caches, and vMCP session IDs are keyed by user, revocation is by user, child runs keep their parent's owner, and audit records the run owner. Shared sessions can then be added without migrating data or changing token formats, and should arrive together with run grants. List the product questions they raise. Replace the sentence that rejected run-owner attribution. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 2 + docs/drafts/agent-identity/audit.md | 4 +- .../credentials-and-lifecycle.md | 14 +++---- docs/drafts/agent-identity/implementation.md | 9 +++- .../agent-identity/mcp-authorization.md | 4 +- .../agent-identity/mcp-gateway-modes.md | 10 ++--- docs/drafts/agent-identity/run-owner.md | 42 +++++++++++++++++++ 7 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 docs/drafts/agent-identity/run-owner.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index ed18579c7b..9ef7f60899 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -138,6 +138,7 @@ The **identity work** establishes who acts and records it: - agent-instance credentials; - the credential flow: sign-in at AS-1, the session-bound refresh token, ID-JAG issuance, AS-2 redemption, and per-user provider credentials at the gateway; - the broker's identity checks: session, workload, and that the acting instance is registered to the broker session; +- the run owner on every call, checked against the grant in use; - sign-out and revocation: signing out everywhere stops every agent and schedule acting for the user, and AS-1's upstream re-check does the same when the company IdP disables the account; - audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. @@ -162,6 +163,7 @@ The proposed chapters develop each part of the architecture. | [MCP authorization](mcp-authorization.md) | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | | [MCP gateway modes](mcp-gateway-modes.md) | Chain the broker's grant issuer to the vMCP gateway's authorization server, so one protocol works with an embedded or an external gateway. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | +| [Run owner](run-owner.md) | Record who started each run, check it against the grant in use, and keep shared sessions possible later. | | [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | The [implementation and verification companion](implementation.md) records integration requirements and checks. This broker-centered architecture supersedes the earlier identity model's harness-owned signing and audit responsibilities; the donor documents are retained in Git history. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 60bcf4df9f..1a804ca30a 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -35,7 +35,7 @@ Every component records evidence as a [toolhive-core](https://github.com/stacklo | Which event | `Metadata.AuditID` | Minted once and stored before the first send. A re-send reuses it, and ingestion removes duplicates by it. | | What kind of event | `Type` | A `mecatl_`-prefixed snake_case type, such as `mecatl_tool_decision`. The vMCP keeps ToolHive's own types, such as `mcp_tool_call`. Whether an event is a decision, a dispatch, or an observed outcome is part of its type. | | Result | `Outcome` | toolhive-core's `success`, `failure`, `error`, and `denied`, plus `unknown` once #341 defines it. Each type uses a fixed subset; see the next table. | -| Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `agent:` identifier), and `agent_instance_ref`. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | +| Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `agent:` identifier), and `agent_instance_ref`. `run_owner_iss` and `run_owner_id`: the [run owner](run-owner.md), reported by mecak8s. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | | Verified or reported | `Metadata.Extra["mecatl.provenance"]` | A map keyed by the `Subjects` key: `verified`, `credential-verified` when the recorder verified a credential but mecak8s reported which instance it belongs to, `record:` when taken from a retained record, `reported:`, or `unavailable`. A missing fact is marked `unavailable`, never left out silently. | | Where it originated | `Source` | The network origin, as in ToolHive: the peer address, plus `request_id` and `session_id` for joining. The calling workload's identity belongs in `Subjects`, not here. | | Where it was recorded | `Component`, `Metadata.Extra` | `mecatl-broker`, `mecatl-as1`, or the ToolHive component name, plus the recording replica and trust domain. | @@ -86,7 +86,7 @@ These records have limits: Two event types record how instances relate. Both are marked as reported by mecak8s, because only mecak8s observes these relationships. - **Registration** (`mecatl_instance_registered`) records the instance, its definition, its session tree, and the `AuditID` of its creation parent's registration. -- **Delegation** (`mecatl_delegation`) records the delegation, such as `D42`; the delegating run and instance; the receiving run and instance; and, when the delegating run was itself delegated, the `AuditID` of that parent delegation. +- **Delegation** (`mecatl_delegation`) records the delegation, such as `D42`; the delegating run and instance; the receiving run, its owner, and its instance; and, when the delegating run was itself delegated, the `AuditID` of that parent delegation. Lineage events are audit records. They are kept for the audit retention period, and session cleanup does not delete them. References between them are `AuditID`s in the append-only audit store, not live session state. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index e7f98510e3..fe3d0533c2 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -24,7 +24,7 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that | **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl` and to the session. | | **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | | **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | -| **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. Cached per session, instance, and gateway. | +| **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. Cached per session, instance, gateway, and user. | | **Upstream provider credential** | Access to Alice's GitHub account. | GitHub → gateway credential store → GitHub. Never leaves the gateway. | | **Signing keys** | Authority to issue credentials. | AS-1, AS-2, and the agent-instance issuer each keep their own key. Separate keys are not process isolation when components share a process. | @@ -96,7 +96,7 @@ sequenceDiagram Note over H,G: (6) Main call: pull_request_list H->>B: InvokeTool: workload JWT, A42, op-list-prs-001, agentJwtSvid = mainSVID B->>B: Check session, workload, actor, effective ceiling, route - opt No cached V for (A42, main, gateway) + opt No cached V for (A42, main, gateway, Alice) B->>A1: (7) RFC 8693: subject_token = RT-A42
actor_token = mainSVID
requested_token_type = id-jag, audience = AS-2 A1->>A1: Session open, hard expiry not passed
main registered to A42, build act A1-->>B: J-main @@ -255,7 +255,7 @@ One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_i ### (6) Per-operation authorization -Local permissions and hooks run before each call. For the reviewer's call, trusted harness client code sends `InvokeTool` with workload authorization metadata, `A42`, `call.id=op-pr42-001`, and `agentJwtSvid=reviewerSVID`. The model supplies only the tool content: +Local permissions and hooks run before each call. For the reviewer's call, trusted harness client code sends `InvokeTool` with workload authorization metadata, `A42`, `call.id=op-pr42-001`, run `R42` with its [run owner](run-owner.md) Alice, and `agentJwtSvid=reviewerSVID`. The model supplies only the tool content: ```json { @@ -332,7 +332,7 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): } ``` -The broker caches `V-reviewer` for `(A42, reviewer, gateway)` until it expires, then obtains a new ID-JAG. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, so each agent instance has its own vMCP session. +The broker caches `V-reviewer` for `(A42, reviewer, gateway, Alice)` until it expires, then obtains a new ID-JAG. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, so each agent instance has its own vMCP session. **Evidence:** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. @@ -354,7 +354,7 @@ No mecak8s code path can name a user who did not sign in at AS-1. An attacker wi AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. -The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. This selects neither multiplayer support nor run-owner attribution in place of session ownership. +The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. Each call also carries the [run owner](run-owner.md), which the broker checks against the grant it uses; today it equals the session owner. ## Credential custody @@ -370,7 +370,7 @@ The following custody boundaries do not imply a selected persistence implementat | Broker refresh token `RT-A42` | Broker; presented only to AS-1. | Bound to `mecatl` and `A42`; rotates; hard expiry; revoked when the session closes. | | Broker private key | Broker; signs client assertions for AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | | ID-JAG | Broker, from issuance to redemption. | Single-use, short-lived. | -| AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, and gateway; per-call checks still apply. No refresh token. | +| AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, gateway, and user; per-call checks still apply. No refresh token. | | Consent link (state) | AS-2, external mode. | Until revoked in the gateway UI or by Mecatl. | | Upstream provider credential | Gateway credential store. | Keyed by (gateway, user, provider); provider-specific renewal stays at the gateway. | @@ -409,7 +409,7 @@ Signing out of one client and stopping Mecatl's agents are different actions. | **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval. | Transcripts. | | **Closing a session** | That session's refresh token. | Other sessions and schedules. | -**Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker deletes the broker session, which cancels in-flight tool calls, drops the session's cached ID-JAGs and `V`, and revokes its refresh token at AS-1. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. +**Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker revokes Alice's grants in those sessions at AS-1, cancels their in-flight tool calls, and drops her cached ID-JAGs and `V`; with one owner per session, that ends each broker session. Revocation is by user, so a [shared session](run-owner.md) would keep running for its other members. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. **Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes that upstream token if it has not done so within the re-check interval. If the IdP refuses, because the account is disabled or its grants were revoked, AS-1 revokes every grant it holds for Alice, including schedule authority, and the exchange fails. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 886134275c..9528edc423 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -63,7 +63,7 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. -**Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: +**Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: 1. resolves the refresh token to its record and checks that it is bound to `mecatl`, that the session is open, and that the hard expiry has not passed; 2. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; @@ -275,6 +275,13 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to **Later work for session-bound tokens.** Before adopting [session-bound tokens](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token), check how each client would hold them. The TUI keeps tokens in the OS keyring and Studio in sealed server-side cookies; both would need one session-bound token per open session, refreshed silently. Studio's server holds those tokens for the browser, so check how it stores and selects them per session and whether that changes its cookie and CSRF handling. +## Run owner checks + +- The broker refuses a call whose reported run owner differs from the subject of the grant in use, and records the denial. +- A child run's owner equals its parent run's owner; the broker refuses a delegation that changes it. +- Two users' calls through the same instance never share a cached `V`, ID-JAG, or vMCP session ID. +- Signing out everywhere revokes only that user's grants and cache entries. + ## Sign-out and revocation checks Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation): diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 518942b412..1ba1242953 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -18,7 +18,7 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. mecak8s reports each delegation to the broker when it happens, so the broker can record it as a [lineage event](audit.md#lineage). Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. The [run owner](run-owner.md) is checked against the subject of the grant in use and never selects it. mecak8s reports each delegation to the broker when it happens, so the broker can record it as a [lineage event](audit.md#lineage). Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority @@ -96,6 +96,6 @@ Local permissions and hooks apply before invocation. The broker then authenticat Missing, invalid, stale, or revoked identity, consent, connection, or authorization evidence fails closed, without fallback to a service credential, another user, or a more privileged agent. -When no valid cached `V` exists for the session, instance, and gateway, the broker authenticates to AS-1 as OAuth client `mecatl` with `private_key_jwt` and requests an ID-JAG by RFC 8693 token exchange, with the session's broker refresh token as `subject_token` and the actor SVID as `actor_token`. It redeems the ID-JAG at AS-2 with RFC 7523's JWT-bearer grant, authenticating again as `mecatl`. AS-2 resolves the native user and returns `V` naming that user, the acting agent in `act`, and client `mecatl`. AS-2 issues no refresh token; when `V` expires, the broker repeats the exchange. A cached `V` skips the exchange and redemption, never the broker's checks or vMCP's authorization. AS-2 and vMCP make their own grant, identity, operation, and connection decisions; a downstream allow does not replace the broker's ceiling decision, and a broker allow does not replace theirs. See [credential roles](credentials-and-lifecycle.md#credential-roles) and the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution) for the full sequence. +When no valid cached `V` exists for the session, instance, gateway, and user, the broker authenticates to AS-1 as OAuth client `mecatl` with `private_key_jwt` and requests an ID-JAG by RFC 8693 token exchange, with the session's broker refresh token as `subject_token` and the actor SVID as `actor_token`. It redeems the ID-JAG at AS-2 with RFC 7523's JWT-bearer grant, authenticating again as `mecatl`. AS-2 resolves the native user and returns `V` naming that user, the acting agent in `act`, and client `mecatl`. AS-2 issues no refresh token; when `V` expires, the broker repeats the exchange. A cached `V` skips the exchange and redemption, never the broker's checks or vMCP's authorization. AS-2 and vMCP make their own grant, identity, operation, and connection decisions; a downstream allow does not replace the broker's ceiling decision, and a broker allow does not replace theirs. See [credential roles](credentials-and-lifecycle.md#credential-roles) and the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execution) for the full sequence. An allow does not prove dispatch or provider success. The [audit contract](audit.md#decision-dispatch-and-observed-outcome) owns durable evidence and correlation requirements. An unknown outcome must not be automatically replayed merely because credentials were recovered. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index f092f73148..c284b9001f 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -39,7 +39,7 @@ AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedd ## Following one call -Alice, AS-1 subject `u-alice-91`, owns harness session `hostS42`. Her session is already open: the broker holds `RT-A42` for broker session `A42`. Her reviewer agent instance asks to read PR 42 in `acme/payments`. The broker checks the session, workload, actor, effective ceiling, and route. With no cached `V` for `(A42, reviewer, gateway)`, it exchanges `RT-A42` and the reviewer SVID at AS-1 for an ID-JAG, redeems the ID-JAG at AS-2 for `V`, and calls the vMCP. The gateway in this example is external, at `https://gateway.example.com`. +Alice, AS-1 subject `u-alice-91`, owns harness session `hostS42`. Her session is already open: the broker holds `RT-A42` for broker session `A42`. Her reviewer agent instance asks to read PR 42 in `acme/payments`. The broker checks the session, workload, actor, effective ceiling, and route. With no cached `V` for `(A42, reviewer, gateway, Alice)`, it exchanges `RT-A42` and the reviewer SVID at AS-1 for an ID-JAG, redeems the ID-JAG at AS-2 for `V`, and calls the vMCP. The gateway in this example is external, at `https://gateway.example.com`. ```mermaid sequenceDiagram @@ -52,7 +52,7 @@ sequenceDiagram Note over B: Holds RT-A42 from session open B->>B: Broker checks
Session, workload, actor, effective ceiling and route Note over B,P: Any denial before provider dispatch stops the request - opt No cached V for (A42, reviewer, gateway) + opt No cached V for (A42, reviewer, gateway, Alice) B->>A1: (1) RFC 8693 + private_key_jwt
subject_token = RT-A42, actor_token = reviewer SVID
requested_token_type = id-jag, audience = AS-2 A1->>A1: Resolve RT-A42 record: session open, not expired
Validate SVID, reviewer registered to A42
Build act A1-->>B: (2) ID-JAG (sub u-alice-91, act, client_id mecatl) @@ -177,7 +177,7 @@ An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked } ``` -This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT access token omits its signature. AS-2 issues its own `iss`, puts its native user ID in `sub`, and copies `act` unchanged. Its lifetime does not exceed the ID-JAG's. AS-2 issues no refresh token (ID-JAG §4.4.3); when `V` expires, the broker obtains a new ID-JAG from `RT-A42`. The broker caches `V` per session, instance, and gateway, and runs its checks before every use of a cached token. +This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT access token omits its signature. AS-2 issues its own `iss`, puts its native user ID in `sub`, and copies `act` unchanged. Its lifetime does not exceed the ID-JAG's. AS-2 issues no refresh token (ID-JAG §4.4.3); when `V` expires, the broker obtains a new ID-JAG from `RT-A42`. The broker caches `V` per session, instance, gateway, and user, and runs its checks before every use of a cached token. ### (5) Dispatch @@ -199,12 +199,12 @@ Mecatl's operator configures AS-1 with the `mecatl` client, the accepted agent t ## Sessions at the vMCP -Each agent instance has its own vMCP session (`Mcp-Session-Id`). The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. +Each agent instance has its own vMCP session (`Mcp-Session-Id`) for each user it acts for. The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. - Sessions are never shared between instances. - The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and `act.sub`. A token naming another instance in its `act` cannot use the session. - The run is not part of the session or token. The run ID travels in trace headers. -- The broker stores each instance's session ID. After a restart it reattaches. When the vMCP reports the session gone (idle timeout or eviction), the broker creates a new one. A wedged session can be reset explicitly. +- The broker stores each session ID under the instance, gateway, and user, so a [shared session](run-owner.md) never hands one user's backend state to another. After a restart it reattaches. When the vMCP reports the session gone (idle timeout or eviction), the broker creates a new one. A wedged session can be reset explicitly. - An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. ## Embedded mode diff --git a/docs/drafts/agent-identity/run-owner.md b/docs/drafts/agent-identity/run-owner.md new file mode 100644 index 0000000000..8778f8e94f --- /dev/null +++ b/docs/drafts/agent-identity/run-owner.md @@ -0,0 +1,42 @@ +# Run owner and shared sessions + +*This chapter describes the intended architecture, not current implementation status.* + +Today one person owns a Mecatl session, and every credential the broker uses comes from that owner. Sessions shared by several people are a likely future direction. In a shared session, a tool call must use the credentials of the person who started the run, not the session owner's; otherwise Bob's run could read Alice's files. This chapter records the run owner now, in a way that lets shared sessions be added later without migrating data or changing token formats. It does not design shared sessions. + +## The run owner + +The **run owner** is the person who started a run. mecak8s records it when the run starts, from the company identity (`sub_id`) in the AS-1 token on the request that started the run. A delegated child run inherits its parent run's owner. While a session has one owner, the run owner always equals the session owner. + +The identity work adds the run owner as follows: + +1. **On every call.** mecak8s sends the run ID and the run owner with each `InvokeTool`, beside the session reference, operation ID, and agent credential. Both become part of the call's [execution context](mcp-authorization.md#authentication-and-execution-context). +2. **Checked, never used as a source.** The ID-JAG's `sub` still comes only from the record of the grant the broker uses. The broker checks that the reported run owner equals that grant's subject; today, that is the session owner. A mismatch refuses the call and is recorded as a denial. A run-owner report therefore cannot choose whose credentials are used. The check is a single function, so a later membership check replaces only its body. +3. **Grants keyed by session and user.** The broker and AS-1 store the session's broker refresh token under (session, user), with exactly one entry per session for now. The session's creator is recorded as a separate fact rather than as a uniqueness rule on the grant. +4. **The user in every per-user key.** The broker's cache of `V` and of ID-JAGs, and its store of vMCP session IDs, are keyed by user as well as session, instance, and gateway. The user comes from the grant's record, not from the reported run owner, and revoking a grant drops its entries. The vMCP already binds each session to the user. +5. **Revocation by user.** [Sign out everywhere](credentials-and-lifecycle.md#sign-out-and-revocation) revokes the user's grants and drops their cache entries. Closing a session is a separate action that revokes every grant in it. +6. **Through delegation.** Each [delegation event](audit.md#lineage) records the receiving run's owner, and the broker checks that a child run's owner equals its parent run's owner. +7. **In audit.** `Subjects` carries `run_owner_iss` and `run_owner_id` beside the session owner, marked as reported by mecak8s, together with the run ID. + +A compromised mecak8s gains nothing from a false run owner: while one grant exists per session, the check only refuses calls. + +## Shared sessions later + +Shared sessions build on the run owner with additions: + +| Shared sessions need | The run owner already provides | Shared sessions add or change | +| --- | --- | --- | +| Who started each run | Run ID and run owner on every call and in audit | Nothing | +| The run owner's credentials | Grants keyed by session and user | A grant for each member | +| No cross-user token reuse | Caches and vMCP sessions keyed by user | Nothing | +| Members other than the creator | The creator recorded separately | A member list kept by mecak8s; the owner check becomes a membership check | +| Revoking one member | Revocation by user | Nothing | + +Shared sessions should arrive together with [run grants](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token), not with a standing refresh token per member. With standing tokens, mecak8s's report of who started a run would select that member's token, so a compromised mecak8s could act as any member. A run grant is minted from the access token of the person who started the run, so no report from mecak8s decides whose credentials are used. A run grant could take the shape of a transaction token issued by AS-1 or the broker, whose `txn` and `sub` cannot change; it never reaches AS-2 or the vMCP. + +Shared sessions also raise product questions outside identity: + +- **One initiator per run.** If Alice interjects in Bob's run, that starts a new run or is refused. +- **Approvals.** Only the run owner approves permission prompts for their run. +- **Visibility.** What one member sees of another member's context, such as earlier tool results or documents. +- **Injection between members.** Content one member adds can steer a run another member starts, which then acts with the second member's credentials. From fcbff9a059a448efd8720d2815c39ad28f6f0a8f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 23:00:16 +0200 Subject: [PATCH 33/50] docs: require lossless audit delivery where compliance demands it In deployments that must not lose audit records, every component's sink must acknowledge only after a durable write; standard output read by a log shipper does not qualify. Components block or refuse rather than drop records, so the broker's rule dispatches a tool call only after its decision record is durably stored. Add the matching check. Co-Authored-By: mecatl --- docs/drafts/agent-identity/audit.md | 2 ++ docs/drafts/agent-identity/implementation.md | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 1a804ca30a..510c258c2b 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -122,6 +122,8 @@ Correctly shaped records are not enough if they can be lost, rewritten, or never **Write order and failure.** The broker mints each attempt ID. Before it dispatches a tool call, its decision record must be accepted by the sink; if the broker cannot deliver it, the broker refuses to dispatch. This is the broker's choice, not a library default. A record being accepted by a sink is not the same as durable storage; the deployment's destination provides durability. Delivery failures are reported on a separate channel, as toolhive-core [#343](https://github.com/stacklok/toolhive-core/issues/343) proposes, so a failing destination does not hide its own failure. +**Lossless delivery.** Some deployments, such as those under government compliance regimes, must not lose audit records. There, every component's sink must be one whose acceptance means the record was durably received, for example an HTTP sink pointed at a collector that acknowledges only after a durable write. Standard output read by a log shipper does not qualify, because the shipper can drop records under load or on a crash. When the sink is slow or unavailable, components block or refuse the operation rather than buffer and drop records. The broker's rule above then means that a tool call is dispatched only after its decision record is durably stored. + **Gap detection.** Each component numbers its records with a counter that is contiguous for each component, replica, and process start, and records a start and stop event for each stream. A missing number or stream end shows that records were lost or removed. All components use a synchronized time source, and each record names whose clock it used. **Reconciliation.** Records from different stores are checked against each other, and each mismatch is flagged: diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 9528edc423..c6c18d40ec 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -262,6 +262,7 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - Two instances of the same definition under different parents stay distinct in reconstruction. - A missing or malformed lineage record makes reconstruction report an incomplete chain, not a shorter one. - A delivery failure is reported through the logger, as toolhive-core [#343](https://github.com/stacklok/toolhive-core/issues/343) proposes. +- With a lossless sink configured, an unavailable sink blocks dispatch, and no record is dropped under back-pressure or when the sink restarts. - The broker refuses to dispatch when its decision record cannot be delivered, and dispatches only after the sink accepts it. - Removing a record or ending a stream without its stop event shows as a sequence gap. - Each reconciliation check flags an injected mismatch: an AS-2 redemption without AS-1 issuance, a vMCP call without broker dispatch, a dispatch without a vMCP call, and a session-open conversion without AS-1 issuance. From f340ce5f35de2c918b386c306c6d6f9cebb210a7 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 15 Oct 2026 23:04:14 +0200 Subject: [PATCH 34/50] docs: add the ToolHive change for redeeming any AS-1 subject ToolHive's ID-JAG redemption enforces an exact subject_bindings list per issuer, and configuration validation rejects empty lists and wildcard subjects or resources. AS-2 must redeem for any subject AS-1 names, resolved through the mapping or consent link. Add the ledger row, with a note to make it an explicit mode rather than a looser check, and say so in the gateway administrator's configuration. Co-Authored-By: mecatl --- docs/drafts/agent-identity/implementation.md | 1 + docs/drafts/agent-identity/mcp-gateway-modes.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index c6c18d40ec..3ea904d4b6 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -158,6 +158,7 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | Refresh token as `subject_token` | No | Large | Identity work | | Single-level `act` from the registered actor input, with `act.iss` set to AS-1's issuer (nesting later, with the chained exchange) | Partly; `buildActClaim` builds `act` from a client or self-issued actor token and nests it | Medium | Identity work | | ID-JAG redemption keeps `act`, including `act.iss` | Yes, copied unchanged | None; verify only | Identity work | +| AS-2 redeems an ID-JAG from a trusted AS-1 for any subject, resolved through the mapping or consent link, with allowed resources set per issuer. ToolHive forbids wildcards on purpose, so this should be a separate, explicit mode, such as subjects resolved by an identity resolver, not a looser check | No; each issuer's policy requires an exact `subject_bindings` list, each subject with its own allowed resources, and configuration validation rejects an empty list and wildcard subjects or resources (checked at main `9adad0abb`) | Small | Identity work | | AS-2 maps the AS-1 subject to a native user after ID-JAG redemption, and stops marking the token as having no upstream session | No; emits `iss#sub` and marks no upstream session | Large | Identity work | | Per-user upstream credential lookup in open-source ToolHive, keyed (gateway, native user, provider) | No; keyed by `tsid`, a per-interactive-login session ID that a delegated `V` lacks, and vMCP loads nothing for a token without one (`pkg/auth/token.go:1253`); the enterprise per-user decorator is build-tagged | Medium | Identity work | | Consent link at AS-2's own login (external mode only) | No | Not sized | Identity work | diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index c284b9001f..79ef1949a1 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -189,7 +189,7 @@ AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. T In external mode, the gateway administrator configures AS-2 with: -- AS-1's issuer and signing keys, trusted as an ID-JAG issuer only, not as an SSO IdP; +- AS-1's issuer and signing keys, trusted as an ID-JAG issuer only, not as an SSO IdP, for any subject AS-1 names; the mapping or consent link, not a list of subjects, decides who is redeemed; - the `mecatl` OAuth client, with the broker's `jwks_uri` registered once for `private_key_jwt`; - the consent-link flow and its revocation in the gateway UI; - permitted actors and allowed vMCP resources; From 1c156d2e28b2059c7a3df2c1be02ff3306c80294 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 10:59:42 +0200 Subject: [PATCH 35/50] docs: make clear the definition loader is part of the identity broker The README introduced the loader as its own component before the identity broker, so it read as a different broker. Introduce the identity broker first, as the existing MCP broker extended into one trusted component, and list its modules: the definition loader, the agent-instance issuer, AS-1, and authorization and audit. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 6 +++--- docs/drafts/agent-identity/agent-definitions.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 9ef7f60899..989d457216 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -41,7 +41,7 @@ The existing `AgentDef` structure currently combines instructions, execution set The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. -The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. An **agent-definition loader**, a broker module, loads definitions from configured sources into the broker and supplies the harness with execution configuration. An **identity broker** is a trusted component that governs access to protected tools. It starts as modules inside the mecak8s process and later becomes a separate service; see [deployment and threats](#deployment-and-threats). It issues agent credentials, checks requested operations against policy, and arranges downstream access using credentials it controls. It also records the decisions and actions it observes. +The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. The **identity broker** is the existing MCP broker, extended to govern access to protected tools; it is one trusted component, not several. It starts as modules inside the mecak8s process and later becomes a separate service; see [deployment and threats](#deployment-and-threats). Its modules are the **definition loader**, which loads agent definitions from configured sources and supplies the harness with execution configuration; the **agent-instance issuer**; **AS-1**, Mecatl's login authorization server; and the authorization and audit logic. Together they issue agent credentials, check requested operations against policy, arrange downstream access using credentials the broker controls, and record the decisions and actions they observe. This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. @@ -54,7 +54,7 @@ flowchart LR F["Agent-definition sources"] -->|"Global definitions at startup"| R R -->|"Execution configuration"| H subgraph B["Identity broker"] - R["Agent-definition loader"] + R["Definition loader"] I["Authorization and audit
OAuth client mecatl"] A1["AS-1: login and ID-JAG issuer"] R -->|"Definition records and ceilings"| I @@ -78,7 +78,7 @@ The diagram shows the broker-backed remote architecture's MCP path. Local deploy At startup, the broker's [definition loader](agent-definitions.md#loading-definitions) loads the global agent definitions from configured sources and records their sources, revisions, and ceilings in the broker. How other definitions, such as repository definitions, are loaded is not yet decided. Before the agent loop starts, mecak8s's trusted session lifecycle code persists the session's fixed definition selection and [registers the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication, naming its definition by reference. The registration API, durable schema, verified per-operation binding for reported context, and protection from same-process substitution remain open. -The broker contains the definition loader, the agent trust issuer, authorization and audit logic, and AS-1, Mecatl's login authorization server. The vMCP gateway contains AS-2, provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or as an external gateway. +The vMCP gateway contains AS-2, provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or as an external gateway. Alice signs in through AS-1, which redirects her to the company IdP. Her client then calls mecak8s with an AS-1 access token. mecak8s validates that token and current session ownership. At session open it passes the token to the broker once, and the broker exchanges it at AS-1 for a refresh token bound to that broker session. On each tool call the broker presents that refresh token and the acting agent's JWT-SVID to AS-1, which returns an ID-JAG naming Alice and the agent. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. The broker is a single OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`. AS-2 maps the AS-1 subject to its native user; an external gateway also requires a one-time consent link created at AS-2's own login. AS-2 receives neither Alice's AS-1 token nor the agent SVID. See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. The tools themselves execute the operations. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 72cc0d76fe..e9a054eff1 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -86,7 +86,7 @@ The broker ceiling is the set of permitted registered MCP server/tool pairs reso ## Loading definitions -The **definition loader** is a broker module. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. Once the [authority work](README.md#order-of-work) is in place, it also resolves and records each definition's ceiling. The operator's loader configuration then also says which loaded definitions may serve as a session's root, optionally per user or group, and caps the ceiling of definitions from other sources; the broker refuses to register a root outside that set. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. +The **definition loader** is a module of the identity broker. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. Once the [authority work](README.md#order-of-work) is in place, it also resolves and records each definition's ceiling. The operator's loader configuration then also says which loaded definitions may serve as a session's root, optionally per user or group, and caps the ceiling of definitions from other sources; the broker refuses to register a root outside that set. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. Each session uses a fixed selection of loaded definitions. mecak8s's trusted session lifecycle code persists that selection and names definitions to the broker by reference; it does not supply their content or ceilings. From b8f0e7bff326db0cf1ed8c713862bf512b4b2db7 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 11:36:38 +0200 Subject: [PATCH 36/50] docs: rewrite the agent identity overview for evaluators Rewrite the README for readers evaluating Mecatl rather than implementing it. The opening now states both goals, attributing each protected call and bounding what an agent may do, with the code-review example. Correct claims that overstated current behavior (subagent bounds, audit coverage, the Cedar check, run grants, per-call exchanges), give the reason behind each design choice, and add sections on what the design does not guarantee and on the standards it builds on. Move root eligibility into the identity work: the broker refuses to register a root instance whose definition the operator has not made root-eligible. Ceilings stay in the authority work. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 166 +++++++++++++----- .../agent-identity/agent-definitions.md | 2 +- docs/drafts/agent-identity/implementation.md | 5 + 3 files changed, 127 insertions(+), 46 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 989d457216..7eed3be56f 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -1,8 +1,10 @@ # Agent identity and authority -This document proposes how Mecatl identifies **who is acting** in a multi-user, multi-session deployment: a user starts an agent, the agent delegates to subagents, each delegation stays within its parent's authority, and the resulting actions leave a durable audit trail. The goal is to let an operator trace an action back to the agent and user behind it, with authorization enforced outside the agent loop. +This document proposes how Mecatl identifies who is acting, and bounds what they may do, in a deployment that serves many users and sessions. A user starts an agent, which may delegate work to subagents. An operator must be able to trace every protected tool call to the agent instance that made it and to the user it acted for. The call must also stay within the agent's purpose: a code-review agent that calls GitHub with its user's token holds everything that user can do, including merging, but it should only read. An agent's authority is therefore the intersection of its user's permissions and its own, and a subagent never gets more than its parent. -**Workload identity alone cannot answer these questions.** It identifies the Mecatl service, but one service can host many users, agent definitions, and running instances. This architecture gives those agents identities of their own and connects them to the users they act for. It follows an agent from the trusted definition loaded at session creation, through its runs and delegated child instances, to the tool calls made on its behalf, which may use a human user's credentials to access an external service. +The platform's workload identity cannot express this. It names the service, mecak8s, and one mecak8s process hosts many users, agent definitions, and running agents. This design gives each agent instance a credential of its own, ties it to a user sign-in that Mecatl cannot fabricate, and places the checks and the audit trail in an identity broker outside the agent loop. + +The document follows one agent from session creation, through delegation, to a tool call that uses the user's credentials to reach an external service. It covers MCP tool calls that pass through the broker. Recording who acts comes first; enforcing each agent's bounds follows (see [order of work](#order-of-work)). *This document describes the intended architecture, not current implementation status.* @@ -12,12 +14,12 @@ Suppose Alice asks her main agent to review PR 42. It delegates to `code-reviewe Four problems follow: -- **An agent name is only a claim until its definition is established.** An audit record naming `code-reviewer` is useful only if we can establish which definition it refers to, where that definition came from, and what was loaded for the session. Those facts need a trusted source outside the agent loop. -- **The user's credentials hide the acting agent.** We need to preserve both identities: whose access was used and which agent exercised it. -- **Access to a user's credentials is not permission to exercise all of their authority.** A reviewer must remain limited to reviewing even if Alice's GitHub credential permits merging. The design needs to bring user permissions and agent permissions together, and delegation must preserve or narrow the resulting authority. -- **The harness cannot be its own independent witness.** Its execution log helps explain what it attempted, but evidence of an external action must come from the component that checked and dispatched it, together with the observed result. +- An agent name is only a claim until the system establishes its definition. An audit record that names `code-reviewer` is useful only if we know which definition the name refers to, where that definition came from, and what was loaded for the session. A trusted source outside the agent loop must provide these facts. +- The user's credentials hide the acting agent. GitHub sees only Alice. The audit must keep both identities: whose access the call used, and which agent made the call. +- A user's credential can do more than the agent should do. For example, Alice's GitHub credential can merge a PR, but the `code-reviewer` agent must only review. The design combines the user's permissions with the agent's permissions. A child agent can have the same authority as its parent, or less, so a main agent cannot reach tools it lacks by delegating to a child whose definition allows them. +- The harness (the code that runs the agent loop) cannot give independent evidence of its own actions. Its execution log helps explain what it tried to do. Evidence of an external action must come from the component that checked and dispatched the action, together with the result that the component observed. -These guarantees also depend on the path an operation takes: a call that bypasses the authorization boundary escapes its checks and audit trail. +These guarantees hold only for calls that pass through the broker. A call that goes around the broker skips its checks and its audit trail. In the remote deployment, protected operations must pass through the broker. The architecture must connect these facts. An auditor should be able to follow Alice's request through the delegation to the tool call, identify the authority under which it was permitted, and distinguish a successful operation from a denied call or an unknown outcome. @@ -29,23 +31,34 @@ An instance is one use of a definition in a session. Alice and Bob can use the s A run is one execution by an instance. Reviewing PR 42 and later PR 57 uses the same reviewer identity but separate authorization contexts. A delegated child has its own instance and run, linked to the creating instance and delegating run. -The user is the person on whose behalf the agents act. The workload is the Mecatl service hosting them, potentially serving many users at once. +The user is the person on whose behalf the agents act. mecak8s, the service that hosts the agents, is the platform workload: it serves many users at once and has its own infrastructure identity. Each agent instance also has an identity of its own, in a separate agent trust domain. ## Establishing an agent's identity The [agent-definition loader](agent-definitions.md#loading-definitions) resolves definitions from configured sources, such as administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. -The existing `AgentDef` structure currently combines instructions, execution settings, and tool configuration. This design proposes a split that gives the harness the configuration needed to execute the agent and the broker the definition records and authority ceilings needed to authorize MCP calls. Source and revision records still need a representation. Changing a turn limit should not redefine the agent's identity; loading a definition should not grant access to a user's accounts. +An agent definition is a value, `AgentDef`, that combines instructions, execution settings, and tool configuration. This design proposes to split it. The harness gets the configuration that it needs to execute the agent. The broker gets the definition records and the authority ceilings that it needs to authorize MCP calls. A ceiling is the set of MCP server/tool pairs that an agent can call. + +Changing a turn limit must not change the agent's identity. Loading a definition must not grant access to a user's accounts. ## Where trust lives The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. -The harness hosts the API and agent loop and validates the initiating user's AS-1 access token. The **identity broker** is the existing MCP broker, extended to govern access to protected tools; it is one trusted component, not several. It starts as modules inside the mecak8s process and later becomes a separate service; see [deployment and threats](#deployment-and-threats). Its modules are the **definition loader**, which loads agent definitions from configured sources and supplies the harness with execution configuration; the **agent-instance issuer**; **AS-1**, Mecatl's login authorization server; and the authorization and audit logic. Together they issue agent credentials, check requested operations against policy, arrange downstream access using credentials the broker controls, and record the decisions and actions they observe. +mecak8s hosts the API and the harness, which runs the agent loop. It validates the access token of the user who starts the session. AS-1, Mecatl's login authorization server, issued that token. + +The **identity broker** establishes who is acting. Its modules load agent definitions, accept instance registrations from mecak8s and issue instance credentials, sign users in, check each protected MCP tool call, and get downstream access with credentials that the broker controls. Once the authority work is in place, the broker also bounds what each agent can reach. It records the audit trail. It is the existing MCP broker, extended, and one trusted component. It starts as modules inside the mecak8s process and later becomes a separate service. See [deployment and threats](#deployment-and-threats). + +The broker has these modules: + +- The **definition loader** loads agent definitions from configured sources. It gives the harness the execution configuration. +- The **agent-instance issuer** signs a JWT-SVID for each instance that mecak8s registers, in an agent trust domain separate from the platform's and from AS-1 and AS-2. It signs on mecak8s's report: in SPIRE terms, the broker is the attestor, and mecak8s's registration is its only evidence. +- **AS-1** signs users in and issues ID-JAGs. An ID-JAG is a short-lived, single-use signed grant that names the user and the acting agent. It is addressed only to AS-2, the gateway's authorization server, which redeems it for an access token, and only when the broker's client presents it. +- The authorization and audit logic. This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. -The broker does not decide whether agents start or whether runs execute; the harness owns agent lifecycle and run execution. Registering an instance with the broker records its identity and grants no permission to run. +The broker does not decide whether agents start or whether runs execute; mecak8s owns agent lifecycle and run execution. Registering an instance with the broker records its identity and grants no permission to run. ```mermaid flowchart LR @@ -66,8 +79,8 @@ flowchart LR T --- V end H -->|"Workload-authenticated instance registration by definition reference"| I - H <-->|"Workload-authenticated MCP calls; AS-1 access token once at session open; agent SVID per call"| I - I -->|"RFC 8693: session refresh token, then ID-JAG per call"| A1 + H <-->|"Workload-authenticated MCP calls; AS-1 access token once at session open; agent JWT-SVID per call"| I + I -->|"RFC 8693: session refresh token, then ID-JAG on a cache miss"| A1 I -->|"RFC 7523: redeem ID-JAG for V"| T I <-->|"Authorized MCP calls with V"| V V <-->|"Protected calls"| P["MCP backends / providers"] @@ -76,36 +89,65 @@ flowchart LR The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. -At startup, the broker's [definition loader](agent-definitions.md#loading-definitions) loads the global agent definitions from configured sources and records their sources, revisions, and ceilings in the broker. How other definitions, such as repository definitions, are loaded is not yet decided. Before the agent loop starts, mecak8s's trusted session lifecycle code persists the session's fixed definition selection and [registers the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication, naming its definition by reference. The registration API, durable schema, verified per-operation binding for reported context, and protection from same-process substitution remain open. +At startup, the broker's [definition loader](agent-definitions.md#loading-definitions) loads the global agent definitions from configured sources and records their sources, revisions, and ceilings in the broker. Before the agent loop starts, mecak8s's trusted session lifecycle code persists the session's fixed definition selection and [registers the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication, naming its definition by reference. Several points about loading and registration are still open. See [open questions](#open-questions). + +The vMCP gateway sends protected MCP calls to MCP backends and providers. It has three parts: AS-2, the gateway's authorization server; provider credential storage; and the vMCP, which dispatches the calls. The gateway can run inside the Mecatl deployment (embedded mode) or outside it (external mode). + +Mecatl uses two authorization servers because the gateway may belong to someone else. One server that both holds users' provider credentials and issues grants naming the user and the agent works when Mecatl runs the gateway, but not when another operator runs it and owns the credentials. AS-1 therefore issues the grants, and AS-2, the gateway's server, holds the credentials and redeems the grants, so one protocol serves both modes. The two keep separate keys even in embedded mode, where the split is a protocol boundary, not a security boundary. -The vMCP gateway contains AS-2, provider credential storage, and the vMCP. The gateway can run inside the Mecatl deployment or as an external gateway. +Sign-in happens at AS-1 rather than in mecak8s so that Mecatl cannot invent a user: mecak8s only forwards a token AS-1 signed, and every later grant names the user from AS-1's record. This is how Alice's tool calls get access: -Alice signs in through AS-1, which redirects her to the company IdP. Her client then calls mecak8s with an AS-1 access token. mecak8s validates that token and current session ownership. At session open it passes the token to the broker once, and the broker exchanges it at AS-1 for a refresh token bound to that broker session. On each tool call the broker presents that refresh token and the acting agent's JWT-SVID to AS-1, which returns an ID-JAG naming Alice and the agent. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. The broker is a single OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`. AS-2 maps the AS-1 subject to its native user; an external gateway also requires a one-time consent link created at AS-2's own login. AS-2 receives neither Alice's AS-1 token nor the agent SVID. See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. The tools themselves execute the operations. +1. Alice signs in at AS-1. AS-1 redirects her to the company identity provider (IdP). +2. Her client calls mecak8s with an AS-1 access token. +3. mecak8s validates the token and checks that Alice currently owns the session. +4. At session open, mecak8s passes the token to the broker once. +5. The broker exchanges the token at AS-1 for a refresh token. The refresh token is bound to the broker session. +6. When the broker has no cached vMCP access token for this session, agent, gateway, and user, it sends the refresh token and the acting agent's JWT-SVID to AS-1. The JWT-SVID lets AS-1 name the acting agent in the ID-JAG's `act` claim, so the gateway and the audit trail can attribute the call. It asserts the agent rather than authenticating it, because agents share one process. AS-1 returns an ID-JAG that names Alice and the agent. +7. The broker redeems the ID-JAG at AS-2 and gets the vMCP access token `V`. -AS-1 witnessed Alice's sign-in itself, checks that the acting instance is registered to the broker session, and authorizes the client, subject, actor, and target. It does not prove that the agent registration, ancestry, run, or delegation reported by trusted lifecycle code is genuine. The agent loop must not choose those facts, and distinct agent credentials do not isolate agents sharing one `mecak8s` process. +The broker holds the refresh token because agents keep working after Alice closes her client. The token carries her authority for that one broker session, has a hard expiry, and is revoked when the session closes or she signs out everywhere. The broker is one OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`, so a leaked refresh token is useless without the broker's key. AS-2 maps the AS-1 subject to its native user. An external gateway also requires a one-time consent link, which the user creates at AS-2's own login. The link is the user's own statement, made at the gateway, that Mecatl may act for them, so a compromised AS-1 cannot use the gateway credentials of a user who never chose Mecatl. AS-2 never receives Alice's AS-1 token or the agent's JWT-SVID. The tools themselves execute the operations. + +See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the full walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. + +AS-1 issued Alice's token after her company-IdP sign-in, and every ID-JAG takes its subject from AS-1's record of the refresh token, never from anything mecak8s sends with a call. When the broker asks for an ID-JAG, AS-1 checks two things: + +- The acting instance is registered to the broker session. +- The combination of client, subject, actor, and target is allowed. The client is `mecatl`. The subject is Alice. The actor is the acting agent instance. The target is the AS-2 gateway. + +AS-1 cannot check that the agent registration, the ancestry, the run, and the delegation are genuine. Trusted lifecycle code in mecak8s reports these facts. The agent loop must not choose them. Each agent has its own credential, but agents that share one mecak8s process are not isolated from each other. ## Deployment and threats -The broker starts as modules inside the mecak8s process: broker core, AS-1, the agent-instance issuer, and the definition loader. Later these modules move together into one separate broker service. The protocol and token formats are the same in both deployments; what changes is which process holds the broker's keys, refresh tokens, and records. +The broker starts as modules inside the mecak8s process: broker core, AS-1, the agent-instance issuer, and the definition loader. Later these modules move together into one separate broker service. The protocol and token formats are the same in both deployments; what changes is which process holds the broker's keys, refresh tokens, and records. Starting inside mecak8s ships the protocol sooner. Moving out protects those keys, tokens, and records against a compromised mecak8s; it is not about hostile workspace code, which executor Pods already contain. Some protections do not depend on where the broker runs: -- **Hostile workspace code** runs in executor Pods, behind the execution provider. In both deployments, executors get no broker credentials, no database credentials, and no network path to mecak8s internals or the KMS. +- **Hostile workspace code** runs in executor Pods, behind the execution provider. In both deployments, executors get no broker credentials, no database credentials, and no network path to mecak8s internals or the key management service (KMS). - **A prompt-injected agent** never sees or chooses credentials. Harness code attaches them, and the model supplies only tool names and arguments. -- **The user comes from a sign-in at AS-1**, not from a harness report. mecak8s passes on the AS-1-signed access token once, AS-1 converts each token at most once, and every ID-JAG names the user from AS-1's record. No mecak8s code path can name a user who did not sign in. +- **The user comes from a sign-in at AS-1**, not from a mecak8s report. mecak8s passes on the AS-1-signed access token once, AS-1 converts each token at most once, and every ID-JAG names the user from AS-1's record. Correct mecak8s code cannot name a user who did not sign in. An attacker running code in the process that holds AS-1's keys can, and while the broker runs inside mecak8s that process is mecak8s; see the first row of the table below. -Where the broker runs matters against one attacker: someone with code execution in the mecak8s process. +Where the broker runs matters against one attacker: someone with code execution in the mecak8s process. The table shows, for each threat, whether the attack works, is limited, or is blocked. | Threat from a compromised mecak8s | Broker inside mecak8s | Separate broker service | | --- | --- | --- | -| Act as any user, by signing an ID-JAG for a user who is not present | Open. The process holds AS-1's signing key and the broker's client key. In embedded mode this reaches every user connected to the gateway; in external mode, only users with a consent link. | Narrowed. mecak8s holds no keys. It can act only in open broker sessions bound to its workload, until each closes or its refresh token's hard expiry passes, and it can open sessions with fresh tokens from users currently using it. | -| Steal refresh tokens or signing keys | Open. A KMS prevents copying a key out, not signing with it while the attacker is inside. | Closed, if the broker has its own database credentials and only the broker's service account may sign with its KMS keys. | -| Read users' provider tokens in embedded mode | Open, if the gateway store shares storage credentials with mecak8s. | Closed, if the embedded gateway is its own deployment with its own credentials. | -| Tamper with broker and AS-1 audit records | Partly closed. Records already delivered to an external append-only store survive; records from the compromised process can be forged or suppressed from then on. | Closed for broker and AS-1 records, which mecak8s no longer writes, if they go to an append-only store that mecak8s cannot write. | +| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes or when its refresh token's hard expiry passes. mecak8s can also open new sessions with fresh tokens from users who are using it now. | +| Steal refresh tokens or signing keys | Attack works. A KMS stops the attacker from copying a key out. It does not stop the attacker from signing with the key while the attacker is inside the process. | Attack blocked, if the broker has its own database credentials and only the broker's service account can sign with its KMS keys. | +| Read users' provider tokens in embedded mode | Attack works, if the gateway store shares storage credentials with mecak8s. | Attack blocked, if the embedded gateway is its own deployment with its own credentials. | +| Tamper with broker and AS-1 audit records | Attack partly blocked. Records that are already in an external append-only store survive. From then on, the compromised process can forge or suppress its own records. | Attack blocked for broker and AS-1 records, because mecak8s does not write them. This holds if the records go to an append-only store that mecak8s cannot write. | + +In both deployments, the broker trusts mecak8s to report these facts: + +1. Which agent instance made a call. +2. Which loaded definition a session uses as its root. +3. Which instance created which child. +4. The run and delegation context. +5. Which open session a call belongs to. -In both deployments, the broker takes these facts on mecak8s's word: which agent instance made a call, which loaded definition a session uses as its root, which instance created which child, the run and delegation context, and which open session a call belongs to, which the broker checks against the user's token and the session's recorded owner to catch mistakes. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark the facts mecak8s reported. +The broker cannot verify facts 1 to 4. For fact 5, the broker checks the session against the user's token and the session's recorded owner. This check catches mistakes. -The authority work limits what those reports can achieve. With ceilings set by the loader and the operator's list of root-eligible definitions, a compromised mecak8s cannot exceed the widest definition a session may use as its root, whichever agent it claims is acting. With run grants, it can act only during runs a user started. +The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark each fact that mecak8s reported. + +The authority work limits what those reports can achieve. With ceilings set by the loader and the operator's list of definitions that can be a session's root, a compromised mecak8s cannot exceed the widest definition a session may use as its root, whichever agent it claims is acting. A run grant is a credential that AS-1 mints for one run, from a fresh access token the user sent. With run grants and a separate broker, a compromised mecak8s needs a fresh token from the user for each run, but it can still turn any request the user sends into a run of its choosing, one run per token, and scheduled runs keep their own authority. Only a key held by the client that signs each run's start would prevent that. A separate broker service delivers its protections only if the split is real: @@ -116,19 +158,43 @@ A separate broker service delivers its protections only if the split is real: While the broker runs inside mecak8s, the [modulith constraints](implementation.md#modulith-constraints) keep the later move a deployment change. +## What this design does not guarantee + +Precise limits matter as much as guarantees. This design does not provide: + +- **Isolation between agents in one process.** Per-agent credentials attribute calls; they do not stop code inside mecak8s from acting as another agent. +- **Verification of what mecak8s reports.** Which instance acted, which definition is a session's root, which instance created which child, and the run and delegation context stay mecak8s's word, marked as such in audit. +- **Protection against a compromised mecak8s while the broker runs inside it.** See the table above. +- **Coverage beyond the broker.** Calls that bypass the broker, and filesystem tools, Shell, and spawning, are outside this design. +- **Non-repudiation.** Audit records are unsigned. +- **Proof of provider-side effects.** A record shows what the recording component observed; an unknown outcome stays unknown. +- **Instant downstream revocation.** ID-JAGs and vMCP access tokens already issued stay valid until they expire, so their lifetimes are short. + ## Following an action through the architecture -Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. +Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main agent calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. -The broker checks the reviewer's effective server/tool ceiling, its definition ceiling narrowed by the main's, with Cedar. User and connection are enforced downstream: AS-2 redeems only for a mapped, and in external mode linked, user, and vMCP dispatches only with Alice's stored credential. This ceiling check is part of the [authority work](#order-of-work), which follows the identity work. Even then, the design does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. If all required checks pass, ToolHive dispatches the call through the authorized connection using its native provider credential. That credential stays outside the harness and model; in embedded mode it can reside inside the Mecatl deployment. If denied, no request reaches GitHub. +During the identity work, the broker checks the session, the workload, that the acting instance is registered to the session, and the route. Once the [authority work](#order-of-work) is in place, it also uses Cedar, a policy engine, to check the reviewer's effective ceiling: the reviewer's definition ceiling, narrowed by the main agent's. Even then, the design does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. -GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. +AS-2 and vMCP enforce the user and the connection. AS-2 redeems the ID-JAG only for a mapped user. In external mode, the user must also have a consent link. vMCP dispatches only with Alice's stored credential. + +If all required checks pass, vMCP dispatches the call through the authorized connection with its native provider credential. That credential stays outside the harness and the model. In embedded mode, it can be inside the Mecatl deployment. If any check denies the call, no request reaches GitHub. Missing, stale, or revoked identity, consent, connection, or authorization evidence fails closed, with no fallback to a service credential, another user, or a more privileged agent. + +GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. Each record is written by the component that made the decision: the broker, AS-1, AS-2, and the vMCP all record through the toolhive-core audit logger, to a store mecak8s cannot write. See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed credentials sequence. +## Standards + +The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. + +ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) and identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)), which external mode follows, are Internet-Drafts and may change. ID-JAG is scoped to parties in different trust domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. + +Agent credentials are SPIFFE JWT-SVIDs. Each protected call is authenticated at two layers, as the WIMSE architecture recommends: the platform workload, and the acting agent. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. + ## Order of work -Mecatl builds this design in two steps. The identity work comes first, and the authority work follows once the identity work is in place. +Mecatl builds this design in two steps. The identity work comes first, and the authority work follows once the identity work is in place. The order follows the dependencies: a ceiling attaches to a definition and to the chain of instances that created each other, so the broker can enforce ceilings only once definitions and registrations are recorded. Recording first also lets attribution ship without committing to a policy model. The **identity work** establishes who acts and records it: @@ -138,8 +204,8 @@ The **identity work** establishes who acts and records it: - agent-instance credentials; - the credential flow: sign-in at AS-1, the session-bound refresh token, ID-JAG issuance, AS-2 redemption, and per-user provider credentials at the gateway; - the broker's identity checks: session, workload, and that the acting instance is registered to the broker session; -- the run owner on every call, checked against the grant in use; -- sign-out and revocation: signing out everywhere stops every agent and schedule acting for the user, and AS-1's upstream re-check does the same when the company IdP disables the account; +- the run owner on every call, checked against the grant in use, so that shared sessions can come later without a call using the wrong user's credentials; +- sign-out and revocation: signing out of one client leaves background agents running, so signing out everywhere stops every agent and schedule acting for the user, and AS-1's upstream re-check does the same when the company IdP disables the account; - audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. The **authority work** decides what each agent may do: @@ -150,20 +216,30 @@ The **authority work** decides what each agent may do: - grants scoped to a run the user started, replacing the session's standing refresh token, so that sessions of absent users carry no authority; - task-specific delegation bounds, and user, resource, and argument policy. -Until the authority work is in place, the harness's existing delegation restrictions are the only ceiling checks; the broker adds none. Registration already records the definition reference and creation parent, so the authority work adds computation, not a migration. The chapters describe the whole design and say which parts belong to the authority work. +Until the authority work is in place, the only ceiling checks are the harness's delegation restrictions. The broker adds none. The identity work records the definition reference and the creation parent at registration. So the authority work adds computation, not a migration. The chapters describe the whole design and say which parts belong to the authority work. + +## Open questions + +The design does not yet settle these points: + +- How the broker stores the source and revision of each agent definition. +- How the broker loads definitions that do not come from the global sources, such as repository definitions. +- The API that mecak8s uses to register an agent instance with the broker, and the durable schema of the registration records. +- How the broker verifies the context that mecak8s reports with each call: the instance, the run, the delegation, and the session. Today the broker accepts this context as a report. It checks only the session, against the user's token and the session's recorded owner. +- How to stop code inside the mecak8s process from posing as the trusted lifecycle code or as another agent. ## Detailed chapters -The proposed chapters develop each part of the architecture. +These chapters describe each part of the design in detail. -| Chapter | Focus | +| Chapter | What it covers | | --- | --- | -| [Audit](audit.md) | Define attribution, correlation, evidence, redaction, and durability requirements. | -| [Agent definitions](agent-definitions.md) | Explain `AgentDef` and separate harness execution configuration from broker-enforced authority. | -| [MCP authorization](mcp-authorization.md) | Explain how user and agent permissions govern MCP calls and how the broker uses ToolHive. | -| [MCP gateway modes](mcp-gateway-modes.md) | Chain the broker's grant issuer to the vMCP gateway's authorization server, so one protocol works with an embedded or an external gateway. | -| [Credentials and lifecycle](credentials-and-lifecycle.md) | Follow one credential-to-outcome walkthrough with validation and audit evidence, then explain renewal and recovery. | -| [Run owner](run-owner.md) | Record who started each run, check it against the grant in use, and keep shared sessions possible later. | -| [All-tool mediation](all-tool-mediation.md) | Develop the broader authorization boundary for filesystem tools, Shell, and spawning. | - -The [implementation and verification companion](implementation.md) records integration requirements and checks. This broker-centered architecture supersedes the earlier identity model's harness-owned signing and audit responsibilities; the donor documents are retained in Git history. +| [Audit](audit.md) | Sets the requirements for attribution, correlation, evidence, redaction, and durability of audit records. | +| [Agent definitions](agent-definitions.md) | Describes `AgentDef`. Separates the harness's execution configuration from the authority that the broker enforces. | +| [MCP authorization](mcp-authorization.md) | Describes how user permissions and agent permissions control MCP calls, and how the broker uses ToolHive. | +| [MCP gateway modes](mcp-gateway-modes.md) | Connects the broker's grant issuer to the vMCP gateway's authorization server. One protocol works with an embedded gateway or an external gateway. | +| [Credentials and lifecycle](credentials-and-lifecycle.md) | Follows one request from credential to outcome, with validation and audit evidence. Then explains credential renewal and recovery. | +| [Run owner](run-owner.md) | Records who started each run and checks it against the grant in use. Keeps shared sessions possible later. | +| [All-tool mediation](all-tool-mediation.md) | Describes how the authorization boundary could extend to filesystem tools, Shell, and spawning. This is a future direction. | + +The [implementation and verification companion](implementation.md) records integration requirements and checks. This design replaces an earlier model in which the harness signed agent credentials and wrote its own audit records. \ No newline at end of file diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index e9a054eff1..17afba2c47 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -86,7 +86,7 @@ The broker ceiling is the set of permitted registered MCP server/tool pairs reso ## Loading definitions -The **definition loader** is a module of the identity broker. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. Once the [authority work](README.md#order-of-work) is in place, it also resolves and records each definition's ceiling. The operator's loader configuration then also says which loaded definitions may serve as a session's root, optionally per user or group, and caps the ceiling of definitions from other sources; the broker refuses to register a root outside that set. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. +The **definition loader** is a module of the identity broker. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. The operator's loader configuration also says which loaded definitions may serve as a session's root, the definition of its main agent, optionally per user or group. When mecak8s registers an instance with no parent, the broker refuses it if its definition is not root-eligible, and records the refusal. Once the [authority work](README.md#order-of-work) is in place, the loader also resolves and records each definition's ceiling, and the configuration caps the ceiling of definitions from other sources, so no agent can exceed the widest root-eligible ceiling. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. Each session uses a fixed selection of loaded definitions. mecak8s's trusted session lifecycle code persists that selection and names definitions to the broker by reference; it does not supply their content or ceilings. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 3ea904d4b6..27d1d3d567 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -269,6 +269,11 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - Each reconciliation check flags an injected mismatch: an AS-2 redemption without AS-1 issuance, a vMCP call without broker dispatch, a dispatch without a vMCP call, and a session-open conversion without AS-1 issuance. - The broker's lineage check rejects a delegation whose instances are in different session trees or whose parent delegation's receiving run differs. +## Root eligibility checks + +- The broker refuses to register a root instance whose definition is not root-eligible for that user, and records the refusal. +- A child instance's definition need not be root-eligible. + ## Session binding checks - AS-1 refuses a session-open conversion when the token's `sub_id` differs from the owner mecak8s sends, and records the refusal. From 433bc0a521ad31eece443fdf9741031468cd0281 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 11:40:52 +0200 Subject: [PATCH 37/50] docs: move the threat table into a threat model chapter Add threat-model.md with the protections that hold in both deployments, the full table of what a compromised mecak8s can do in each, a new row for false reports, the facts mecak8s reports and the broker's consistency checks on them, and what the split requires. The README keeps an overview and links to it. Give the reason for two authorization servers in the gateway modes chapter, and the full identity-work and authority-work breakdown in the implementation companion, so the README can shorten without losing them. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 43 ++------------ docs/drafts/agent-identity/implementation.md | 26 +++++++++ .../agent-identity/mcp-gateway-modes.md | 2 + docs/drafts/agent-identity/threat-model.md | 56 +++++++++++++++++++ 4 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 docs/drafts/agent-identity/threat-model.md diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 7eed3be56f..2ae08b1b49 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -118,45 +118,13 @@ AS-1 cannot check that the agent registration, the ancestry, the run, and the de ## Deployment and threats -The broker starts as modules inside the mecak8s process: broker core, AS-1, the agent-instance issuer, and the definition loader. Later these modules move together into one separate broker service. The protocol and token formats are the same in both deployments; what changes is which process holds the broker's keys, refresh tokens, and records. Starting inside mecak8s ships the protocol sooner. Moving out protects those keys, tokens, and records against a compromised mecak8s; it is not about hostile workspace code, which executor Pods already contain. +The broker starts as modules inside the mecak8s process and later moves, as a whole, into a separate service; the protocol and token formats do not change. Starting inside mecak8s ships the protocol sooner. Moving out protects the broker's signing keys, refresh tokens, and audit records against a compromised mecak8s. Hostile workspace code is a different threat, already contained by executor Pods. -Some protections do not depend on where the broker runs: +Some protections hold in both deployments. Executor Pods get no broker or database credentials. A prompt-injected agent never sees or chooses credentials, because harness code attaches them and the model supplies only tool names and arguments. And correct mecak8s code cannot name a user who did not sign in at AS-1. -- **Hostile workspace code** runs in executor Pods, behind the execution provider. In both deployments, executors get no broker credentials, no database credentials, and no network path to mecak8s internals or the key management service (KMS). -- **A prompt-injected agent** never sees or chooses credentials. Harness code attaches them, and the model supplies only tool names and arguments. -- **The user comes from a sign-in at AS-1**, not from a mecak8s report. mecak8s passes on the AS-1-signed access token once, AS-1 converts each token at most once, and every ID-JAG names the user from AS-1's record. Correct mecak8s code cannot name a user who did not sign in. An attacker running code in the process that holds AS-1's keys can, and while the broker runs inside mecak8s that process is mecak8s; see the first row of the table below. +Against an attacker with code execution in mecak8s, the split is what matters. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is separate, the attacker holds no keys, can act only in open sessions bound to its workload, and every false report it makes must pass the broker's checks and is recorded where it cannot erase it. In both deployments, which agent acted, which instance created which, and the run and delegation context remain mecak8s's word: checked for consistency, not verified. -Where the broker runs matters against one attacker: someone with code execution in the mecak8s process. The table shows, for each threat, whether the attack works, is limited, or is blocked. - -| Threat from a compromised mecak8s | Broker inside mecak8s | Separate broker service | -| --- | --- | --- | -| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes or when its refresh token's hard expiry passes. mecak8s can also open new sessions with fresh tokens from users who are using it now. | -| Steal refresh tokens or signing keys | Attack works. A KMS stops the attacker from copying a key out. It does not stop the attacker from signing with the key while the attacker is inside the process. | Attack blocked, if the broker has its own database credentials and only the broker's service account can sign with its KMS keys. | -| Read users' provider tokens in embedded mode | Attack works, if the gateway store shares storage credentials with mecak8s. | Attack blocked, if the embedded gateway is its own deployment with its own credentials. | -| Tamper with broker and AS-1 audit records | Attack partly blocked. Records that are already in an external append-only store survive. From then on, the compromised process can forge or suppress its own records. | Attack blocked for broker and AS-1 records, because mecak8s does not write them. This holds if the records go to an append-only store that mecak8s cannot write. | - -In both deployments, the broker trusts mecak8s to report these facts: - -1. Which agent instance made a call. -2. Which loaded definition a session uses as its root. -3. Which instance created which child. -4. The run and delegation context. -5. Which open session a call belongs to. - -The broker cannot verify facts 1 to 4. For fact 5, the broker checks the session against the user's token and the session's recorded owner. This check catches mistakes. - -The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings. Audit records mark each fact that mecak8s reported. - -The authority work limits what those reports can achieve. With ceilings set by the loader and the operator's list of definitions that can be a session's root, a compromised mecak8s cannot exceed the widest definition a session may use as its root, whichever agent it claims is acting. A run grant is a credential that AS-1 mints for one run, from a fresh access token the user sent. With run grants and a separate broker, a compromised mecak8s needs a fresh token from the user for each run, but it can still turn any request the user sends into a run of its choosing, one run per token, and scheduled runs keep their own authority. Only a key held by the client that signs each run's start would prevent that. - -A separate broker service delivers its protections only if the split is real: - -- the broker has its own database, KMS permissions, and namespace; -- mecak8s cannot create pods, read secrets, or change configuration in the broker's namespace; -- mecak8s cannot write the broker's workload allowlist, trust bundle, or policy inputs; -- the embedded gateway is its own deployment. - -While the broker runs inside mecak8s, the [modulith constraints](implementation.md#modulith-constraints) keep the later move a deployment change. +The [threat model](threat-model.md) has the full table, the facts mecak8s reports and how the broker checks them, and what the split requires to be real. ## What this design does not guarantee @@ -164,7 +132,7 @@ Precise limits matter as much as guarantees. This design does not provide: - **Isolation between agents in one process.** Per-agent credentials attribute calls; they do not stop code inside mecak8s from acting as another agent. - **Verification of what mecak8s reports.** Which instance acted, which definition is a session's root, which instance created which child, and the run and delegation context stay mecak8s's word, marked as such in audit. -- **Protection against a compromised mecak8s while the broker runs inside it.** See the table above. +- **Protection against a compromised mecak8s while the broker runs inside it.** See the [threat model](threat-model.md#a-compromised-mecak8s). - **Coverage beyond the broker.** Calls that bypass the broker, and filesystem tools, Shell, and spawning, are outside this design. - **Non-repudiation.** Audit records are unsigned. - **Proof of provider-side effects.** A record shows what the recording component observed; an unknown outcome stays unknown. @@ -240,6 +208,7 @@ These chapters describe each part of the design in detail. | [MCP gateway modes](mcp-gateway-modes.md) | Connects the broker's grant issuer to the vMCP gateway's authorization server. One protocol works with an embedded gateway or an external gateway. | | [Credentials and lifecycle](credentials-and-lifecycle.md) | Follows one request from credential to outcome, with validation and audit evidence. Then explains credential renewal and recovery. | | [Run owner](run-owner.md) | Records who started each run and checks it against the grant in use. Keeps shared sessions possible later. | +| [Threat model](threat-model.md) | Lists the attackers the design considers, what each can do in each deployment, and what the split from mecak8s requires. | | [All-tool mediation](all-tool-mediation.md) | Describes how the authorization boundary could extend to filesystem tools, Shell, and spawning. This is a future direction. | The [implementation and verification companion](implementation.md) records integration requirements and checks. This design replaces an earlier model in which the harness signed agent credentials and wrote its own audit records. \ No newline at end of file diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 27d1d3d567..53f5b88098 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -299,6 +299,32 @@ Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#si - After it, an ID-JAG exchange with any revoked refresh token fails, and each schedule's next run ends with "needs owner action". - When the upstream IdP refuses a refresh, AS-1 revokes all the user's grants, including schedule authority, and the exchange fails. A client's own sign-out does not remove the upstream token AS-1 re-checks. Re-checks happen at most once per interval. +## Work breakdown + +The [overview](README.md#order-of-work) gives the order of work and its reasons. The full breakdown: + +The **identity work** establishes who acts and records it: + +- the broker modules: definition loader, agent-instance issuer, AS-1, and broker core; +- root eligibility: the broker refuses to register a root instance whose definition the operator has not made root-eligible; +- definitions loaded with their provenance; +- instance registration, with the incarnation, definition reference, and creation parent; +- agent-instance credentials; +- the credential flow: sign-in at AS-1, the session-bound refresh token, ID-JAG issuance, AS-2 redemption, and per-user provider credentials at the gateway; +- the broker's identity checks: session, workload, and that the acting instance is registered to the broker session; +- the run owner on every call, checked against the grant in use, so that shared sessions can come later without a call using the wrong user's credentials; +- sign-out and revocation: signing out of one client leaves background agents running, so signing out everywhere stops every agent and schedule acting for the user, and AS-1's upstream re-check does the same when the company IdP disables the account; +- audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. + +The **authority work** decides what each agent may do: + +- the definition loader resolving each definition's ceiling into permitted MCP server/tool pairs, and the operator's loader configuration capping the ceiling of definitions from other sources; +- each instance's effective ceiling, its definition ceiling narrowed by its parent's; +- Cedar policy generation and a ceiling check on every call; +- grants scoped to a run the user started, replacing the session's standing refresh token, so that sessions of absent users carry no authority; +- task-specific delegation bounds, and user, resource, and argument policy. + + ## Modulith constraints While the broker runs as modules inside mecak8s, these constraints keep the later move to a separate service a deployment change rather than a redesign: diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 79ef1949a1..9177eb20f0 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -22,6 +22,8 @@ The design works in two deployments: standalone Mecatl, with no Stacklok Enterpr ## Two authorization servers +Mecatl uses two authorization servers because the gateway may belong to someone else. One server that both holds users' provider credentials and issues grants naming the user and the agent works when Mecatl runs the gateway, but not when another operator runs it and owns the credentials. AS-1 therefore issues the grants, and AS-2, the gateway's own server, holds the credentials and redeems the grants, so one protocol serves both modes. + **AS-1** is Mecatl's login authorization server, running inside the broker. Alice signs in through AS-1, which redirects her to the company IdP as an ordinary OIDC client. Her client receives an AS-1 access token for the Mecatl API. At `OpenSession`, mecak8s passes that token to the broker once. The broker exchanges it at AS-1 with RFC 8693 for a session-bound broker refresh token, `RT-A42`. The [session-open step](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token) describes that exchange and the AS-1 record behind `RT-A42`. **AS-2** is the gateway's authorization server. The vMCP accepts only access tokens AS-2 issued. On each MCP request, the vMCP validates the token and loads the user's provider credential. diff --git a/docs/drafts/agent-identity/threat-model.md b/docs/drafts/agent-identity/threat-model.md new file mode 100644 index 0000000000..a579ce094d --- /dev/null +++ b/docs/drafts/agent-identity/threat-model.md @@ -0,0 +1,56 @@ +# Threat model + +*This chapter describes the intended architecture, not current implementation status.* + +This chapter lists the attackers the design considers, what each can do, and which protections depend on where the broker runs. The [overview](README.md#deployment-and-threats) summarizes it. Limits the design accepts are listed under [what this design does not guarantee](README.md#what-this-design-does-not-guarantee). + +## Deployment + +The broker starts as modules inside the mecak8s process: broker core, AS-1, the agent-instance issuer, and the definition loader. Later these modules move together into one separate broker service. The protocol and token formats are the same in both deployments; what changes is which process holds the broker's keys, refresh tokens, and records. Starting inside mecak8s ships the protocol sooner. Moving out protects those keys, tokens, and records against a compromised mecak8s; it is not about hostile workspace code, which executor Pods already contain. + +## Protections in both deployments + +Some protections do not depend on where the broker runs: + +- **Hostile workspace code** runs in executor Pods, behind the execution provider. In both deployments, executors get no broker credentials, no database credentials, and no network path to mecak8s internals or the key management service (KMS). +- **A prompt-injected agent** never sees or chooses credentials. Harness code attaches them, and the model supplies only tool names and arguments. +- **The user comes from a sign-in at AS-1**, not from a mecak8s report. mecak8s passes on the AS-1-signed access token once, AS-1 converts each token at most once, and every ID-JAG names the user from AS-1's record. Correct mecak8s code cannot name a user who did not sign in. An attacker running code in the process that holds AS-1's keys can, and while the broker runs inside mecak8s that process is mecak8s; see the first row of the table below. + +## A compromised mecak8s + +Where the broker runs matters against one attacker: someone with code execution in the mecak8s process. The table shows, for each threat, whether the attack works, is limited, or is blocked. + +| Threat from a compromised mecak8s | Broker inside mecak8s | Separate broker service | +| --- | --- | --- | +| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes or when its refresh token's hard expiry passes. mecak8s can also open new sessions with fresh tokens from users who are using it now. | +| Steal refresh tokens or signing keys | Attack works. A KMS stops the attacker from copying a key out. It does not stop the attacker from signing with the key while the attacker is inside the process. | Attack blocked, if the broker has its own database credentials and only the broker's service account can sign with its KMS keys. | +| Read users' provider tokens in embedded mode | Attack works, if the gateway store shares storage credentials with mecak8s. | Attack blocked, if the embedded gateway is its own deployment with its own credentials. | +| Tamper with broker and AS-1 audit records | Attack partly blocked. Records that are already in an external append-only store survive. From then on, the compromised process can forge or suppress its own records. | Attack blocked for broker and AS-1 records, because mecak8s does not write them. This holds if the records go to an append-only store that mecak8s cannot write. | +| Report a false agent, parent, run, or root | Attack works. An attacker in the process can bypass the broker's checks and its records. | Attack limited. The report still cannot be verified, but it must pass the broker's consistency checks, is recorded where mecak8s cannot erase it, and must name a root-eligible definition. Once the authority work is in place, it cannot exceed the widest root-eligible ceiling. | + +## What mecak8s reports + +In both deployments, the broker trusts mecak8s to report these facts: + +1. Which agent instance made a call. +2. Which loaded definition a session uses as its root. +3. Which instance created which child. +4. The run and delegation context. +5. Which open session a call belongs to. + +Only mecak8s observes these facts: the agents are goroutines in its memory, and nothing outside the process can see which one is which. The broker cannot verify them; it checks that they are consistent. The acting instance must be registered to the session, a parent must belong to the same session tree and not be revoked, a delegation's links must line up, the run owner must match the grant in use, and a session's root must be a definition the operator made root-eligible. For fact 5, the broker checks the session against the user's token and the session's recorded owner. These checks catch mistakes, not a compromised mecak8s, which can report a consistent lie. The broker loads global definitions itself, so mecak8s cannot supply their content or ceilings, and audit records mark each fact that mecak8s reported. + +A separate broker does not make these facts verifiable, but it makes its checks impossible to skip and records every report where mecak8s cannot erase it. Making them verifiable would need each agent in its own pod or sandbox, with attestation, which is outside this design. + +The [authority work](README.md#order-of-work) limits what those reports can achieve. With ceilings set by the loader and capped by root eligibility, a compromised mecak8s cannot exceed the widest definition a session may use as its root, whichever agent it claims is acting. A run grant is a credential that AS-1 mints for one run, from a fresh access token the user sent. With run grants and a separate broker, a compromised mecak8s needs a fresh token from the user for each run, but it can still turn any request the user sends into a run of its choosing, one run per token, and scheduled runs keep their own authority. Only a key held by the client that signs each run's start would prevent that. + +## What the split requires + +A separate broker service delivers its protections only if the split is real: + +- the broker has its own database, KMS permissions, and namespace; +- mecak8s cannot create pods, read secrets, or change configuration in the broker's namespace; +- mecak8s cannot write the broker's workload allowlist, trust bundle, or policy inputs; +- the embedded gateway is its own deployment. + +While the broker runs inside mecak8s, the [modulith constraints](implementation.md#modulith-constraints) keep the later move a deployment change. From 5adac4b05ebd8c70caf485eff73a62ccd88a1c74 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 11:42:32 +0200 Subject: [PATCH 38/50] docs: restructure the agent identity overview Merge the token flow and the walk through Alice's request into one end-to-end section that gives the reason for each step, state each fact once, define ceiling, root, workload, ID-JAG, JWT-SVID, and vMCP where they are first needed, add a short section on what mecak8s reports and how the broker checks it, and shorten the order of work and open questions, linking to the chapters that hold the detail. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 117 +++++++++------------------ 1 file changed, 39 insertions(+), 78 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 2ae08b1b49..34ae433d52 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -19,46 +19,36 @@ Four problems follow: - A user's credential can do more than the agent should do. For example, Alice's GitHub credential can merge a PR, but the `code-reviewer` agent must only review. The design combines the user's permissions with the agent's permissions. A child agent can have the same authority as its parent, or less, so a main agent cannot reach tools it lacks by delegating to a child whose definition allows them. - The harness (the code that runs the agent loop) cannot give independent evidence of its own actions. Its execution log helps explain what it tried to do. Evidence of an external action must come from the component that checked and dispatched the action, together with the result that the component observed. -These guarantees hold only for calls that pass through the broker. A call that goes around the broker skips its checks and its audit trail. In the remote deployment, protected operations must pass through the broker. +These guarantees hold only for calls that pass through the broker. A call that goes around the broker skips its checks and its audit trail. The architecture must connect these facts. An auditor should be able to follow Alice's request through the delegation to the tool call, identify the authority under which it was permitted, and distinguish a successful operation from a denied call or an unknown outcome. ## The identity model -A definition describes a kind of agent, such as `code-reviewer`. Its source is part of that identity: a repository's definition and an administrator's definition with the same name are not interchangeable. +A **definition** describes a kind of agent, such as `code-reviewer`. Its source is part of that identity: a repository's definition and an administrator's definition with the same name are not interchangeable. A definition's **ceiling** is the set of MCP server/tool pairs that an agent built from it may call. Changing an execution setting such as a turn limit does not change a definition's identity, and loading a definition grants no access to a user's accounts. -An instance is one use of a definition in a session. Alice and Bob can use the same definition but have distinct instances. Restoration preserves an instance; a new session creates another. +An **instance** is one use of a definition in a session. Alice and Bob can use the same definition but have distinct instances. Restoring a session preserves its instances; a new session creates new ones. A session's **root** is the definition its main agent uses. -A run is one execution by an instance. Reviewing PR 42 and later PR 57 uses the same reviewer identity but separate authorization contexts. A delegated child has its own instance and run, linked to the creating instance and delegating run. +A **run** is one execution by an instance. Reviewing PR 42 and later PR 57 uses the same reviewer instance in two runs. A delegated child has its own instance and run, linked to the instance that created it and to the run that delegated to it. -The user is the person on whose behalf the agents act. mecak8s, the service that hosts the agents, is the platform workload: it serves many users at once and has its own infrastructure identity. Each agent instance also has an identity of its own, in a separate agent trust domain. - -## Establishing an agent's identity - -The [agent-definition loader](agent-definitions.md#loading-definitions) resolves definitions from configured sources, such as administrator-managed files, repositories, or APIs; the loop cannot establish identity by supplying a name. The selected definitions stay fixed for the session. Their sources and revisions must remain available across pauses and restoration, so repository changes cannot silently replace them. - -An agent definition is a value, `AgentDef`, that combines instructions, execution settings, and tool configuration. This design proposes to split it. The harness gets the configuration that it needs to execute the agent. The broker gets the definition records and the authority ceilings that it needs to authorize MCP calls. A ceiling is the set of MCP server/tool pairs that an agent can call. - -Changing a turn limit must not change the agent's identity. Loading a definition must not grant access to a user's accounts. +The **user** is the person on whose behalf the agents act. mecak8s, the service that hosts the agents, is the platform **workload**: it serves many users at once and has its own infrastructure identity. Each agent instance also has an identity of its own, in a separate agent trust domain. ## Where trust lives -The agent loop uses those definitions to perform work, but it cannot decide which identities or permissions the rest of the system should trust. Definition loading, authorization, and audit therefore need trusted responsibilities outside the loop. +The agent loop uses definitions to do work, but it cannot decide which identities or permissions the rest of the system trusts; otherwise a prompt-injected model or a buggy harness could grant itself access. Definition loading, credential issuance, authorization, and audit therefore live outside the loop. -mecak8s hosts the API and the harness, which runs the agent loop. It validates the access token of the user who starts the session. AS-1, Mecatl's login authorization server, issued that token. +mecak8s hosts the API and the harness. It validates the AS-1 access token of the user who starts a session, and it authenticates to the broker with its platform workload credential, such as a Kubernetes ServiceAccount token. -The **identity broker** establishes who is acting. Its modules load agent definitions, accept instance registrations from mecak8s and issue instance credentials, sign users in, check each protected MCP tool call, and get downstream access with credentials that the broker controls. Once the authority work is in place, the broker also bounds what each agent can reach. It records the audit trail. It is the existing MCP broker, extended, and one trusted component. It starts as modules inside the mecak8s process and later becomes a separate service. See [deployment and threats](#deployment-and-threats). +The **identity broker** establishes who is acting. It is the existing MCP broker, extended, and one trusted component. Once the authority work is in place, it also bounds what each agent can reach. Its modules are: -The broker has these modules: +- The **definition loader** loads agent definitions from configured sources, records each one's source and revision, and gives the harness the execution configuration. A session's selected definitions stay fixed, so a later change at the source cannot silently replace them. The operator says which definitions may be a session's root, and the broker refuses to register a root instance outside that set. See [agent definitions](agent-definitions.md#loading-definitions). +- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in an agent trust domain separate from the platform's and from AS-1 and AS-2. It signs on mecak8s's report: in SPIRE terms, the broker is the attestor, and mecak8s's registration is its only evidence. +- **AS-1**, Mecatl's login authorization server, signs users in through the company identity provider (IdP) and issues ID-JAGs (Identity Assertion JWT Authorization Grants). An ID-JAG is a short-lived, single-use signed grant that names the user and the acting agent. It is addressed only to AS-2, the gateway's authorization server, which redeems it for an access token, and only when the broker's client presents it. +- The **authorization and audit** logic checks each protected MCP call and records what it decided and observed. -- The **definition loader** loads agent definitions from configured sources. It gives the harness the execution configuration. -- The **agent-instance issuer** signs a JWT-SVID for each instance that mecak8s registers, in an agent trust domain separate from the platform's and from AS-1 and AS-2. It signs on mecak8s's report: in SPIRE terms, the broker is the attestor, and mecak8s's registration is its only evidence. -- **AS-1** signs users in and issues ID-JAGs. An ID-JAG is a short-lived, single-use signed grant that names the user and the acting agent. It is addressed only to AS-2, the gateway's authorization server, which redeems it for an access token, and only when the broker's client presents it. -- The authorization and audit logic. +Downstream, the **vMCP gateway** sends protected calls to MCP backends and providers. It has three parts: AS-2; storage for users' provider credentials, such as GitHub tokens; and the vMCP, ToolHive's virtual MCP server, which dispatches the calls. The gateway runs inside the Mecatl deployment (embedded mode) or outside it (external mode). -This design scopes broker authorization to MCP tools. Extending that boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md); their authorization and routing remain to be designed. - -The broker does not decide whether agents start or whether runs execute; mecak8s owns agent lifecycle and run execution. Registering an instance with the broker records its identity and grants no permission to run. +Mecatl uses two authorization servers because the gateway may belong to someone else. One server that both holds users' provider credentials and issues grants naming the user and the agent works when Mecatl runs the gateway, but not when another operator runs it and owns the credentials. AS-1 therefore issues the grants, and AS-2 holds the credentials and redeems the grants, so one protocol serves both modes. The two keep separate keys even in embedded mode, where the split is a protocol boundary, not a security boundary. ```mermaid flowchart LR @@ -78,43 +68,34 @@ flowchart LR V["vMCP"] T --- V end - H -->|"Workload-authenticated instance registration by definition reference"| I + H -->|"Workload-authenticated instance registration"| I H <-->|"Workload-authenticated MCP calls; AS-1 access token once at session open; agent JWT-SVID per call"| I - I -->|"RFC 8693: session refresh token, then ID-JAG on a cache miss"| A1 - I -->|"RFC 7523: redeem ID-JAG for V"| T + I -->|"Session refresh token; ID-JAG on a cache miss"| A1 + I -->|"Redeem ID-JAG for V"| T I <-->|"Authorized MCP calls with V"| V V <-->|"Protected calls"| P["MCP backends / providers"] I -->|"Decisions and observed outcomes"| A["Audit records"] ``` -The diagram shows the broker-backed remote architecture's MCP path. Local deployments can retain direct tool access; protected operations in the remote deployment must pass through the broker. +This design scopes broker authorization to MCP tools. Local deployments can keep direct tool access, and none of these guarantees apply to them. Extending the boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md). The broker does not decide whether agents start or whether runs execute; mecak8s owns agent lifecycle and run execution, and registering an instance records its identity without granting permission to run. -At startup, the broker's [definition loader](agent-definitions.md#loading-definitions) loads the global agent definitions from configured sources and records their sources, revisions, and ceilings in the broker. Before the agent loop starts, mecak8s's trusted session lifecycle code persists the session's fixed definition selection and [registers the main instance](agent-definitions.md#registering-instances) with the broker under workload authentication, naming its definition by reference. Several points about loading and registration are still open. See [open questions](#open-questions). +## One request, end to end -The vMCP gateway sends protected MCP calls to MCP backends and providers. It has three parts: AS-2, the gateway's authorization server; provider credential storage; and the vMCP, which dispatches the calls. The gateway can run inside the Mecatl deployment (embedded mode) or outside it (external mode). +Alice asks her main agent to list pull requests and review PR 42. The main agent calls `pull_request_list`, then delegates to `code-reviewer`, which calls `pull_request_read` for PR 42. Each call gets its own authorization decision. -Mecatl uses two authorization servers because the gateway may belong to someone else. One server that both holds users' provider credentials and issues grants naming the user and the agent works when Mecatl runs the gateway, but not when another operator runs it and owns the credentials. AS-1 therefore issues the grants, and AS-2, the gateway's server, holds the credentials and redeems the grants, so one protocol serves both modes. The two keep separate keys even in embedded mode, where the split is a protocol boundary, not a security boundary. +**Sign-in and session open.** Alice signs in at AS-1, which redirects her to the company IdP, and her client calls mecak8s with the AS-1 access token. Sign-in happens at AS-1 rather than in mecak8s so that Mecatl cannot invent a user: mecak8s checks that Alice owns the session and forwards the token to the broker once, and the broker exchanges it at AS-1 for a refresh token bound to that broker session. Every later grant names the user from AS-1's record of that refresh token, never from anything mecak8s sends with a call. The broker holds the refresh token because agents keep working after Alice closes her client. It carries her authority for that one session, has a hard expiry, and is revoked when the session closes or she signs out everywhere. The broker is one OAuth client, `mecatl`, at both AS-1 and AS-2, so a leaked refresh token is useless without the broker's key. -Sign-in happens at AS-1 rather than in mecak8s so that Mecatl cannot invent a user: mecak8s only forwards a token AS-1 signed, and every later grant names the user from AS-1's record. This is how Alice's tool calls get access: +**Each call.** When the broker has no cached access token for this session, agent, gateway, and user, it sends the refresh token and the acting agent's JWT-SVID to AS-1. AS-1 checks that the agent is registered to the session and that the combination of client, user, agent, and gateway is allowed, then returns an ID-JAG naming Alice and the agent in its `act` claim. The JWT-SVID gives the gateway and the audit trail the acting agent; it asserts that agent rather than authenticating it, because agents share one process. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. AS-2 maps AS-1's user to its own account for Alice and never receives her AS-1 token or the agent's JWT-SVID. An external gateway also requires a one-time consent link, which Alice creates at AS-2's own login. The link is her own statement, made at the gateway, that Mecatl may act for her, so a compromised AS-1 cannot use the gateway credentials of a user who never chose Mecatl. -1. Alice signs in at AS-1. AS-1 redirects her to the company identity provider (IdP). -2. Her client calls mecak8s with an AS-1 access token. -3. mecak8s validates the token and checks that Alice currently owns the session. -4. At session open, mecak8s passes the token to the broker once. -5. The broker exchanges the token at AS-1 for a refresh token. The refresh token is bound to the broker session. -6. When the broker has no cached vMCP access token for this session, agent, gateway, and user, it sends the refresh token and the acting agent's JWT-SVID to AS-1. The JWT-SVID lets AS-1 name the acting agent in the ID-JAG's `act` claim, so the gateway and the audit trail can attribute the call. It asserts the agent rather than authenticating it, because agents share one process. AS-1 returns an ID-JAG that names Alice and the agent. -7. The broker redeems the ID-JAG at AS-2 and gets the vMCP access token `V`. +**The checks.** During the identity work, the broker checks the session, the workload, that the acting instance is registered to the session, and the route. Once the [authority work](#order-of-work) is in place, it also uses Cedar, a policy engine, to check the reviewer's effective ceiling: the reviewer's definition ceiling, narrowed by the main agent's. Even then, the design does not evaluate PR-specific bounds or comprehensive enterprise user policy. AS-2 redeems only for a mapped user, and in external mode only for a linked one, and the vMCP dispatches only with Alice's stored GitHub credential, which never reaches the harness or the model. If any check denies the call, no request reaches GitHub. Missing, stale, or revoked identity, consent, connection, or authorization evidence fails closed, with no fallback to a service credential, another user, or a more privileged agent. -The broker holds the refresh token because agents keep working after Alice closes her client. The token carries her authority for that one broker session, has a hard expiry, and is revoked when the session closes or she signs out everywhere. The broker is one OAuth client, `mecatl`, at both AS-1 and AS-2, and authenticates with `private_key_jwt`, so a leaked refresh token is useless without the broker's key. AS-2 maps the AS-1 subject to its native user. An external gateway also requires a one-time consent link, which the user creates at AS-2's own login. The link is the user's own statement, made at the gateway, that Mecatl may act for them, so a compromised AS-1 cannot use the gateway credentials of a user who never chose Mecatl. AS-2 never receives Alice's AS-1 token or the agent's JWT-SVID. The tools themselves execute the operations. +**The record.** GitHub sees access under Alice's account. The audit trail supplies the rest: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. Each record is written by the component that made the decision: the broker, AS-1, AS-2, and the vMCP all record through the toolhive-core audit logger, to a store mecak8s cannot write. See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the full walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. -AS-1 issued Alice's token after her company-IdP sign-in, and every ID-JAG takes its subject from AS-1's record of the refresh token, never from anything mecak8s sends with a call. When the broker asks for an ID-JAG, AS-1 checks two things: - -- The acting instance is registered to the broker session. -- The combination of client, subject, actor, and target is allowed. The client is `mecatl`. The subject is Alice. The actor is the acting agent instance. The target is the AS-2 gateway. +## What mecak8s reports -AS-1 cannot check that the agent registration, the ancestry, the run, and the delegation are genuine. Trusted lifecycle code in mecak8s reports these facts. The agent loop must not choose them. Each agent has its own credential, but agents that share one mecak8s process are not isolated from each other. +AS-1 and the broker cannot verify the instance registration, the creation ancestry, the run, or the delegation: only mecak8s observes them, because the agents are goroutines in its memory, and mecak8s reports them. The broker records them as reported and checks them for consistency: the instance is registered to the session, its parent belongs to the same session tree, the run owner matches the grant in use, and a session's root is root-eligible. It also loads definition content and ceilings itself, so mecak8s cannot supply them. These checks catch mistakes, not a compromised mecak8s. Each agent has its own credential, but agents that share one mecak8s process are not isolated from each other. ## Deployment and threats @@ -138,23 +119,9 @@ Precise limits matter as much as guarantees. This design does not provide: - **Proof of provider-side effects.** A record shows what the recording component observed; an unknown outcome stays unknown. - **Instant downstream revocation.** ID-JAGs and vMCP access tokens already issued stay valid until they expire, so their lifetimes are short. -## Following an action through the architecture - -Alice asks her main agent to list pull requests and review PR 42. Once the session's definitions are loaded, the main agent calls `pull_request_list`, then delegates to `code-reviewer`. The reviewer calls `pull_request_read` for PR 42. Each call requires its own authorization decision. - -During the identity work, the broker checks the session, the workload, that the acting instance is registered to the session, and the route. Once the [authority work](#order-of-work) is in place, it also uses Cedar, a policy engine, to check the reviewer's effective ceiling: the reviewer's definition ceiling, narrowed by the main agent's. Even then, the design does not evaluate PR-specific delegated bounds or comprehensive enterprise user policy. - -AS-2 and vMCP enforce the user and the connection. AS-2 redeems the ID-JAG only for a mapped user. In external mode, the user must also have a consent link. vMCP dispatches only with Alice's stored credential. - -If all required checks pass, vMCP dispatches the call through the authorized connection with its native provider credential. That credential stays outside the harness and the model. In embedded mode, it can be inside the Mecatl deployment. If any check denies the call, no request reaches GitHub. Missing, stale, or revoked identity, consent, connection, or authorization evidence fails closed, with no fallback to a service credential, another user, or a more privileged agent. - -GitHub sees access under Alice's account. The broker's audit record supplies the rest of the story: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. Each record is written by the component that made the decision: the broker, AS-1, AS-2, and the vMCP all record through the toolhive-core audit logger, to a store mecak8s cannot write. - -See [Interactive execution](credentials-and-lifecycle.md#interactive-execution) for the detailed credentials sequence. - ## Standards -The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. +The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. That key belongs to the broker, ideally in a key management service; ToolHive's authorization server also accepts a SPIFFE JWT-SVID as client authentication, which could later replace the key with the broker's own workload identity. ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) and identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)), which external mode follows, are Internet-Drafts and may change. ID-JAG is scoped to parties in different trust domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. @@ -166,36 +133,30 @@ Mecatl builds this design in two steps. The identity work comes first, and the a The **identity work** establishes who acts and records it: -- the broker modules: definition loader, agent-instance issuer, AS-1, and broker core; -- definitions loaded with their provenance; -- instance registration, with the incarnation, definition reference, and creation parent; -- agent-instance credentials; -- the credential flow: sign-in at AS-1, the session-bound refresh token, ID-JAG issuance, AS-2 redemption, and per-user provider credentials at the gateway; -- the broker's identity checks: session, workload, and that the acting instance is registered to the broker session; -- the run owner on every call, checked against the grant in use, so that shared sessions can come later without a call using the wrong user's credentials; -- sign-out and revocation: signing out of one client leaves background agents running, so signing out everywhere stops every agent and schedule acting for the user, and AS-1's upstream re-check does the same when the company IdP disables the account; +- the identity broker and its modules, with definitions loaded with their provenance and root eligibility checked at registration; +- instance registration and agent-instance credentials; +- the credential flow, from sign-in at AS-1 to per-user provider credentials at the gateway; +- the [run owner](run-owner.md) on every call, so that shared sessions can come later without a call using the wrong user's credentials; +- sign-out everywhere, which stops every agent and schedule acting for the user, and AS-1's re-check of the company-IdP grant, which does the same when the account is disabled; - audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. The **authority work** decides what each agent may do: -- the definition loader resolving each definition's ceiling into permitted MCP server/tool pairs, and the operator's loader configuration naming which definitions may be a session's root and capping the ceiling of definitions from other sources; -- each instance's effective ceiling, its definition ceiling narrowed by its parent's; -- Cedar policy generation and a ceiling check on every call; +- ceilings resolved by the loader, narrowed on delegation, and checked with Cedar on every call; - grants scoped to a run the user started, replacing the session's standing refresh token, so that sessions of absent users carry no authority; - task-specific delegation bounds, and user, resource, and argument policy. -Until the authority work is in place, the only ceiling checks are the harness's delegation restrictions. The broker adds none. The identity work records the definition reference and the creation parent at registration. So the authority work adds computation, not a migration. The chapters describe the whole design and say which parts belong to the authority work. +Until the authority work is in place, the only ceiling checks are the harness's delegation restrictions. The identity work already records the definition reference and creation parent at registration, so the authority work adds computation, not a migration. The [implementation companion](implementation.md#work-breakdown) has the full breakdown. ## Open questions -The design does not yet settle these points: +Two questions bear on what the design can promise: -- How the broker stores the source and revision of each agent definition. -- How the broker loads definitions that do not come from the global sources, such as repository definitions. -- The API that mecak8s uses to register an agent instance with the broker, and the durable schema of the registration records. -- How the broker verifies the context that mecak8s reports with each call: the instance, the run, the delegation, and the session. Today the broker accepts this context as a report. It checks only the session, against the user's token and the session's recorded owner. +- How the broker could verify the context mecak8s reports with each call, the instance, run, and delegation, rather than only checking it for consistency. - How to stop code inside the mecak8s process from posing as the trusted lifecycle code or as another agent. +Narrower open points, such as how repository definitions are loaded and the registration API, are listed in the chapters. + ## Detailed chapters These chapters describe each part of the design in detail. @@ -211,4 +172,4 @@ These chapters describe each part of the design in detail. | [Threat model](threat-model.md) | Lists the attackers the design considers, what each can do in each deployment, and what the split from mecak8s requires. | | [All-tool mediation](all-tool-mediation.md) | Describes how the authorization boundary could extend to filesystem tools, Shell, and spawning. This is a future direction. | -The [implementation and verification companion](implementation.md) records integration requirements and checks. This design replaces an earlier model in which the harness signed agent credentials and wrote its own audit records. \ No newline at end of file +The [implementation and verification companion](implementation.md) records integration requirements and checks. This design replaces an earlier model in which the harness signed agent credentials and wrote its own audit records. From 59cd3f9cb72479847f3fc5b9fba6845eed81c9ce Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 11:53:52 +0200 Subject: [PATCH 39/50] docs: tighten the overview and name the enterprise gateway case Drop the overview's sections on reported facts and non-guarantees; the threat model, audit, and credentials chapters hold those limits. Shorten the deployment summary. Explain the two authorization servers through a gateway Mecatl does not operate, such as the connector gateway in the Stacklok Enterprise Platform, in the overview and the gateway modes chapter. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 21 +++---------------- .../agent-identity/mcp-gateway-modes.md | 2 +- docs/drafts/agent-identity/threat-model.md | 2 +- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 34ae433d52..8fcc5ca4d1 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -48,7 +48,7 @@ The **identity broker** establishes who is acting. It is the existing MCP broker Downstream, the **vMCP gateway** sends protected calls to MCP backends and providers. It has three parts: AS-2; storage for users' provider credentials, such as GitHub tokens; and the vMCP, ToolHive's virtual MCP server, which dispatches the calls. The gateway runs inside the Mecatl deployment (embedded mode) or outside it (external mode). -Mecatl uses two authorization servers because the gateway may belong to someone else. One server that both holds users' provider credentials and issues grants naming the user and the agent works when Mecatl runs the gateway, but not when another operator runs it and owns the credentials. AS-1 therefore issues the grants, and AS-2 holds the credentials and redeems the grants, so one protocol serves both modes. The two keep separate keys even in embedded mode, where the split is a protocol boundary, not a security boundary. +Mecatl uses two authorization servers so that it can work with an MCP gateway it does not operate, such as the connector gateway in the Stacklok Enterprise Platform. That gateway holds users' provider connections, such as their GitHub or Google tokens, under its own administration, and its vMCP accepts only access tokens from the gateway's own authorization server; Mecatl can neither mint those tokens nor hold the credentials behind them. So AS-1, Mecatl's server, issues a grant naming the user and the acting agent, and AS-2, the gateway's server, verifies the grant, decides whether to honor it, and issues its own access token. When Mecatl runs the gateway itself (embedded mode), it uses the same two servers and the same protocol, so there is one code path; there the split is a protocol boundary rather than a security boundary, although the two servers still keep separate keys. ```mermaid flowchart LR @@ -93,9 +93,6 @@ Alice asks her main agent to list pull requests and review PR 42. The main agent See [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) for the full walkthrough and [MCP gateway modes](mcp-gateway-modes.md) for the gateway side. -## What mecak8s reports - -AS-1 and the broker cannot verify the instance registration, the creation ancestry, the run, or the delegation: only mecak8s observes them, because the agents are goroutines in its memory, and mecak8s reports them. The broker records them as reported and checks them for consistency: the instance is registered to the session, its parent belongs to the same session tree, the run owner matches the grant in use, and a session's root is root-eligible. It also loads definition content and ceilings itself, so mecak8s cannot supply them. These checks catch mistakes, not a compromised mecak8s. Each agent has its own credential, but agents that share one mecak8s process are not isolated from each other. ## Deployment and threats @@ -103,27 +100,15 @@ The broker starts as modules inside the mecak8s process and later moves, as a wh Some protections hold in both deployments. Executor Pods get no broker or database credentials. A prompt-injected agent never sees or chooses credentials, because harness code attaches them and the model supplies only tool names and arguments. And correct mecak8s code cannot name a user who did not sign in at AS-1. -Against an attacker with code execution in mecak8s, the split is what matters. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is separate, the attacker holds no keys, can act only in open sessions bound to its workload, and every false report it makes must pass the broker's checks and is recorded where it cannot erase it. In both deployments, which agent acted, which instance created which, and the run and delegation context remain mecak8s's word: checked for consistency, not verified. +Against an attacker with code execution in mecak8s, the split is what matters. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is separate, the attacker holds no keys, can act only in open sessions bound to its workload, and every false report it makes must pass the broker's checks and is recorded where it cannot erase it. The [threat model](threat-model.md) has the full table, the facts mecak8s reports and how the broker checks them, and what the split requires to be real. -## What this design does not guarantee - -Precise limits matter as much as guarantees. This design does not provide: - -- **Isolation between agents in one process.** Per-agent credentials attribute calls; they do not stop code inside mecak8s from acting as another agent. -- **Verification of what mecak8s reports.** Which instance acted, which definition is a session's root, which instance created which child, and the run and delegation context stay mecak8s's word, marked as such in audit. -- **Protection against a compromised mecak8s while the broker runs inside it.** See the [threat model](threat-model.md#a-compromised-mecak8s). -- **Coverage beyond the broker.** Calls that bypass the broker, and filesystem tools, Shell, and spawning, are outside this design. -- **Non-repudiation.** Audit records are unsigned. -- **Proof of provider-side effects.** A record shows what the recording component observed; an unknown outcome stays unknown. -- **Instant downstream revocation.** ID-JAGs and vMCP access tokens already issued stay valid until they expire, so their lifetimes are short. - ## Standards The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. That key belongs to the broker, ideally in a key management service; ToolHive's authorization server also accepts a SPIFFE JWT-SVID as client authentication, which could later replace the key with the broker's own workload identity. -ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) and identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)), which external mode follows, are Internet-Drafts and may change. ID-JAG is scoped to parties in different trust domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. +The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. Agent credentials are SPIFFE JWT-SVIDs. Each protected call is authenticated at two layers, as the WIMSE architecture recommends: the platform workload, and the acting agent. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 9177eb20f0..3e0553dd28 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -22,7 +22,7 @@ The design works in two deployments: standalone Mecatl, with no Stacklok Enterpr ## Two authorization servers -Mecatl uses two authorization servers because the gateway may belong to someone else. One server that both holds users' provider credentials and issues grants naming the user and the agent works when Mecatl runs the gateway, but not when another operator runs it and owns the credentials. AS-1 therefore issues the grants, and AS-2, the gateway's own server, holds the credentials and redeems the grants, so one protocol serves both modes. +Mecatl uses two authorization servers so that it can work with an MCP gateway it does not operate, such as the connector gateway in the Stacklok Enterprise Platform. That gateway holds users' provider connections, such as their GitHub or Google tokens, under its own administration, and its vMCP accepts only access tokens from the gateway's own authorization server; Mecatl can neither mint those tokens nor hold the credentials behind them. So AS-1, Mecatl's server, issues a grant naming the user and the acting agent, and AS-2, the gateway's server, verifies the grant, decides whether to honor it, and issues its own access token. When Mecatl runs the gateway itself (embedded mode), it uses the same two servers and the same protocol, so there is one code path; there the split is a protocol boundary rather than a security boundary, although the two servers still keep separate keys. **AS-1** is Mecatl's login authorization server, running inside the broker. Alice signs in through AS-1, which redirects her to the company IdP as an ordinary OIDC client. Her client receives an AS-1 access token for the Mecatl API. At `OpenSession`, mecak8s passes that token to the broker once. The broker exchanges it at AS-1 with RFC 8693 for a session-bound broker refresh token, `RT-A42`. The [session-open step](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token) describes that exchange and the AS-1 record behind `RT-A42`. diff --git a/docs/drafts/agent-identity/threat-model.md b/docs/drafts/agent-identity/threat-model.md index a579ce094d..c5b0eaa394 100644 --- a/docs/drafts/agent-identity/threat-model.md +++ b/docs/drafts/agent-identity/threat-model.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status.* -This chapter lists the attackers the design considers, what each can do, and which protections depend on where the broker runs. The [overview](README.md#deployment-and-threats) summarizes it. Limits the design accepts are listed under [what this design does not guarantee](README.md#what-this-design-does-not-guarantee). +This chapter lists the attackers the design considers, what each can do, and which protections depend on where the broker runs. The [overview](README.md#deployment-and-threats) summarizes it. ## Deployment From e437f255089d572df4c36c0a37c2f7906cb0719a Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:16:49 +0200 Subject: [PATCH 40/50] docs: correct the credential flow and settle its open design gaps Bring the credentials walkthrough up to date: the diagram now shows the owner at session open, AS-1's acceptance checks, root eligibility at registration, the delegation report, the run owner on every call, the upstream re-check, and step (5). The per-call check list gains the run owner, and the ceiling check is marked as authority work. Settle gaps the review found: the broker refresh token does not rotate and is accepted only for ID-JAG exchanges; a retried session open is idempotent; the owner and session travel in authorization_details; the upstream re-check revokes only on invalid_grant; the SVID has one audience that AS-1 accepts; agent: identifiers are random; single-use ID-JAGs are Mecatl policy under RFC 7523; schedule authority is its own record; operation IDs travel in the MCP request _meta. Fix overstated claims about leaked tokens, AS-1's registry check, and attestation, in the chapter, the README, and agent-definitions. Also includes the pending agent-definitions rework: which glossary terms exist in the code today, how Origin affects ceilings, narrowing at registration, and why inline MCPServers entries are dropped in broker mode. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 4 +- .../agent-identity/agent-definitions.md | 66 +++++++----- docs/drafts/agent-identity/audit.md | 6 +- .../credentials-and-lifecycle.md | 101 +++++++++--------- docs/drafts/agent-identity/implementation.md | 6 +- .../agent-identity/mcp-authorization.md | 2 +- .../agent-identity/mcp-gateway-modes.md | 4 +- 7 files changed, 103 insertions(+), 86 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 8fcc5ca4d1..b15fe16903 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -42,7 +42,7 @@ mecak8s hosts the API and the harness. It validates the AS-1 access token of the The **identity broker** establishes who is acting. It is the existing MCP broker, extended, and one trusted component. Once the authority work is in place, it also bounds what each agent can reach. Its modules are: - The **definition loader** loads agent definitions from configured sources, records each one's source and revision, and gives the harness the execution configuration. A session's selected definitions stay fixed, so a later change at the source cannot silently replace them. The operator says which definitions may be a session's root, and the broker refuses to register a root instance outside that set. See [agent definitions](agent-definitions.md#loading-definitions). -- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in an agent trust domain separate from the platform's and from AS-1 and AS-2. It signs on mecak8s's report: in SPIRE terms, the broker is the attestor, and mecak8s's registration is its only evidence. +- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in an agent trust domain separate from the platform's and from AS-1 and AS-2. It signs on mecak8s's workload-authenticated registration, which is an assertion, not attestation: no evidence about the agent itself is produced or checked. - **AS-1**, Mecatl's login authorization server, signs users in through the company identity provider (IdP) and issues ID-JAGs (Identity Assertion JWT Authorization Grants). An ID-JAG is a short-lived, single-use signed grant that names the user and the acting agent. It is addressed only to AS-2, the gateway's authorization server, which redeems it for an access token, and only when the broker's client presents it. - The **authorization and audit** logic checks each protected MCP call and records what it decided and observed. @@ -110,7 +110,7 @@ The design composes existing OAuth and workload-identity standards rather than i The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. -Agent credentials are SPIFFE JWT-SVIDs. Each protected call is authenticated at two layers, as the WIMSE architecture recommends: the platform workload, and the acting agent. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. +Agent credentials are SPIFFE JWT-SVIDs. Each protected call is authenticated at two layers, as the WIMSE architecture describes: the platform workload, and the acting agent. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. ## Order of work diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 17afba2c47..0868f9b0a0 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -1,23 +1,23 @@ # Agent definitions -*This chapter describes the intended architecture. The `AgentDef` inventory below describes the existing code; the harness/broker split is proposed.* +*This chapter describes the intended architecture. The vocabulary table marks which terms exist in the code today. The `AgentDef` inventory describes the code. The harness/broker split is proposed.* ## Definitions and executing instances An agent definition describes how an agent should perform work. Mecatl represents it with `AgentDef`, a value containing metadata and instructions, rather than an executing loop. This chapter uses the following vocabulary: -| Term | Meaning | -| --- | --- | -| Definition | A kind of agent, identified with its source and admitted revision, not just its name. | -| Host session | The harness's persisted context for one agent instance, identified by a host session ID such as `hostS42`. A child instance has its own child session, linked to its parent's by `ParentSessionID`; a root session and its children form a session tree. | -| Broker session | The broker's session for one session tree, opened once for the root session, such as `A42`. Every instance in the tree registers to it. | -| Agent instance | One use of a selected definition, backed by its own host session, with its own identity and any creation-parent association. A delegated child has its own instance. | -| Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | -| Execution context | The workload, acting instance, broker session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the broker session. Run and delegation context remain mecak8s-reported. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | -| Agent-instance incarnation | The durable identity allocated by mecak8s for that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | -| Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | +| Term | Meaning | Exists today? | +| --- | --- | --- | +| Definition | A kind of agent, identified with its source and admitted revision, not just its name. | Yes, as `AgentDef`. It has no source or revision field. | +| Host session | The harness's persisted context for one agent instance, identified by a host session ID such as `hostS42`. A subagent child has its own child session. It links to its parent by `ParentSessionID`, `ParentIncarnation`, and the ID of the call that created it. A root session and its children form a session tree. | Yes. | +| Broker session | The broker's session for one session tree, opened once for the root session, such as `A42`. Every instance in the tree registers to it. | No. Today the in-process broker is keyed by the host session ID. | +| Agent instance | One use of a selected definition, backed by its own host session, with its own identity and any creation-parent association. A delegated child has its own instance. | No. Today a child is a child session, with no instance identity of its own. | +| Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | Yes, as `RunID`. | +| Execution context | The workload, acting instance, broker session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the broker session. Run and delegation context remain mecak8s-reported. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | Partly. Runs and the session owner exist. The workload, instance, broker session, and delegation parts are new. | +| Agent-instance incarnation | The durable identity of that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | Yes, as a session incarnation. Using it as the instance identity is new. | +| Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | Yes. | -The existing implementation persists a [session incarnation](../../../engine/session/incarnation.go) on each session aggregate, including child sessions. The proposal uses that durable incarnation for agent-instance identity; it is not the host session ID or a run ID. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). +The session aggregate mints an [incarnation](../../../engine/session/incarnation.go) when it creates a session, and persists it on every session, including child sessions. The design uses that durable incarnation for agent-instance identity. It is not the host session ID or a run ID. mecak8s persists the incarnation and reports it to the broker. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). ## Relationships and lifetimes @@ -42,9 +42,11 @@ flowchart LR R -.->|"delegates work to"| CR ``` -Here, the host session is the persisted context for one agent instance. A child has its own child session. The root session and its children form the session tree, which shares one broker session. Each child follows the same relationships shown for the parent instance, and can use a different selected definition. A root instance has no creation parent; a child has one creation-parent link. +Here, the host session is the persisted context for one agent instance. A child has its own child session. The root session and its children form the session tree, which shares one broker session. Each child follows the same relationships shown for the parent instance, and can use a different selected definition. A root instance has no creation parent; a child has one creation-parent link. Today the authority of a root session allows one delegation hop, so a child cannot create children of its own. The design allows deeper nesting, up to a depth limit that the broker enforces at registration. -Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in separate runs. The selected definition stays fixed for the instance. Pausing for approval and resuming continues the same run, including after restoration. +Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in separate runs. The selected definition stays fixed for the instance. A run that is paused for approval resumes as the same run. + +Today, each delegation creates a new child instance. The model can reuse an instance by resuming it with its agent ID. A resumed instance keeps its original definition, and its persisted authority must still fit inside the authority of the parent that resumes it. A turn that was in progress when the process exited is not continued. The session is repaired and returns to idle. | Event | Instance identity | Run | | --- | --- | --- | @@ -54,15 +56,17 @@ Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in | Pause and resume unfinished work | Keep the same instance. | Continue the existing run. | | Restore after a process restart | Recover the original instance, selected definition, and creation-parent link. | Continue a recoverable unfinished run, or fail closed; later work starts a new run. | -Creation ancestry and execution delegation describe different relationships. The creation-parent link records which instance created a child. A delegation links the run assigning work to the run performing it. The child's identity remains stable across its runs; each run supplies its own execution context. See [registering instances](#registering-instances). +Creation ancestry and execution delegation describe different relationships. The creation-parent link records which instance created a child. A delegation links the run assigning work to the run performing it. The child's identity remains stable across its runs; each run supplies its own execution context. + +The broker narrows authority along both links. At registration, it stores each instance's effective ceiling: the instance's definition ceiling intersected with its creation parent's effective ceiling. This is the upper bound for the instance's whole life, and it never widens. At each delegation, it records a delegation ceiling: the delegatee's effective ceiling intersected with the delegating run's ceiling. A call made under that delegation is checked against the delegation ceiling. See [registering instances](#registering-instances). The [domain model](../../architecture/mecatl.modelith.md) provides the broader execution model; this view focuses on agent identity and continuity. ## What AgentDef contains -The existing [`AgentDef`](../../../engine/tool/agentsource.go) combines several concerns: +[`AgentDef`](../../../engine/tool/agentsource.go) combines several concerns: -| Concern | Fields | Current purpose | +| Concern | Fields | Purpose | | --- | --- | --- | | Naming and routing | `Name`, `Description` | Identify a definition and help the model select it. | | Instructions and context | `Body`, `Skills`, `Memory` | Supply instructions, preloaded skills, and a memory-tier selection. | @@ -72,9 +76,11 @@ The existing [`AgentDef`](../../../engine/tool/agentsource.go) combines several | Presentation | `Color` | Supply a UI hint. | | Source tier | `Origin` | Record the admission tier: `explicit`, `project`, `user`, or `driver`. | -`Origin` is an observability label, not a source location or an authorization input. `AgentDef` has no definition-source identifier or revision field. +`Origin` is not a source location. `AgentDef` has no definition-source identifier or revision field. -Tool catalog filtering and authority enforcement are distinct in the existing code. Composition registers the tools selected for the specialist. It also derives a harness-side authority ceiling from resolved tools and MCP capabilities for managed, explicitly sourced definitions. Delegation intersects that ceiling with parent and candidate authority. The ceiling is derived separately, not stored as a field in `AgentDef`, and this path does not establish broker enforcement. +The harness does use `Origin` for one authorization decision. Only `explicit` definitions get a ceiling of their own, and a child created from one is labelled `explicit:`. A child from any other tier is not narrowed by its definition. It gets the intersection of its parent's authority and its candidate authority, and it keeps its parent's definition label. + +Tool catalog filtering and authority enforcement are separate in the code. Composition registers the tools selected for the specialist. For `explicit` definitions, it also derives a harness-side authority ceiling from the resolved tools and MCP capabilities. Delegation intersects that ceiling with the parent's authority and the candidate's authority. The ceiling is derived separately and is not a field of `AgentDef`. This path is not broker enforcement. ## Harness configuration and broker authority @@ -82,19 +88,25 @@ The proposed split keeps execution configuration in the harness and supplies the The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the agent's available tools. The broker needs an independently established ceiling on permitted MCP operations. A tool appearing in the harness catalog is not sufficient authorization to call it, and a model-supplied tool list cannot establish the broker's ceiling. -The broker ceiling is the set of permitted registered MCP server/tool pairs resolved under the existing `Tools`, `DisallowedTools`, and `MCPServers` semantics. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. At registration, the broker narrows it to each instance's [effective ceiling](mcp-authorization.md#delegated-authority). The existing definition-source snapshot contract is not, by itself, broker initialization. +The broker ceiling is the set of permitted registered MCP server/tool pairs, resolved from the definition's `Tools`, `DisallowedTools`, and `MCPServers`. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. At registration, the broker narrows it to each instance's [effective ceiling](mcp-authorization.md#delegated-authority). The harness's definition-source snapshot is not broker initialization. The broker loads its own records, as described under [loading definitions](#loading-definitions). + +Only definitions that the broker's operator loads get a ceiling of their own. Any other definition, such as a repository definition, registers with an effective ceiling equal to its creation parent's effective ceiling. It is neither widened nor narrowed. The loader must not treat a definition with no tier as one of the operator's definitions. + +In broker mode, a definition's inline `MCPServers` entry is not connected. An inline entry names its own server and carries one static credential for every user. It also bypasses the broker. The harness drops such an entry when it admits the definition, reports a diagnostic, and loads the rest of the definition. Every MCP call goes through the broker. Today an inline entry connects its own server directly. ## Loading definitions -The **definition loader** is a module of the identity broker. At broker startup it loads the global agent definitions from configured sources, such as administrator-managed files or APIs, and records each definition's source and revision in the broker. The operator's loader configuration also says which loaded definitions may serve as a session's root, the definition of its main agent, optionally per user or group. When mecak8s registers an instance with no parent, the broker refuses it if its definition is not root-eligible, and records the refusal. Once the [authority work](README.md#order-of-work) is in place, the loader also resolves and records each definition's ceiling, and the configuration caps the ceiling of definitions from other sources, so no agent can exceed the widest root-eligible ceiling. It also supplies the harness with execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too; when the broker becomes a separate service, the loader moves with it. How other definitions, such as repository definitions, are loaded and when is not yet decided. +The **definition loader** is a module of the identity broker. At broker startup it loads the global agent definitions from sources that the operator configures, such as administrator-managed files or APIs. It records each definition's source and revision in the broker. The operator's loader configuration also says which loaded definitions can be the root of a session, that is, the definition of its main agent. It can say this per user or group. When mecak8s registers an instance that has no parent, the broker refuses it if its definition is not on that list, and records the refusal. The loader also gives the harness the execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too. When the broker becomes a separate service, the loader moves with it. + +Once the [authority work](README.md#order-of-work) is in place, the loader also resolves and records each definition's ceiling. A definition from any other source, such as a repository, is not loaded by the broker. mecak8s names it by reference, and the broker accepts it only as a mecak8s report, marked as such in audit. It gets no ceiling of its own. It inherits the ceiling of the instance that creates it, so no agent can exceed the widest ceiling of a definition that can be a session's root. How the broker could load repository definitions itself is an open question. -Each session uses a fixed selection of loaded definitions. mecak8s's trusted session lifecycle code persists that selection and names definitions to the broker by reference; it does not supply their content or ceilings. +The harness keeps each session's fixed selection of definitions and persists it. Each registration names its definition to the broker by reference: source, name, and revision. For a definition that the broker loaded, the broker checks the reference against its own record and refuses a mismatch. mecak8s does not supply the content or the ceiling of such a definition. The broker is not given the session's whole selection. Naming any definition can only narrow authority, because an instance's effective ceiling is its definition ceiling intersected with its creation parent's. Today a session persists no definition selection, and the harness resolves definitions from the current source. -For Alice's session `hostS42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and `project/code-reviewer`. Selecting the reviewer definition does not create a reviewer instance. The particular child is allocated later when the harness creates it. +For Alice's session `hostS42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and the reviewer definition, `global/code-reviewer`. Here the operator loaded the reviewer definition, so it has a ceiling of its own. Selecting the reviewer definition does not create a reviewer instance. The harness creates the particular child later. -Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition, and the default anonymous child uses `system/explorer`; neither requires a definition file. Built-in definitions are registered and ceiling-resolved like any other, with the Mecatl release that ships them as their source and revision. `system/main` grants no name-based privilege: Alice's and Bob's default mains share the definition but not instances, users, or authority. A session started with a named definition, such as `project/code-reviewer`, registers that definition as its main instead. Whether the harness constructs the main loop from a real `AgentDef` or a built-in equivalent is an implementation choice. +Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition, and the default anonymous child uses `system/explorer`. Neither needs a definition file. Built-in definitions are registered and ceiling-resolved like any other. The Mecatl release that ships them is their source and revision. `system/main` grants no privilege by its name: Alice's and Bob's default mains share the definition but not instances, users, or authority. A session started with a named definition, such as `global/code-reviewer`, registers that definition as its main agent instead. The harness can build the main loop from a real `AgentDef` or from a built-in equivalent. This is an implementation choice. -Before the main loop starts, mecak8s allocates and persists the main's durable agent-instance incarnation, then authenticates to the broker with its workload ServiceAccount to register the main instance with no parent, naming its definition by reference. Broker initialization and registration must complete before execution; a suitable instance SVID can be obtained when needed, before protected calls. The selected definitions remain fixed for the session so that the harness and broker use the same selection. +Before the main loop starts, mecak8s persists the main's agent-instance incarnation. It then authenticates to the broker with its workload ServiceAccount and registers the main instance with no parent, naming its definition by reference. Broker initialization and registration must finish before execution. A suitable instance SVID can be obtained when needed, before protected calls. The selected definitions stay fixed for the session. ## Registering instances @@ -104,7 +116,7 @@ When the main creates the reviewer, mecak8s allocates and persists the child's i Creation ancestry records who created an instance. Execution delegation separately links parent run `R41`, reviewer run `R42`, and the bounds of delegation `D42`. Registration establishes identity records; it does not authorize spawning, run execution, or a tool call. The harness manages execution, and the broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). -The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The user side does not rest on mecak8s's reports: AS-1 witnessed Alice's sign-in and names her from the session's broker refresh token, but that does not prove run or delegation context. While the broker runs inside mecak8s, an attacker with code execution in that process also holds AS-1's keys; see [deployment and threats](README.md#deployment-and-threats). Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances; mecak8s is a trusted attestor, not an independently verified one. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. +The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The user side does not rest on mecak8s's reports: AS-1 witnessed Alice's sign-in and names her from the session's broker refresh token, but that does not prove run or delegation context. While the broker runs inside mecak8s, an attacker with code execution in that process also holds AS-1's keys; see [deployment and threats](README.md#deployment-and-threats). Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances. It signs on mecak8s's registration, which is an assertion, not attestation. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. ## Identity continuity across runs and restarts @@ -124,7 +136,7 @@ Identity continuity is separate from broker attachment recovery. See [pauses and ## Definition provenance -Each instance's registration carries a provenance record for the exact definition it uses. mecak8s's trusted resolver produces it from source resolution; the model never supplies it, and it is never reconstructed later from whatever is current at the source. +Each instance's registration carries a provenance record for the exact definition it uses. The broker's definition loader produces it from source resolution; the model never supplies it, and it is never reconstructed later from whatever is current at the source. The record has a versioned **profile** and profile-specific metadata. A Git profile might carry repository, path, and full commit; a Kubernetes profile the cluster, object UID, and observed revision; a service-backed profile a tenant namespace, object ID, and revision. Each profile defines its required fields and revision semantics. Operator configuration names the profiles and evidence it accepts; a source cannot weaken that by selecting a more permissive profile. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index 510c258c2b..cd2238be09 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -16,9 +16,9 @@ Records must distinguish verified facts from trusted reports. Qualify subjects b | Definition source, admitted revision, and ceilings | For global definitions, the broker's definition loader, which read them from configured sources at startup. How other definitions are loaded is not yet decided. | Records what the loader read and resolved. It does not show which loaded definition mecak8s selected for a session. | | Definition selection and instance ancestry | Workload-authenticated mecak8s lifecycle registration, validated and retained by the broker | A trusted lifecycle assertion, not a fact carried by the SVID or proof of same-process isolation. | | Initiating user and session ownership | AS-1 ran the sign-in through the company IdP and issued an access token for the Mecatl API. mecak8s records that it validated the token for its API audience and checked that the company identity in the token's `sub_id` owns the host session. The record keeps both the owner (company identity) and the AS-1 subject. | Establishes that AS-1 authenticated the user at sign-in and that the user currently owns the session. It does not establish fresh human action for each call, task-specific delegation, or reported run context. | -| Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | +| Session authority | AS-1 records the one-time conversion at `OpenSession`: client `mecatl` authenticated with `private_key_jwt`, user, the owner match, source access-token `jti`, broker session, and hard expiry | Shows that the broker held a valid AS-1 token for that user when the session opened. The session reference is broker-asserted. The conversion does not show that the user approved any later call. | | Run and execution-delegation links | mecak8s reports them in the workload-authenticated call request as part of the [execution context](mcp-authorization.md#authentication-and-execution-context) | Correlation is not proof that the broker enforced delegated resource bounds. Creation ancestry is separate. The delegation lives in Mecatl's harness and broker records only; the ID-JAG and `V` name the current actor, not the instance that delegated the task. | -| Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, and actor checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. An allow proves neither dispatch nor downstream success. | +| Ceiling evaluation and broker decision | Broker's evaluation of the registered actor's exact server/tool ceiling and route, together with its session, workload, actor, and run-owner checks | A Cedar permit does not establish user entitlement or connection authority; AS-2 and vMCP enforce those. An allow proves neither dispatch nor downstream success. | | ID-JAG issuance | AS-1 records each issuance: user from the broker refresh token's session record, session, validated actor, target AS-2, and ID-JAG `jti`. AS-1 validated the SVID and checked that the instance is registered to the broker session. | AS-1 does not see the user again per call; the user comes from the session record. Instance membership comes from the broker registry. | | AS-2 redemption and consent | AS-2 records each redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. In external mode, AS-2 also records creation and revocation of the consent link. | AS-2 trusts AS-1's signature and receives neither the AS-1 access token nor the SVID. In external mode the consent link is AS-2's own evidence that the user chose Mecatl; in embedded mode consent is implicit. | | vMCP decision | vMCP records its decision with the access-token reference `(iss, jti)`, native user, actor, tool, outcome, and the propagated operation ID | The gateway sees only the current actor in `act.sub`, not who delegated the task. The operation ID is reported, unverified trace context, as is other forwarded context unless independently verified. | @@ -72,7 +72,7 @@ The identity work needs these event families: The authority work adds Cedar evaluation status, policy IDs, and policy and ceiling revisions. Until then, decision events record that no ceiling was evaluated. -For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `agent:reviewer-gh56`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and a reference to the delegation event for `D42`. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `agent:reviewer-gh56` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. +For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `agent:7hq2x9`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and a reference to the delegation event for `D42`. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `agent:7hq2x9` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. These records have limits: diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index fe3d0533c2..346432ff93 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -21,7 +21,7 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that | **Client refresh token** | The client's ability to renew its own AS-1 access token. | AS-1 → client only. Never leaves the client. | | **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and issuer. It identifies neither Alice nor an agent instance. | | **Agent-instance JWT-SVID** | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | -| **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl` and to the session. | +| **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl`, the broker session, and the user. Accepted only as the subject of an ID-JAG exchange. | | **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | | **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | | **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. Cached per session, instance, gateway, and user. | @@ -69,7 +69,7 @@ sequenceDiagram Note over L,B: (0) Broker startup L->>L: Load global definitions from configured sources - L->>B: Definition records, sources, revisions, ceilings + L->>B: Definition records, sources, revisions, root eligibility
(ceilings with the authority work) Note over U,IdP: (1) Sign-in, once per login session U->>A1: Authorization code + PKCE
(issuer from mecak8s protected-resource metadata) @@ -77,28 +77,30 @@ sequenceDiagram IdP-->>A1: Alice authenticated A1-->>U: AS-1 access token (aud = Mecatl API) + client refresh token - Note over U,A1: (2)(3) Session open + Note over U,A1: (2)(3) Session open, on the session's first request U->>H: PR review request + AS-1 access token H->>H: Validate token, check ownership of hostS42 - H->>B: OpenSession: workload JWT + AS-1 access token (once) - B->>A1: RFC 8693: subject_token = AS-1 access token
requested_token_type = refresh_token
client mecatl (private_key_jwt) - A1-->>B: RT-A42 (bound to A42, hard maximum lifetime) + H->>B: OpenSession: workload JWT + AS-1 access token
+ owner of hostS42 (once) + B->>A1: RFC 8693: subject_token = AS-1 access token
requested_token_type = refresh_token
authorization_details: session A42, owner
(5) client mecatl, private_key_jwt + A1->>A1: Token issued by AS-1 for the Mecatl API, active, jti not yet converted
sub_id = reported owner; A42 unowned or owned by the same user + A1-->>B: RT-A42 (bound to mecatl, A42, Alice; hard expiry; no rotation) B-->>H: SessionSnapshot (BrokerSessionRef A42) rect rgb(245, 245, 245) Note over H,I: (4) Main bootstrap, before R41 H->>I: registerInstance(main, definition reference), workload JWT + I->>I: Definition loaded; no parent, so it must be root-eligible I-->>H: mainRef + canonical main subject H->>I: requestSVID(mainRef), workload JWT I-->>H: mainSVID end Note over H,G: (6) Main call: pull_request_list - H->>B: InvokeTool: workload JWT, A42, op-list-prs-001, agentJwtSvid = mainSVID - B->>B: Check session, workload, actor, effective ceiling, route + H->>B: InvokeTool: workload JWT, A42, run R41, run owner Alice,
op-list-prs-001, agentJwtSvid = mainSVID + B->>B: Check session, workload, actor, run owner = grant subject, route
(ceiling with the authority work) opt No cached V for (A42, main, gateway, Alice) B->>A1: (7) RFC 8693: subject_token = RT-A42
actor_token = mainSVID
requested_token_type = id-jag, audience = AS-2 - A1->>A1: Session open, hard expiry not passed
main registered to A42, build act + A1->>A1: RT-A42 bound to mecatl, session open, hard expiry not passed
upstream IdP re-checked if due
main registered to A42, build act (iss = AS-1) A1-->>B: J-main B->>A2: (8) RFC 7523: assertion = J-main, client mecatl A2->>A2: Map AS-1 subject to native user
External mode: require consent link @@ -114,13 +116,15 @@ sequenceDiagram rect rgb(245, 245, 245) Note over H,I: Reviewer bootstrap, after the list succeeds H->>H: Delegate review: D42 / R42 - H->>I: registerInstance(reviewer, parentRef = mainRef), workload JWT + H->>I: registerInstance(reviewer, definition reference project/code-reviewer,
parentRef = mainRef), workload JWT + H->>B: Report delegation D42: R41 to R42, run owner Alice + B->>B: Child run owner = parent run owner; parent in the same session tree I-->>H: reviewerRef H->>I: requestSVID(reviewerRef), workload JWT I-->>H: reviewerSVID end - H->>B: InvokeTool: A42, op-pr42-001, agentJwtSvid = reviewerSVID + H->>B: InvokeTool: A42, run R42, run owner Alice, op-pr42-001,
agentJwtSvid = reviewerSVID Note over B,G: Same checks and cache-miss path with actor_token = reviewerSVID
J-reviewer and V-reviewer: act = reviewer B-->>H: Sanitized outcome H-->>U: Review outcome @@ -155,7 +159,7 @@ The client receives an access token for the Mecatl API and keeps its own refresh } ``` -mecak8s validates it as a JWT access token (RFC 9068, including `typ`), checks `aud`, and checks that the company identity in `sub_id` owns `hostS42`. If either check fails, it rejects the request. An `id_token` is not accepted here: its audience is the client, not the API. +mecak8s validates it as a JWT access token (RFC 9068): `typ` is `at+jwt`, `iss` is AS-1, the signature verifies against AS-1's keys with an allowed algorithm, `exp` has not passed, `aud` is the Mecatl API, and `client_id` is the TUI or Studio. A failure returns 401 `invalid_token` (RFC 6750). It then checks that the company identity in `sub_id` owns `hostS42`; a failure there returns 403. An `id_token` is not accepted here: its audience is the client, not the API. Session ownership is keyed by the company identity `(https://login.example.com, alice-204)` that AS-1 carries in `sub_id`, not by AS-1's own `(iss, sub)`. Existing owners keep matching without migration, and the owner stays Alice's stable human identity. AS-1 must always set `sub_id`; mecak8s rejects an AS-1 token without it. The company IdP sees only AS-1 as the application: per-application IdP policies can no longer distinguish the TUI from Studio. @@ -180,15 +184,15 @@ Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s prese } ``` -The broker checks the signature against trusted cluster keys, the issuer, allowed algorithm, broker audience, and expiry, then checks that the ServiceAccount may contact it. This identifies the mecak8s service, not Alice or an agent instance. [SPIRE alternatives](#workload-authentication) replace this credential without changing its role. +The token is short-lived and has a broker-specific audience; tokens with the Kubernetes API server's audience are rejected. The broker validates it offline against the cluster's published keys: signature, issuer, allowed algorithm, broker audience, and expiry, then checks that the ServiceAccount may contact it. This proves the token was issued for that ServiceAccount, not that the presenter is that pod, and offline validation does not notice a deleted pod before `exp`; short lifetimes bound that. It identifies the mecak8s service, not Alice or an agent instance. [SPIRE alternatives](#workload-authentication) replace this credential without changing its role. ### (3) Session open and the broker refresh token At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, alongside workload authentication. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. -The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authenticating as client `mecatl`, and asks for a refresh token. AS-1 accepts only a token it issued for the Mecatl API that is still active, presented by `mecatl`, converted at most once, and asking for no wider scope than the original grant. The [implementation companion](implementation.md#as-1-grant-and-as-2-user-resolution) lists the exact checks. +The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authenticating as client `mecatl`, and asks for a refresh token. The request names the broker session `A42` and the owner mecak8s holds for `hostS42` in an RFC 9396 `authorization_details` entry of type `mecatl_session`, because RFC 8693 has no standard parameter for either. AS-1 accepts only a token it issued for the Mecatl API that is still active, presented by `mecatl`, converted at most once, and asking for no wider scope than the original grant. One conversion per `jti` stops a leaked access token from opening a second session. A retry of the same open, with the same `jti`, client, and broker session, returns the existing refresh token, so a lost response does not leave the session unopenable; clients refresh their access token before each new session open. The [implementation companion](implementation.md#as-1-grant-and-as-2-user-resolution) lists the exact checks. -AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, and to session `A42`, with a hard expiry that rotation does not extend. Every ID-JAG issued from it names that subject. +AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91` and her company identity as the session owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. AS-1 accepts `RT-A42` only as the subject token of an ID-JAG exchange; presented at the refresh-token grant, it returns `invalid_grant`, so the broker cannot mint Mecatl API tokens as Alice. `RT-A42` does not rotate: client authentication already binds it to the broker (RFC 9700 §4.14.2, RFC 9449 §5), and rotation would make concurrent exchanges from several broker replicas look like token reuse. The broker supplies `A42` when the record is created, so the session binding is broker-asserted. Only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. Two checks catch that kind of bug: @@ -199,19 +203,19 @@ A mismatch refuses the open and is recorded. Both checks compare facts inside Me **Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with RFC 9396 `authorization_details` naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs RFC 9396 support at AS-1, a session-bound token per session in the TUI and Studio, and a two-step session open. Neither check changes the ID-JAG or anything downstream of `RT-A42`. -**Later: run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still act during a run the user started; only a key held by the client and signing each run's start would prevent that. +**Later: run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still turn any fresh token the user sends into a run of its choosing, one run per token; only a key held by the client and signing each run's start would prevent that. The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, and the hard expiry end it, and AS-1's upstream re-check ends it if the company IdP disables her account; see [sign-out and revocation](#sign-out-and-revocation). The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. -**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, does not rotate, and is an extension to the ID-JAG profile. +**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, and is an extension to the ID-JAG profile. -**Evidence:** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, source token `jti`, session `A42`, and the hard expiry. +**Evidence:** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry, and records any refused open with its reason. ### (4) Agent-instance JWT-SVID -For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The agent-instance issuer constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). Registration records identity, not permission to spawn, run, or call tools. +For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The agent-instance issuer constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). A registration with no parent, including after a fork or clear, succeeds only for a root-eligible definition. Definition names that would need encoding in a SPIFFE path are rejected, and nesting depth is capped so a subject stays under the 2048-byte limit of SPIFFE-ID §2.3; registration past the cap is refused. Registration records identity, not permission to spawn, run, or call tools. The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. Mecatl's harness holds it outside model history and presents it to the broker for invocation. The broker forwards it to AS-1 as `actor_token`; AS-2 receives only the actor claim in the ID-JAG. The agent issuer retains its private signing key. This fictional decoded example deliberately uses only required JWT-SVID claims: @@ -223,16 +227,16 @@ The broker's agent issuer signs each registered instance's JWT-SVID. This payloa } ``` -Before issuance, the issuer checks the workload and reviewer instance against the registration; a definition's existence alone is insufficient. Mecatl checks that the returned subject is the registered reviewer instance. The broker and AS-1 each validate the SVID against the configured agent trust bundle. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and trust domain are distinct from OAuth AS-1 and AS-2; AS-1 uses the validated registered identity to form the ID-JAG's `act` claim. +Before issuance, the issuer checks the workload and reviewer instance against the registration; a definition's existence alone is insufficient. This is an assertion, not attestation: the issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. mecak8s checks that the returned subject is the registered reviewer instance. The SVID carries a single audience, the broker's, as JWT-SVID §7.2 recommends; AS-1 is configured to accept that value as its own SVID audience, because the broker presents the same SVID to both. The broker and AS-1 each validate the SVID against the configured agent trust bundle. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and trust domain are distinct from OAuth AS-1 and AS-2; AS-1 uses the validated registered identity to form the ID-JAG's `act` claim. See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. -The agent-instance JWT-SVID stays the actor input because it gives agent identity its own issuer and trust domain, separate from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. While agents share one harness process, it asserts the agent rather than authenticating it: the issuer is in the broker, the broker presents the SVID, and AS-1 still checks its registry that the instance belongs to the session. Two strengthenings are possible but not selected: +The agent-instance JWT-SVID stays the actor input because it gives agent identity its own SPIFFE trust domain name and signing key, so verifiers can tell it apart from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. While the broker runs inside mecak8s, the issuer shares a process, and therefore its security controls, with AS-1 and the broker. While agents share one harness process, the SVID asserts the agent rather than authenticating it: the issuer is in the broker, and the broker presents the SVID. AS-1 checks the broker's registry that the instance belongs to the session; that catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings are possible but not selected: - **Session claim.** The SVID names the session it was issued for, so AS-1 can check that the SVID and the subject token belong to the same session without relying on the broker's pairing. It is still issued on mecak8s's word. - **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession, so a stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. -Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the experimental [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. +Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. ### (5) Broker client authentication @@ -247,9 +251,9 @@ The subject tokens the broker presents are bearer tokens: they prove whose autho } ``` -`aud` is the authorization server's issuer identifier, following current guidance on client-assertion audiences. The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. +`aud` is the authorization server's issuer identifier as its only value, not its token-endpoint URL, as [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) requires. The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. -This makes "accept only from the broker" enforceable. A leaked Alice access token or a leaked `RT-A42` is useless without the key. A refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5). A `client_secret` is not used: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. +This makes "accept only from the broker" enforceable. A leaked `RT-A42` is useless without the key: a refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5). A leaked AS-1 access token cannot be converted directly either, but mecak8s converts the token it receives at session open, so a thief who presents a stolen token to the Mecatl API can open a session with it, once per token. A `client_secret` is rejected: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. @@ -267,21 +271,22 @@ Local permissions and hooks run before each call. For the reviewer's call, trust Before any exchange or dispatch, the broker checks: - the session `A42` is open and bound to the authenticated workload and `hostS42`; -- the SVID names an instance registered to `A42`; -- the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors; -- the route to the gateway. +- the SVID is unexpired and names an instance registered to `A42`; +- the reported run owner equals the subject of `RT-A42`'s record; a mismatch denies the call and is recorded, and the run owner never selects the grant; +- the route to the gateway; +- once the authority work is in place, the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors. A cache hit skips the token exchanges, never these checks. User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). -The broker does not check provider connections. It authorizes the session, workload, actor, ceiling, and route. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. +The broker does not check provider connections. It authorizes the session, workload, actor, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. -**Evidence:** `op-pr42-001` passed the session, workload, and actor checks, and, once the authority work is in place, the effective-ceiling check. This decision proves neither dispatch nor success. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. +**Evidence:** `op-pr42-001` passed the session, workload, actor, and run-owner checks, and, once the authority work is in place, the effective-ceiling check. This decision proves neither dispatch nor success. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. ### (7) ID-JAG from AS-1 On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with an RFC 8693 token exchange: `RT-A42` is the subject token, `reviewerSVID` the actor token, and AS-2 the audience. The broker authenticates as `mecatl`. -AS-1 resolves `RT-A42` to its record and checks that the session is open, the hard expiry has not passed, and the token is bound to `mecatl`. It validates the SVID and checks that the reviewer is registered to `A42`. It authorizes the combination of client, subject, actor, and target AS-2, then issues: +AS-1 resolves `RT-A42` to its record and checks that the session is open, the hard expiry has not passed, and the token is bound to `mecatl`. If the re-check interval has passed, it re-checks Alice's company-IdP grant first ([upstream re-check](#sign-out-and-revocation)). It validates the SVID and checks that the reviewer is registered to `A42`. ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); this registration check is that missing binding. AS-1 authorizes the combination of client, subject, actor, and target AS-2, then issues: ```json {"typ": "oauth-id-jag+jwt", "alg": "ES256", "kid": "as1-key-1"} @@ -290,29 +295,29 @@ AS-1 resolves `RT-A42` to its record and checks that the session is open, the ha "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 } ``` -- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name; `act.iss` names AS-1, which minted it. AS-1 derives it from the validated reviewer SVID; the full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. +- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name. `agent:` is a private URI scheme. AS-1 generates a random identifier for each instance and stores the mapping from SPIFFE ID; stability lets the vMCP bind a session to the instance and audit correlate its calls. `act.iss` names AS-1, which minted it, so the identifier stays attributable to AS-1 inside `V`, whose own `iss` is AS-2. The full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. - AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different trust domains; external mode fits it, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). -ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both drafts are Internet-Drafts, not RFCs. +ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. **Evidence:** AS-1 records the ID-JAG issuance: user, session `A42`, validated actor, target, and `jti`. ### (8) AS-2 access token -The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), authenticating again as `mecatl`. AS-2 checks the `typ`, that `aud` is its own issuer, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then resolves the native user: +The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), authenticating again as `mecatl`. AS-2 checks the `typ`, that `aud` is its own issuer, and that `client_id` matches the authenticated client (§4.4.1). It also rejects a reused `jti` (RFC 7523 §3) and keeps used identifiers until they expire. Single use is Mecatl policy, stricter than ID-JAG §4.4.3, which lets a client re-submit an ID-JAG. It then resolves the native user: | Mode | How AS-2 resolves and authorizes the user | | --- | --- | -| Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit; AS-1, AS-2, and the credential store share one trust domain. | -| External | Identity chaining across trust domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | +| Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit. AS-1 and AS-2 are distinct issuers with distinct keys, so no issuer redeems its own ID-JAG; embedded mode is still outside §9.3's cross-domain scope. | +| External | Identity chaining across trust domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` and client `mecatl` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). AS-2 never resolves users through `sub_id` (§9.5). @@ -323,7 +328,7 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): { "iss": "https://gateway.example.com", "sub": "usr-alice-7c1d", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, "client_id": "mecatl", "aud": "https://gateway.example.com/mcp", "iat": 1800000005, @@ -332,7 +337,7 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): } ``` -The broker caches `V-reviewer` for `(A42, reviewer, gateway, Alice)` until it expires, then obtains a new ID-JAG. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, so each agent instance has its own vMCP session. +The broker caches `V-reviewer` for `(A42, reviewer, gateway, Alice)`. It treats `V` as expired a small skew margin before `exp`; on a 401 `invalid_token` from the vMCP it drops `V` and retries the exchange once. A cache hit still needs a currently valid SVID for the same instance. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, and the broker stores each session ID under the instance, gateway, and user, so each agent instance has its own vMCP session per user. **Evidence:** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. @@ -344,13 +349,13 @@ If no credential is stored, vMCP answers "needs authorization" and the user conn **Evidence:** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, and outcome. Neither a successful MCP response nor an authorization record proves provider-side effects. -**Correlation:** a cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. To tie a gateway record to a single call, the broker must propagate the operation ID, here `op-pr42-001`, with each MCP request, and vMCP logs it as reported, unverified trace context. This propagation is required for correlation. Because `act` names only the reviewer, this operation ID is what lets an investigator reach delegation `D42` from a gateway log. +**Correlation:** a cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. To tie a gateway record to a single call, the broker propagates the operation and attempt IDs, here `op-pr42-001`, in each MCP request's `_meta`, and vMCP logs them as reported, unverified trace context. This propagation is required for correlation. Because `act` names only the reviewer, this operation ID is what lets an investigator reach delegation `D42` from a gateway log. ## User identity AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s authenticates the API request with the AS-1 token and takes the session owner from its `sub_id`, the company identity. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. -No mecak8s code path can name a user who did not sign in at AS-1. An attacker with code execution in mecak8s is another matter. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act for any user AS-1 knows. Once the broker is a separate service, the attacker can act only in open broker sessions bound to its workload and open new ones with fresh tokens from users currently using it. See [deployment and threats](README.md#deployment-and-threats). A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. +No mecak8s code path can name a user who did not sign in at AS-1. An attacker with code execution in mecak8s is another matter. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is a separate service, the attacker can act only in open broker sessions bound to its workload and open new ones with fresh tokens from users currently using it. See [deployment and threats](README.md#deployment-and-threats). A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. @@ -367,7 +372,7 @@ The following custody boundaries do not imply a selected persistence implementat | Workload credential | Platform-managed; mecak8s presents it to the issuer and broker. | Projected credential or configured SPIRE identity. Authenticates the workload, not a user or agent. | | Agent-instance JWT-SVID | Harness holds `mainSVID` / `reviewerSVID` outside model context; broker forwards it to AS-1 as `actor_token`. | Agent-instance issuer keeps its signing key. Renewal requires the same registered identity and does not widen authority. | | Broker session `A42` (state) | Broker owns the workload and host-session binding. | Recovered under workload and continuity checks. Possession of the reference grants nothing. | -| Broker refresh token `RT-A42` | Broker; presented only to AS-1. | Bound to `mecatl` and `A42`; rotates; hard expiry; revoked when the session closes. | +| Broker refresh token `RT-A42` | Broker; presented only to AS-1. | Bound to `mecatl`, `A42`, and Alice; does not rotate; hard expiry; revoked when the session closes. | | Broker private key | Broker; signs client assertions for AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | | ID-JAG | Broker, from issuance to redemption. | Single-use, short-lived. | | AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, gateway, and user; per-call checks still apply. No refresh token. | @@ -386,7 +391,7 @@ An agent instance keeps its identity across runs. These lifetimes are independen | Client refresh token | AS-1 policy and upstream sign-in status. | Sign in again. | | Workload JWT | Expired credentials are rejected. | Platform rotation for the same workload. | | `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the ceiling does not widen. | -| `RT-A42` | Hard expiry, session close, sign out everywhere, a failed upstream re-check, revocation, or reuse detection. Rotation does not extend the hard expiry. | A new session open with a fresh AS-1 access token. | +| `RT-A42` | Hard expiry, session close, sign out everywhere, a failed upstream re-check, or revocation. It does not rotate. | A new session open with a fresh AS-1 access token. | | ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | | `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | | Broker private key | Operator rotation. | New key published through `jwks_uri`. | @@ -396,7 +401,7 @@ An agent instance keeps its identity across runs. These lifetimes are independen The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The values of `RT-A42`'s hard expiry and of a schedule's authority horizon are not selected. They bound how long Mecatl acts without a fresh sign-in, and the company IdP administrator should know them. -Closing or revoking the session revokes `RT-A42` and invalidates the broker's `V` cache for that session. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. +Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. ## Sign-out and revocation @@ -411,7 +416,7 @@ Signing out of one client and stopping Mecatl's agents are different actions. **Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker revokes Alice's grants in those sessions at AS-1, cancels their in-flight tool calls, and drops her cached ID-JAGs and `V`; with one owner per session, that ends each broker session. Revocation is by user, so a [shared session](run-owner.md) would keep running for its other members. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. -**Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes that upstream token if it has not done so within the re-check interval. If the IdP refuses, because the account is disabled or its grants were revoked, AS-1 revokes every grant it holds for Alice, including schedule authority, and the exchange fails. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. +**Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes that upstream token if it has not done so within the re-check interval. AS-1 requests `offline_access` at sign-in so the IdP issues it a refresh token, and stores the newest one if the IdP rotates them. Only an `invalid_grant` from the IdP, meaning the account is disabled or its grants were revoked, makes AS-1 revoke every grant it holds for Alice, including schedule authority, and fail the exchange. A network error or a server error fails that exchange closed and revokes nothing. An upstream refresh token that merely reached its own maximum lifetime ends Alice's authority with a "needs owner action" outcome, not as a disabled account. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. **What remains after revocation.** Issued ID-JAGs and AS-2 access tokens cannot be revoked. The broker stops using them at once, but a `V` already issued stays valid at the gateway until it expires, and the vMCP does not check for revocation. Keep both lifetimes short. @@ -421,11 +426,11 @@ Signing out of one client and stopping Mecatl's agents are different actions. The broker's session reference and broker session incarnation are separate from the host session ID and [agent-instance incarnation](agent-definitions.md#definitions-and-executing-instances). An existing stable broker session can recover a new process-local attachment through continuity checks. Those references do not combine user, agent, definition, and authority into one record. -Workload-only session access checks the exact authenticated workload and configured deployment, and rejects workload-supplied owner assertions. A replacement Pod with the same workload identity can serve the existing partition; a different identity cannot assume access. +Workload-only session access checks the exact authenticated workload and configured deployment. It never takes the owner from a workload's assertion: an owner mecak8s reports is only compared with AS-1's record, and a mismatch refuses. A replacement Pod with the same workload identity can serve the existing partition; a different identity cannot assume access. An approval wait or provider-connection pause leaves the current run unfinished. Before dispatching the pending operation, the broker checks it against the applicable policy again. Connecting a provider account does not approve the pending operation. -After restart, recovering credentials is not enough to continue safely. Recovery must restore `A42` and its workload and host binding, find `RT-A42`'s AS-1 record still valid, and reestablish the registered actors and unfinished execution. If `RT-A42` has expired or been revoked, tool calls fail closed until a new session open supplies fresh authority. The harness decides whether execution resumes; the broker authorizes subsequent calls. +After restart, recovering credentials is not enough to continue safely. Recovery must restore `A42` and its workload and host binding, find `RT-A42`'s AS-1 record still valid, and reestablish the registered actors and the unfinished execution, including each run's owner. If `RT-A42` has expired or been revoked, tool calls fail closed until a new session open supplies fresh authority. The harness decides whether execution resumes; the broker authorizes subsequent calls. Under the [identity and selection continuity rules](agent-definitions.md#identity-continuity-across-runs-and-restarts), continuation retains the unfinished run's original bounds. With the authority work in place, the broker rechecks the stored effective ceiling; the harness retains its run and delegation restrictions. Credential renewal does not widen recovered authority. @@ -435,7 +440,7 @@ An operation that may have been dispatched but has no recorded completion has an A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, in the same one-time conversion as at session open, but bound to the schedule rather than a session. It has an explicit horizon. Deleting the schedule, signing out everywhere, and a failed upstream re-check revoke it. Each run draws on that authority. +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, in the same one-time conversion as at session open, but as a separate kind of record bound to the schedule rather than a session. A session cannot redeem a schedule's authority, and a schedule cannot redeem a session's refresh token. A scheduled run's owner is checked against the schedule's authority. It has an explicit horizon. Deleting the schedule, signing out everywhere, and a failed upstream re-check revoke it. Each run draws on that authority. When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. @@ -517,6 +522,6 @@ These IDs describe instance identity and creation ancestry. The initiating user ## Sender binding -`RT-A42`, the ID-JAG, and `V` are all presented only by the broker. `RT-A42` and the ID-JAG are usable only together with the broker's client authentication, so they are sender-constrained through `mecatl`'s private key. `V` remains a bearer token presented to vMCP; its signature protects the claims but does not prove the holder. DPoP-binding `V` to a broker key would close that gap; it is later work. +`RT-A42`, the ID-JAG, and `V` are all presented only by the broker. `RT-A42` and the ID-JAG are usable only together with the broker's client authentication, so they are sender-constrained through `mecatl`'s private key. The agent SVID is a bearer token too; its holder is limited only by the broker's workload-authentication and presenter checks, and at AS-1 by `mecatl`'s client authentication. `V` remains a bearer token presented to vMCP; its signature protects the claims but does not prove the holder. DPoP-binding `V` to a broker key would close that gap; it is later work. Binding a credential to a worker's key would not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent-instance identity and tool-call authorization remain separate checks. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index 53f5b88098..e1d246ad97 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -55,13 +55,13 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | --- | --- | --- | | Client | `mecatl` | Only the authenticated broker can use it. | | AS-1 `sub` | `u-alice-91` | Subject of every ID-JAG issued from it. | +| Owner | `(https://login.example.com, alice-204)` | The session owner, recorded at the first open; later opens for another user are refused. | | Broker session reference | `A42` | The session binding. | | Host session and incarnation | `hostS42` | Restart and recovery continuity. | | Source access-token `jti` | | One conversion per session. | | Issue time and hard expiry | | Authority horizon. | -| Rotation chain | | Reuse detection. | -The broker supplies the session reference. Rotation inherits the record and does not extend the hard expiry. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. +The broker supplies the session reference. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: @@ -116,7 +116,7 @@ Test the session-open exchange: - AS-1 rejects access tokens it did not issue, tokens for another audience, expired or revoked tokens, a second conversion of the same `jti`, and requests for wider scope. - AS-1 accepts a Mecatl-API-audience token only from client `mecatl`; another client presenting the same token is rejected. -- The refresh token record names the session; rotation keeps the hard expiry; reuse of a rotated token is detected; session close revokes it. +- The refresh token record names the session and its owner; session close revokes it; presenting it at the refresh-token grant returns `invalid_grant`; a retried open with the same `jti`, client, and session returns the existing refresh token. Test ID-JAG issuance: diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 1ba1242953..080fc3b602 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -86,7 +86,7 @@ For both calls, Alice's use of her GitHub account is checked downstream. AS-2 re ## Authorization, credentials, and dispatch -Local permissions and hooks apply before invocation. The broker then authenticates the workload and actor, checks session and execution context, evaluates the effective server/tool ceiling, and verifies the route. A failure stops the call before any exchange. If these checks pass, the broker uses a cached `V` or obtains one through AS-1 and AS-2, then dispatches to vMCP. +Local permissions and hooks apply before invocation. The broker then authenticates the workload and actor, checks session and execution context including the run owner, verifies the route, and, once the authority work is in place, evaluates the effective server/tool ceiling. A failure stops the call before any exchange. If these checks pass, the broker uses a cached `V` or obtains one through AS-1 and AS-2, then dispatches to vMCP. | Outcome | Dispatch behavior | | --- | --- | diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 3e0553dd28..de8a636ef4 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -122,7 +122,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 @@ -172,7 +172,7 @@ An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked "sub": "usr-alice-7c1d", "aud": "https://gateway.example.com/mcp", "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:reviewer-gh56"}, + "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, "iat": 1800000005, "exp": 1800000120, "jti": "access-pr42-001" From a383e11ef1798dfea0ccc8c6f863864193a8e0d2 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:20:58 +0200 Subject: [PATCH 41/50] docs: fix the credentials sequence diagram so it renders Mermaid treats a semicolon in a sequence-diagram message as a line break, so four messages added in the previous commit broke the diagram. Use commas instead, and send the delegation report after the reviewer's registration reply. Co-Authored-By: mecatl --- .../drafts/agent-identity/credentials-and-lifecycle.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 346432ff93..c97d0aec4b 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -82,14 +82,14 @@ sequenceDiagram H->>H: Validate token, check ownership of hostS42 H->>B: OpenSession: workload JWT + AS-1 access token
+ owner of hostS42 (once) B->>A1: RFC 8693: subject_token = AS-1 access token
requested_token_type = refresh_token
authorization_details: session A42, owner
(5) client mecatl, private_key_jwt - A1->>A1: Token issued by AS-1 for the Mecatl API, active, jti not yet converted
sub_id = reported owner; A42 unowned or owned by the same user - A1-->>B: RT-A42 (bound to mecatl, A42, Alice; hard expiry; no rotation) + A1->>A1: Token issued by AS-1 for the Mecatl API, active, jti not yet converted
sub_id = reported owner, A42 unowned or owned by the same user + A1-->>B: RT-A42 (bound to mecatl, A42, Alice, hard expiry, no rotation) B-->>H: SessionSnapshot (BrokerSessionRef A42) rect rgb(245, 245, 245) Note over H,I: (4) Main bootstrap, before R41 H->>I: registerInstance(main, definition reference), workload JWT - I->>I: Definition loaded; no parent, so it must be root-eligible + I->>I: Definition loaded, no parent, so it must be root-eligible I-->>H: mainRef + canonical main subject H->>I: requestSVID(mainRef), workload JWT I-->>H: mainSVID @@ -117,9 +117,9 @@ sequenceDiagram Note over H,I: Reviewer bootstrap, after the list succeeds H->>H: Delegate review: D42 / R42 H->>I: registerInstance(reviewer, definition reference project/code-reviewer,
parentRef = mainRef), workload JWT - H->>B: Report delegation D42: R41 to R42, run owner Alice - B->>B: Child run owner = parent run owner; parent in the same session tree I-->>H: reviewerRef + H->>B: Report delegation D42: R41 to R42, run owner Alice + B->>B: Child run owner = parent run owner, parent in the same session tree H->>I: requestSVID(reviewerRef), workload JWT I-->>H: reviewerSVID end From 2bc800d86fb11323361f45656beedf0d2fb7053e Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:32:38 +0200 Subject: [PATCH 42/50] docs: settle delegation, registration, and provenance in the agent definitions chapter - Mark which terms exist in the code today, and correct the claims about Origin, incarnations, child sessions, and instance reuse. - Narrow authority twice: at registration and at each delegation. The broker keeps a run record and checks it on every protected call. - List the broker's registration checks, the tier rule for definition ceilings, and how broker mode treats inline MCP servers. - Have the broker record provenance only for definitions it loaded itself. - Rewrite the chapter in short, plain sentences. Co-Authored-By: mecatl --- .../agent-identity/agent-definitions.md | 141 ++++++++++-------- 1 file changed, 82 insertions(+), 59 deletions(-) diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 0868f9b0a0..12da567bde 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -1,27 +1,27 @@ # Agent definitions -*This chapter describes the intended architecture. The vocabulary table marks which terms exist in the code today. The `AgentDef` inventory describes the code. The harness/broker split is proposed.* +*This chapter describes the intended architecture. The vocabulary table shows which terms exist in the code today. The `AgentDef` inventory describes the code. The split between the harness and the broker is a proposal.* ## Definitions and executing instances -An agent definition describes how an agent should perform work. Mecatl represents it with `AgentDef`, a value containing metadata and instructions, rather than an executing loop. This chapter uses the following vocabulary: +An agent definition describes how an agent performs work. Mecatl represents it with `AgentDef`. `AgentDef` is a value that holds metadata and instructions. It is not an executing loop. This chapter uses these terms: | Term | Meaning | Exists today? | | --- | --- | --- | -| Definition | A kind of agent, identified with its source and admitted revision, not just its name. | Yes, as `AgentDef`. It has no source or revision field. | -| Host session | The harness's persisted context for one agent instance, identified by a host session ID such as `hostS42`. A subagent child has its own child session. It links to its parent by `ParentSessionID`, `ParentIncarnation`, and the ID of the call that created it. A root session and its children form a session tree. | Yes. | -| Broker session | The broker's session for one session tree, opened once for the root session, such as `A42`. Every instance in the tree registers to it. | No. Today the in-process broker is keyed by the host session ID. | -| Agent instance | One use of a selected definition, backed by its own host session, with its own identity and any creation-parent association. A delegated child has its own instance. | No. Today a child is a child session, with no instance identity of its own. | -| Run | One execution by an instance, such as `R41` or `R42`. An instance can perform multiple runs. | Yes, as `RunID`. | -| Execution context | The workload, acting instance, broker session, initiating user, run, and any delegation a protected call is made under. The user comes from the broker refresh token AS-1 issued at session open, after Alice signed in through AS-1; AS-1 also checks that the acting instance is registered to the broker session. Run and delegation context remain mecak8s-reported. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | Partly. Runs and the session owner exist. The workload, instance, broker session, and delegation parts are new. | -| Agent-instance incarnation | The durable identity of that instance's lifetime, preserved across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | Yes, as a session incarnation. Using it as the instance identity is new. | -| Workload | The Mecatl service process hosting instances for many users and sessions, with its own infrastructure identity. | Yes. | +| Definition | A kind of agent. Its source and admitted revision identify it, not just its name. | Yes, as `AgentDef`. `AgentDef` has no source or revision field. | +| Host session | The harness's persisted context for one agent instance. A host session ID identifies it, such as `hostS42`. A subagent child has its own child session. The child session links to its parent by `ParentSessionID`, `ParentIncarnation`, and the ID of the call that created it. A root session and its children form a session tree. | Yes. | +| Broker session | The broker's session for one session tree, such as `A42`. The broker opens it once, for the root session. Every instance in the tree registers to it. | No. Today the in-process broker uses the host session ID as its key. | +| Agent instance | One use of a selected definition. Its own host session backs it. It has its own identity and, if it is a child, a creation-parent link. A delegated child has its own instance. | No. Today a child is a child session. It has no instance identity of its own. | +| Run | One execution by an instance, such as `R41` or `R42`. An instance can perform many runs. | Yes, as `RunID`. | +| Execution context | The context that a protected call is made under: the workload, the acting instance, the broker session, the initiating user, the run, and any delegation. The user comes from the broker refresh token that AS-1 issued at session open, after Alice signed in through AS-1. AS-1 also checks that the acting instance is registered to the broker session. mecak8s still reports the run and delegation context. See [authentication and execution context](mcp-authorization.md#authentication-and-execution-context). | Partly. Runs and the session owner exist. The workload, instance, broker session, and delegation parts are new. | +| Agent-instance incarnation | The durable identity of an instance for its whole lifetime. It stays the same across restoration and credential renewal. A fresh incarnation identifies a distinct instance. | Yes, as a session incarnation. Using it as the instance identity is new. | +| Workload | The Mecatl service process that hosts instances for many users and sessions. It has its own infrastructure identity. | Yes. | -The session aggregate mints an [incarnation](../../../engine/session/incarnation.go) when it creates a session, and persists it on every session, including child sessions. The design uses that durable incarnation for agent-instance identity. It is not the host session ID or a run ID. mecak8s persists the incarnation and reports it to the broker. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). +The session aggregate mints an [incarnation](../../../engine/session/incarnation.go) when it creates a session. It persists the incarnation on every session, including child sessions. The design uses that durable incarnation as the agent-instance identity. The incarnation is not the host session ID or a run ID. mecak8s persists the incarnation and reports it to the broker. The broker's own session incarnation and attachment binding are separate [recovery concepts](credentials-and-lifecycle.md#pauses-and-restart). ## Relationships and lifetimes -The broker's [definition loader](#loading-definitions) loads definitions up front. An agent definition can be reused by many agent instances. Each instance has its own identity and persisted context, and can perform several runs over its lifetime. +The broker's [definition loader](#loading-definitions) loads definitions up front. Many agent instances can reuse one agent definition. Each instance has its own identity and persisted context. It can perform several runs over its lifetime. ```mermaid flowchart LR @@ -42,25 +42,27 @@ flowchart LR R -.->|"delegates work to"| CR ``` -Here, the host session is the persisted context for one agent instance. A child has its own child session. The root session and its children form the session tree, which shares one broker session. Each child follows the same relationships shown for the parent instance, and can use a different selected definition. A root instance has no creation parent; a child has one creation-parent link. Today the authority of a root session allows one delegation hop, so a child cannot create children of its own. The design allows deeper nesting, up to a depth limit that the broker enforces at registration. +In the diagram, the host session is the persisted context for one agent instance. A child has its own child session. The root session and its children form the session tree. The tree shares one broker session. Each child follows the same relationships as the parent instance. A child can use a different selected definition. A root instance has no creation parent. A child has one creation-parent link. Today the authority of a root session allows one delegation hop, so a child cannot create children of its own. The design allows deeper nesting, up to a depth limit. The broker enforces the limit at registration. -Reviewing PR 42 and later reviewing PR 57 can use the same reviewer instance in separate runs. The selected definition stays fixed for the instance. A run that is paused for approval resumes as the same run. +The same reviewer instance can review PR 42 and later review PR 57, in separate runs. The selected definition stays fixed for the instance. A run that is paused for approval resumes as the same run. -Today, each delegation creates a new child instance. The model can reuse an instance by resuming it with its agent ID. A resumed instance keeps its original definition, and its persisted authority must still fit inside the authority of the parent that resumes it. A turn that was in progress when the process exited is not continued. The session is repaired and returns to idle. +Today, each delegation creates a new child session. The model can reuse a child session by resuming it with its agent ID. A resumed child session keeps its original definition. Its persisted authority must still fit inside the authority of the parent that resumes it. A turn that was in progress when the process exited is not continued. The harness repairs the session and returns it to idle. + +The table shows how the design handles each event. | Event | Instance identity | Run | | --- | --- | --- | | Start a new root session | Create a new main instance. | New work starts a run. | | Assign more work to an existing instance | Keep the same instance and selected definition. | Start another run. | -| Create a child agent | Create a distinct instance with its own Session and a creation-parent link. | The child's work has its own run. | +| Create a child agent | Create a distinct instance with its own host session and a creation-parent link. | The child's work has its own run. | | Pause and resume unfinished work | Keep the same instance. | Continue the existing run. | -| Restore after a process restart | Recover the original instance, selected definition, and creation-parent link. | Continue a recoverable unfinished run, or fail closed; later work starts a new run. | +| Restore after a process restart | Recover the original instance, selected definition, and creation-parent link. | Continue a recoverable unfinished run, or fail closed. Later work starts a new run. | -Creation ancestry and execution delegation describe different relationships. The creation-parent link records which instance created a child. A delegation links the run assigning work to the run performing it. The child's identity remains stable across its runs; each run supplies its own execution context. +Creation ancestry and execution delegation are different relationships. The creation-parent link records which instance created a child. A delegation links the run that assigns work to the run that performs it. The child's identity stays stable across its runs. Each run supplies its own execution context. -The broker narrows authority along both links. At registration, it stores each instance's effective ceiling: the instance's definition ceiling intersected with its creation parent's effective ceiling. This is the upper bound for the instance's whole life, and it never widens. At each delegation, it records a delegation ceiling: the delegatee's effective ceiling intersected with the delegating run's ceiling. A call made under that delegation is checked against the delegation ceiling. See [registering instances](#registering-instances). +The broker narrows authority along both links. At registration, it stores each instance's effective ceiling. The effective ceiling is the instance's definition ceiling intersected with the effective ceiling of its creation parent. It is the upper bound for the instance's whole life, and it never widens. At each delegation, the broker records a delegation ceiling. The delegation ceiling is the delegatee's effective ceiling intersected with the delegating run's ceiling. A root run's ceiling is its instance's effective ceiling. A child run's ceiling is its delegation ceiling. The broker checks a call made under that delegation against the delegation ceiling. See [registering instances](#registering-instances). -The [domain model](../../architecture/mecatl.modelith.md) provides the broader execution model; this view focuses on agent identity and continuity. +The [domain model](../../architecture/mecatl.modelith.md) gives the broader execution model. This chapter focuses on agent identity and continuity. ## What AgentDef contains @@ -68,89 +70,110 @@ The [domain model](../../architecture/mecatl.modelith.md) provides the broader e | Concern | Fields | Purpose | | --- | --- | --- | -| Naming and routing | `Name`, `Description` | Identify a definition and help the model select it. | +| Naming and routing | `Name`, `Description` | Identify a definition. Help the model select it. | | Instructions and context | `Body`, `Skills`, `Memory` | Supply instructions, preloaded skills, and a memory-tier selection. | -| Model selection | `Model`, `Provider` | Configure the specialist's model and provider. | -| Tool configuration | `Tools`, `DisallowedTools`, `MCPServers` | Filter the specialist's tool catalog and configure its MCP servers. | -| Execution controls | `PermissionMode`, `MaxTurns`, `MaxToolCalls`, `Hooks` | Supply a session permission-mode hint, execution budgets, and lifecycle hooks. | +| Model selection | `Model`, `Provider` | Set the model and the provider of the agent. | +| Tool configuration | `Tools`, `DisallowedTools`, `MCPServers` | Filter the tool catalog of the agent. Configure its MCP servers. | +| Execution controls | `PermissionMode`, `MaxTurns`, `MaxToolCalls`, `Hooks` | Supply a hint for the session permission mode, execution budgets, and lifecycle hooks. | | Presentation | `Color` | Supply a UI hint. | | Source tier | `Origin` | Record the admission tier: `explicit`, `project`, `user`, or `driver`. | -`Origin` is not a source location. `AgentDef` has no definition-source identifier or revision field. +`Origin` is not a source location. `AgentDef` has no field for a definition-source identifier or a revision. -The harness does use `Origin` for one authorization decision. Only `explicit` definitions get a ceiling of their own, and a child created from one is labelled `explicit:`. A child from any other tier is not narrowed by its definition. It gets the intersection of its parent's authority and its candidate authority, and it keeps its parent's definition label. +Today, the harness uses `Origin` for one authorization decision. Only `explicit` definitions get a ceiling of their own. A child created from an `explicit` definition is labelled `explicit:`. A child from any other tier is not narrowed by its definition. It gets the intersection of its parent's authority and its candidate authority. It keeps its parent's definition label. -Tool catalog filtering and authority enforcement are separate in the code. Composition registers the tools selected for the specialist. For `explicit` definitions, it also derives a harness-side authority ceiling from the resolved tools and MCP capabilities. Delegation intersects that ceiling with the parent's authority and the candidate's authority. The ceiling is derived separately and is not a field of `AgentDef`. This path is not broker enforcement. +In the code, tool catalog filtering and authority enforcement are separate. Composition registers the tools selected for the agent. For `explicit` definitions, composition also derives a harness-side authority ceiling from the resolved tools and MCP capabilities. Delegation intersects that ceiling with the parent's authority and the candidate's authority. The ceiling is derived separately. It is not a field of `AgentDef`. This path is not broker enforcement. ## Harness configuration and broker authority -The proposed split keeps execution configuration in the harness and supplies the broker with the authority ceiling needed for independent MCP authorization. Tool information serves both components for different reasons: the harness configures which tools are available, while the broker decides whether a requested MCP call is allowed. Turn and tool-call budgets remain harness concerns. +The proposed split keeps execution configuration in the harness. It gives the broker the authority ceiling that it needs for independent MCP authorization. Both components use tool information, for different reasons. The harness configures which tools are available. The broker decides whether a requested MCP call is allowed. Turn and tool-call budgets stay with the harness. -The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the agent's available tools. The broker needs an independently established ceiling on permitted MCP operations. A tool appearing in the harness catalog is not sufficient authorization to call it, and a model-supplied tool list cannot establish the broker's ceiling. +The harness uses `Tools`, `DisallowedTools`, and `MCPServers` to configure the tools that the agent can use. The broker needs a ceiling on permitted MCP operations that it establishes independently. A tool in the harness catalog is not enough authorization to call it. A model-supplied tool list cannot establish the broker's ceiling. -The broker ceiling is the set of permitted registered MCP server/tool pairs, resolved from the definition's `Tools`, `DisallowedTools`, and `MCPServers`. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling), tied to the definition source and revision. At registration, the broker narrows it to each instance's [effective ceiling](mcp-authorization.md#delegated-authority). The harness's definition-source snapshot is not broker initialization. The broker loads its own records, as described under [loading definitions](#loading-definitions). +The broker ceiling is the set of permitted registered MCP server/tool pairs. The broker resolves it from the definition's `Tools`, `DisallowedTools`, and `MCPServers`. That fixed admitted ceiling drives [deterministic Cedar policy generation](mcp-authorization.md#definition-derived-ceiling). The same definition source and revision always produce the same policy. At registration, the broker narrows the ceiling to each instance's [effective ceiling](mcp-authorization.md#delegated-authority). The harness's definition-source snapshot is not broker initialization. The broker loads its own records, as [loading definitions](#loading-definitions) describes. -Only definitions that the broker's operator loads get a ceiling of their own. Any other definition, such as a repository definition, registers with an effective ceiling equal to its creation parent's effective ceiling. It is neither widened nor narrowed. The loader must not treat a definition with no tier as one of the operator's definitions. +In this design, only definitions that the broker's operator loads get a ceiling of their own. Any other definition, such as a repository definition, registers with an effective ceiling equal to the effective ceiling of its creation parent. The broker neither widens nor narrows it. The loader must not treat a definition with no tier as one of the operator's definitions. -In broker mode, a definition's inline `MCPServers` entry is not connected. An inline entry names its own server and carries one static credential for every user. It also bypasses the broker. The harness drops such an entry when it admits the definition, reports a diagnostic, and loads the rest of the definition. Every MCP call goes through the broker. Today an inline entry connects its own server directly. +In broker mode, the harness does not connect an inline `MCPServers` entry in a definition. An inline entry names its own server and carries one static credential for every user. It also bypasses the broker. The harness drops such an entry when it admits the definition. It reports a diagnostic and loads the rest of the definition. Every MCP call goes through the broker. Today an inline entry connects its own server directly. ## Loading definitions -The **definition loader** is a module of the identity broker. At broker startup it loads the global agent definitions from sources that the operator configures, such as administrator-managed files or APIs. It records each definition's source and revision in the broker. The operator's loader configuration also says which loaded definitions can be the root of a session, that is, the definition of its main agent. It can say this per user or group. When mecak8s registers an instance that has no parent, the broker refuses it if its definition is not on that list, and records the refusal. The loader also gives the harness the execution configuration. While the broker runs as modules inside mecak8s, the loader runs there too. When the broker becomes a separate service, the loader moves with it. +The **definition loader** is a module of the identity broker. At broker startup, it loads the global agent definitions from sources that the operator configures, such as administrator-managed files or APIs. It records each definition's source and revision in the broker. The operator's loader configuration also says which loaded definitions can be the root of a session. The root of a session is the definition of its main agent. The configuration can set this per user or group. When mecak8s registers an instance that has no parent, the broker checks its definition against this list. If the definition is not on the list, the broker refuses the registration and records the refusal. The loader also gives the execution configuration to the harness. While the broker runs as modules inside mecak8s, the loader runs there too. When the broker becomes a separate service, the loader moves with it. -Once the [authority work](README.md#order-of-work) is in place, the loader also resolves and records each definition's ceiling. A definition from any other source, such as a repository, is not loaded by the broker. mecak8s names it by reference, and the broker accepts it only as a mecak8s report, marked as such in audit. It gets no ceiling of its own. It inherits the ceiling of the instance that creates it, so no agent can exceed the widest ceiling of a definition that can be a session's root. How the broker could load repository definitions itself is an open question. +Once the [authority work](README.md#order-of-work) is in place, the loader also resolves and records each definition's ceiling. The broker does not load a definition from any other source, such as a repository. mecak8s names that definition by reference. The broker accepts it only as a mecak8s report and marks it as such in audit. It gets no ceiling of its own. It inherits the ceiling of the instance that creates it. So no agent can exceed the widest ceiling of a definition that can be a session's root. How the broker could load repository definitions itself is an open question. -The harness keeps each session's fixed selection of definitions and persists it. Each registration names its definition to the broker by reference: source, name, and revision. For a definition that the broker loaded, the broker checks the reference against its own record and refuses a mismatch. mecak8s does not supply the content or the ceiling of such a definition. The broker is not given the session's whole selection. Naming any definition can only narrow authority, because an instance's effective ceiling is its definition ceiling intersected with its creation parent's. Today a session persists no definition selection, and the harness resolves definitions from the current source. +The harness keeps each session's fixed selection of definitions and persists it. Each registration names its definition to the broker by reference: source, name, and revision. For a definition that the broker loaded, the broker checks the reference against its own record. It refuses a mismatch. mecak8s does not supply the content or the ceiling of such a definition. The broker does not receive the session's whole selection. Naming any definition can only narrow authority. The reason is that an instance's effective ceiling is its definition ceiling intersected with the effective ceiling of its creation parent. Today a session persists no definition selection. The harness resolves definitions from the current source. -For Alice's session `hostS42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and the reviewer definition, `global/code-reviewer`. Here the operator loaded the reviewer definition, so it has a ceiling of its own. Selecting the reviewer definition does not create a reviewer instance. The harness creates the particular child later. +For Alice's session `hostS42` in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the selection includes the main definition and the reviewer definition, `global/code-reviewer`. The operator loaded the reviewer definition, so it has a ceiling of its own. Selecting the reviewer definition does not create a reviewer instance. The harness creates that particular child later. -Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition, and the default anonymous child uses `system/explorer`. Neither needs a definition file. Built-in definitions are registered and ceiling-resolved like any other. The Mecatl release that ships them is their source and revision. `system/main` grants no privilege by its name: Alice's and Bob's default mains share the definition but not instances, users, or authority. A session started with a named definition, such as `global/code-reviewer`, registers that definition as its main agent instead. The harness can build the main loop from a real `AgentDef` or from a built-in equivalent. This is an implementation choice. +Today the root session has no `AgentDef`. In this design, a session started without a named definition uses the built-in `system/main` definition. The default anonymous child uses `system/explorer`. Neither needs a definition file. Built-in definitions follow the same registration and ceiling resolution as any other definition. The Mecatl release that ships them is their source and revision. `system/main` grants no privilege by its name. Alice's and Bob's default mains share the definition. They do not share instances, users, or authority. A session started with a named definition, such as `global/code-reviewer`, registers that definition as its main agent instead. The harness can build the main loop from a real `AgentDef` or from a built-in equivalent. This is an implementation choice. -Before the main loop starts, mecak8s persists the main's agent-instance incarnation. It then authenticates to the broker with its workload ServiceAccount and registers the main instance with no parent, naming its definition by reference. Broker initialization and registration must finish before execution. A suitable instance SVID can be obtained when needed, before protected calls. The selected definitions stay fixed for the session. +Before the main loop starts, mecak8s persists the main's agent-instance incarnation. Then mecak8s authenticates to the broker with its workload ServiceAccount. It registers the main instance with no parent and names its definition by reference. Broker initialization and registration must finish before execution. A suitable instance SVID can be obtained when needed, before protected calls. The selected definitions stay fixed for the session. ## Registering instances -The broker validates the workload-authenticated registration records and constructs the canonical instance subject. It checks that the workload may act for the session and instance and associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any, and stores the effective ceiling computed from them. mecak8s allocates incarnations; the broker does not. A known definition or loop-supplied name alone is insufficient for issuance. +The broker's agent-instance issuer validates the registration records that the workload authenticated. It then constructs the canonical instance subject. The issuer makes these checks: + +- The workload can act for the session and the instance. +- If there is a parent, the parent is registered to the same broker session. +- A registration with no parent names a definition that can be a session's root. +- For a definition that the broker loaded, the reference matches the broker's record. +- The subject follows the SPIFFE rules. Each segment uses only `[a-zA-Z0-9._-]`. The whole ID is at most 2048 bytes. The nesting is within the depth limit. A registration that breaks a rule fails closed. + +If the checks pass, the issuer associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any. It stores the effective ceiling that it computes from them. The session aggregate mints the incarnation. mecak8s persists it and reports it. The broker does not allocate incarnations. A known definition, or a name that the loop supplies, is not enough for issuance. + +When the main creates the reviewer, the harness creates the child session, and mecak8s persists the child's incarnation. mecak8s then registers that particular child. The registration carries the persisted incarnation, the reference to the reviewer definition, and the reference to the registered main instance. The issuer validates these records and constructs the child subject. This is a lifecycle assertion that mecak8s's workload credential authenticates. It is not proof derived from possession of the parent's SVID. -When the main creates the reviewer, mecak8s allocates and persists the child's incarnation. It registers that particular child using the persisted incarnation, the reviewer definition already registered from the session selection, and the registered main-instance reference. The broker validates these records and constructs the child subject. This is a lifecycle assertion authenticated by mecak8s's workload credential, not proof derived from possession of the parent's SVID. +Registration establishes identity records. It does not authorize spawning, run execution, or a tool call. The harness manages execution. The broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). Creation ancestry and delegation are different links. See [relationships and lifetimes](#relationships-and-lifetimes). -Creation ancestry records who created an instance. Execution delegation separately links parent run `R41`, reviewer run `R42`, and the bounds of delegation `D42`. Registration establishes identity records; it does not authorize spawning, run execution, or a tool call. The harness manages execution, and the broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). +The broker also keeps a run record for each run that it accepts. A root run starts when a user speaks to the main agent or a schedule fires. The owner of the run is the subject of the grant that the run uses. A child run starts when mecak8s reports a delegation and the broker accepts it. The broker copies the child run's owner from the delegating run's record, not from the report. It stores the delegation ceiling in the child run's record. A run record ends in any of these cases: the task ends, the parent run ends, an instance is revoked, the session closes, or the lease expires. A lost run record denies calls. -The broker explicitly trusts mecak8s's workload-authenticated lifecycle assertions. These responsibilities are outside the untrusted loop, but a ServiceAccount cannot distinguish trusted lifecycle code from compromised code in the same process. This design does not establish same-process isolation. The user side does not rest on mecak8s's reports: AS-1 witnessed Alice's sign-in and names her from the session's broker refresh token, but that does not prove run or delegation context. While the broker runs inside mecak8s, an attacker with code execution in that process also holds AS-1's keys; see [deployment and threats](README.md#deployment-and-threats). Unlike a SPIRE agent, which inspects a workload before issuing it an identity, the broker cannot observe mecak8s's instances. It signs on mecak8s's registration, which is an assertion, not attestation. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. +Every protected call must name a run that the broker accepted. The broker denies the call unless all of these are true: + +- The run is open. +- The run belongs to the session. +- The run is bound to the instance in the presented credential. +- The subject of the grant in use owns the run. + +The broker trusts the lifecycle assertions that mecak8s sends with its workload credential. mecak8s is the registrant. A ServiceAccount cannot tell trusted lifecycle code from compromised code in the same process. This design does not isolate them. The user does not come from a mecak8s report. AS-1 saw Alice sign in. See [deployment and threats](README.md#deployment-and-threats) for what a compromised mecak8s can do. A SPIRE agent inspects a workload before it issues an identity to it. The broker cannot observe mecak8s's instances in this way. It signs on mecak8s's registration. The registration is an assertion, not attestation. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. ## Identity continuity across runs and restarts -An instance can perform several runs without changing identity. Its fixed definition selection provides continuity, while each call remains subject to applicable current user permissions and broker policy. +An instance keeps its identity across runs and restarts. Its fixed definition reference provides continuity. Each call is still checked against the user's current permissions and the broker's policy. + +The table shows how the design handles each event. Today, a turn that was in progress at process exit is not continued. See [relationships and lifetimes](#relationships-and-lifetimes). -| Event | What remains fixed or must be checked | +| Event | What stays fixed or must be checked | | --- | --- | -| New run on the same instance | The incarnation and selected definition stay fixed. The new run has its own execution context and applicable bounds; the instance credential alone does not authorize it. | -| Definition source edited | The session's pinned source and revision selection does not change. Restoration must not substitute whatever is current at the source. | -| Process restart or session restoration | Restore the original incarnation, definition selection, parent association, stored effective ceiling, and harness delegation bounds. Recover the unfinished execution context and its user/actor/connection association or fail closed. | -| Instance credential expires | Renew for the registered identity after the required checks. Renewal grants no new authority and does not widen recovered bounds. | -| Fresh incarnation | Register a distinct identity. Reusing a session or definition name does not inherit the old instance's authority. | +| New run on the same instance | The incarnation and the definition stay fixed. The new run has its own run record. The instance credential alone does not authorize it. | +| Definition source edited | The instance keeps the revision that it registered with. Restoration never uses the revision that is current at the source. The broker keeps the stored ceiling and the revision record, not the source bytes. If the harness cannot rebuild the registered revision, the instance fails closed and does not resume. The user starts a new instance on the current revision. | +| Process restart or session restoration | Restore the original incarnation, definition reference, creation parent, stored effective ceiling, and run records with their delegation ceilings. Recover the unfinished execution context and its user, actor, and connection association, or fail closed. A run record that cannot be restored denies calls. | +| Instance credential expires | Renew the credential for the registered identity after the required checks. Renewal does not widen authority. | +| Fork or clear | Register a distinct identity. A fork or a clear creates a new root instance. | +| Fresh incarnation | Register a distinct identity. Reusing a session name or a definition name does not inherit the old instance's authority. | +| Authority work switched on | The broker computes a ceiling once for each live instance, parents before children. It marks the ceiling as backfilled. Until then, an instance with no ceiling is denied every MCP call. An instance whose definition or parent cannot be resolved gets a ceiling that denies every MCP call. That instance must register again. | -Continuation preserves the unfinished run's original bounds. Current restrictions or withdrawal can constrain it; credential renewal or policy expansion must not automatically widen recovered authority. If the original identity or selection cannot be recovered, restoration must fail closed rather than silently substitute another instance or definition. +A continued run keeps its original bounds. A new restriction can narrow them. So can the withdrawal of a permission. Credential renewal and policy changes must not widen recovered authority. If the original identity or definition cannot be recovered, restoration fails closed. It never substitutes another instance or definition. Identity continuity is separate from broker attachment recovery. See [pauses and restart](credentials-and-lifecycle.md#pauses-and-restart) for attachment checks, credential and execution recovery, and unknown operation outcomes. ## Definition provenance -Each instance's registration carries a provenance record for the exact definition it uses. The broker's definition loader produces it from source resolution; the model never supplies it, and it is never reconstructed later from whatever is current at the source. +The registration of each instance refers to a provenance record for the exact definition that the instance uses. For a definition that the broker loaded, the broker's loader produces the record from source resolution. The model never supplies the record. The broker never rebuilds it later from the current state of the source. -The record has a versioned **profile** and profile-specific metadata. A Git profile might carry repository, path, and full commit; a Kubernetes profile the cluster, object UID, and observed revision; a service-backed profile a tenant namespace, object ID, and revision. Each profile defines its required fields and revision semantics. Operator configuration names the profiles and evidence it accepts; a source cannot weaken that by selecting a more permissive profile. +The record has a versioned **profile** and profile-specific metadata. A Git profile can carry the repository, the path, and the full commit. A Kubernetes profile can carry the cluster, the object UID, and the observed revision. A service-backed profile can carry a tenant namespace, an object ID, and a revision. Each profile defines its required fields and its revision rules. The operator's configuration names the profiles and the evidence that it accepts. A source cannot weaken this by selecting a more permissive profile. -The record carries two digests: +The broker computes two digests: -- **Source content digest:** the bytes read from the source. -- **Accepted definition digest:** the normalized definition mecak8s admitted and constructs the agent from. +- **Source content digest:** The bytes that the loader read from the source. +- **Accepted definition digest:** The normalized definition that the broker admitted and that the harness builds the agent from. -Parsing and normalization mean these differ. A commit plus a digest does not prove the bytes came from that commit; a locally modified file is the obvious counterexample. +Parsing and normalization make these two digests differ. A commit plus a digest does not prove that the bytes came from that commit. For example, someone can modify the file locally. The digest then matches the modified bytes, while the commit names the original. -The broker records provenance as a mecak8s assertion unless it independently verified a fact, and audit marks which. "mecak8s reports loading these bytes from commit X" is distinct from "the broker verified these bytes against commit X"; the design requires only the former for now. Provenance lives in the broker's per-instance registration record, not in instance credentials. It grants no permission by itself; later policy may require approved revisions or particular source authorities. +The broker verifies provenance only for definitions that it loaded itself. For any other definition, mecak8s reports the provenance. The broker stores it as a mecak8s report. Audit marks each fact as verified or reported. "mecak8s reports loading these bytes from commit X" is not the same as "the broker verified these bytes against commit X". Provenance lives in the broker's per-instance registration record, not in instance credentials. It grants no permission by itself. Later policy can require approved revisions or particular source authorities. ## Source records and policy representation -The proposal adopts workload-authenticated mecak8s lifecycle assertions, host-allocated durable incarnations, and broker validation and subject construction as its semantic mechanism. The exact registration wire API, durable record schema, and verified per-operation binding for reported context remain unresolved, as does protection from same-process substitution. These are proposed semantics, not implemented contracts. +The design uses three mechanisms. mecak8s sends lifecycle assertions that its workload credential authenticates. The session aggregate mints durable incarnations. mecak8s persists them and reports them. The broker validates the assertions and constructs the subject. These are proposed semantics, not implemented contracts. The [open questions](README.md#open-questions) list what is not settled. -Cedar is selected for the effective server/tool ceiling. The representation of definition-source and revision records, registration API, and Cedar policies remains an implementation choice. The authority work generates policy only from resolved definition facts; separately managed user, resource, argument, and dynamic delegation policies are deferred. Existing user/connection authorization checks remain required. +Cedar is the policy engine for the effective server/tool ceiling. How the broker stores definition-source and revision records, how registration works, and how the Cedar policies are laid out are implementation choices. The authority work generates policy only from resolved definition facts. The design defers separate policies for users, resources, arguments, and dynamic delegation. The user and connection checks at AS-2 and the vMCP remain required. From 5aabd951559c1bb0b24de40daf9ad43aaf2a430c Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:33:22 +0200 Subject: [PATCH 43/50] docs: give each credential hop its exact checks and failures Rewrite the credential walkthrough so each hop states its input, the checks in the order they run, what it issues, the error code for each failure and whether a retry can succeed, why the step exists, what it does not prove, and what it records. Add the wire bodies for session open, ID-JAG issuance, and AS-2 redemption, the exact JWT-SVID and ServiceAccount validation rules, a failure matrix, and renewal triggers. Apply the round-two review decisions: the upstream re-check distinguishes revocation from an expired upstream token by its recorded expiry and refreshes single-flight per user; a retried session open replaces the first refresh token in one transaction; the session and owner travel in a mecatl_session authorization_details entry that AS-1 echoes; schedules use mecatl_schedule; act carries a plain ag_ identifier; the run owner is compared with the grant's recorded owner; a lost redemption re-mints the ID-JAG; and the OAuth sense of trust domain becomes administrative domain. Bring the other chapters in line: single-use jti cites RFC 7523, operation IDs travel in _meta, the AS-1 check lists include the re-check, and the ToolHive ledger and tests cover the new behavior. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 4 +- docs/drafts/agent-identity/audit.md | 12 +- .../credentials-and-lifecycle.md | 436 +++++++++++++++--- docs/drafts/agent-identity/implementation.md | 54 ++- .../agent-identity/mcp-authorization.md | 6 +- .../agent-identity/mcp-gateway-modes.md | 29 +- docs/drafts/agent-identity/run-owner.md | 2 +- 7 files changed, 430 insertions(+), 113 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index b15fe16903..9073905c66 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -108,9 +108,9 @@ The [threat model](threat-model.md) has the full table, the facts mecak8s report The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. That key belongs to the broker, ideally in a key management service; ToolHive's authorization server also accepts a SPIFFE JWT-SVID as client authentication, which could later replace the key with the broker's own workload identity. -The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. +The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different administrative domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. -Agent credentials are SPIFFE JWT-SVIDs. Each protected call is authenticated at two layers, as the WIMSE architecture describes: the platform workload, and the acting agent. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. +Agent credentials are SPIFFE JWT-SVIDs. Each protected call carries two identities, as the WIMSE architecture describes: the platform workload is authenticated, and the acting agent is asserted by the broker's issuer. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. ## Order of work diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index cd2238be09..c47ba71d08 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -35,7 +35,7 @@ Every component records evidence as a [toolhive-core](https://github.com/stacklo | Which event | `Metadata.AuditID` | Minted once and stored before the first send. A re-send reuses it, and ingestion removes duplicates by it. | | What kind of event | `Type` | A `mecatl_`-prefixed snake_case type, such as `mecatl_tool_decision`. The vMCP keeps ToolHive's own types, such as `mcp_tool_call`. Whether an event is a decision, a dispatch, or an observed outcome is part of its type. | | Result | `Outcome` | toolhive-core's `success`, `failure`, `error`, and `denied`, plus `unknown` once #341 defines it. Each type uses a fixed subset; see the next table. | -| Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `agent:` identifier), and `agent_instance_ref`. `run_owner_iss` and `run_owner_id`: the [run owner](run-owner.md), reported by mecak8s. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | +| Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `ag_` identifier in `act`), and `agent_instance_ref`. `run_owner_iss` and `run_owner_id`: the [run owner](run-owner.md), reported by mecak8s. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | | Verified or reported | `Metadata.Extra["mecatl.provenance"]` | A map keyed by the `Subjects` key: `verified`, `credential-verified` when the recorder verified a credential but mecak8s reported which instance it belongs to, `record:` when taken from a retained record, `reported:`, or `unavailable`. A missing fact is marked `unavailable`, never left out silently. | | Where it originated | `Source` | The network origin, as in ToolHive: the peer address, plus `request_id` and `session_id` for joining. The calling workload's identity belongs in `Subjects`, not here. | | Where it was recorded | `Component`, `Metadata.Extra` | `mecatl-broker`, `mecatl-as1`, or the ToolHive component name, plus the recording replica and trust domain. | @@ -72,14 +72,14 @@ The identity work needs these event families: The authority work adds Cedar evaluation status, policy IDs, and policy and ceiling revisions. Until then, decision events record that no ceiling was evaluated. -For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `agent:7hq2x9`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and a reference to the delegation event for `D42`. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `agent:7hq2x9` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. +For the reviewer's call in the [walkthrough](credentials-and-lifecycle.md#interactive-execution), the broker's decision event carries `Type` `mecatl_tool_decision`, `Outcome` `success`, `Subjects` with Alice's `user_id` `u-alice-91` (`record:` the session-open event), her `owner_id` `alice-204`, the verified workload, and the reviewer's `agent_spiffe_id` and `agent_actor_id` `ag_7hq2x9`, `Target` `pull_request_read` on the payments server, operation `op-pr42-001` and its attempt, and `Data.lineage` with run `R42` and a reference to the delegation event for `D42`. Its dispatch and outcome events share the operation and attempt. AS-1's issuance event links the reviewer's SPIFFE ID to `ag_7hq2x9` and records ID-JAG `(https://broker.example.com/as, jag-pr42-001)`; AS-2's redemption event links that ID-JAG to its access token; the vMCP's tool-call event records that access token and the reported operation ID. These records have limits: - They are unsigned, so they do not provide non-repudiation. - In external mode, AS-2 and vMCP records belong to the gateway operator; the Mecatl-side trail ends at the broker's dispatch event. -- AS-2 may accept the same ID-JAG more than once (ID-JAG §4.4.3 allows re-submission), so one ID-JAG can lead to several redemption events. -- Reaching one operation from a gateway record depends on the propagated operation ID; the mechanism that carries it is not yet chosen. +- AS-2 rejects a reused ID-JAG `jti`, so each ID-JAG leads to at most one redemption event. ID-JAG §4.4.3 would allow re-submission; single use is Mecatl policy. +- Reaching one operation from a gateway record depends on the propagated operation ID, which vMCP records as reported, not verified. ### Lineage @@ -92,7 +92,7 @@ Lineage events are audit records. They are kept for the audit retention period, From any tool call, an investigator follows the delegation reference up to the root to reconstruct execution delegation, and registration references to reconstruct creation ancestry. If a reference does not resolve, the reconstruction reports the chain as incomplete, names where it stopped, and gives the reason: unresolved, truncated, or malformed. It stops on a cycle. It never presents a partial chain as complete. -The `agent:` identifiers in `act` are opaque on purpose: the SPIFFE path, with its creation ancestry, stays inside Mecatl. AS-1's issuance record must link each `agent:` identifier it writes to the registered instance it validated, so an investigator can map a gateway record back to that instance. +The `ag_` identifiers in `act` are opaque on purpose: the SPIFFE path, with its creation ancestry, stays inside Mecatl. AS-1's issuance record must link each `ag_` identifier it writes to the registered instance it validated, so an investigator can map a gateway record back to that instance. ## Decision, dispatch, and observed outcome @@ -100,7 +100,7 @@ The broker records its authorization decision, whether it attempted dispatch, an Decision, dispatch, and observed-outcome evidence must survive broker restart; [delivery, protection, and reconciliation](#delivery-protection-and-reconciliation) sets the write order and what happens when evidence cannot be delivered. This design does not claim atomicity between an audit write and an external effect. -Use operation and attempt references to connect the evidence; individual events also need their own references. The operation ID, the ID-JAG `jti`, and the AS-2 access token `(iss, jti)` are separate references. A cached access token serves several calls, so its `jti` cannot replace a per-operation reference. To reach one operation from a gateway record, the broker must propagate the operation ID with each MCP request, and vMCP records it as reported, unverified trace context. This propagation is required for correlation; the mechanism is still to be chosen, and it is not an authenticated propagation contract. Because `act` names only the current actor, this operation ID is what keeps delegation `D42` reachable from a gateway log. +Use operation and attempt references to connect the evidence; individual events also need their own references. The operation ID, the ID-JAG `jti`, and the AS-2 access token `(iss, jti)` are separate references. A cached access token serves several calls, so its `jti` cannot replace a per-operation reference. To reach one operation from a gateway record, the broker must propagate the operation ID with each MCP request, and vMCP records it as reported, unverified trace context. This propagation is required for correlation. The broker sends the IDs in each MCP request's `_meta` and as request parameters at AS-1; it is not an authenticated propagation contract. Because `act` names only the current actor, this operation ID is what keeps delegation `D42` reachable from a gateway log. ## Delivery, protection, and reconciliation diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index c97d0aec4b..7165fce1fd 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -46,7 +46,7 @@ No credential value belongs in the model's context. ## Interactive execution -Alice owns harness session `hostS42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` calls `pull_request_list`. After the list returns, main delegates the review through `D42` to reviewer run `R42`, which calls `pull_request_read`. Tool names and arguments are illustrative. The reviewer uses definition `project/code-reviewer`; main both creates it and delegates the task to it, but creation and delegation remain separate facts. In the diagram, `main` and `reviewer` abbreviate the SPIFFE subjects in [example instance identities](#example-instance-identities). +Alice owns harness session `hostS42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` calls `pull_request_list`. After the list returns, main delegates the review through `D42` to reviewer run `R42`, which calls `pull_request_read`. Tool names and arguments are illustrative. The reviewer uses definition `global/code-reviewer`; main both creates it and delegates the task to it, but creation and delegation remain separate facts. In the diagram, `main` and `reviewer` abbreviate the SPIFFE subjects in [example instance identities](#example-instance-identities). The gateway in this example is external, at `https://gateway.example.com`. Embedded mode follows the same steps with AS-2 and vMCP inside the Mecatl deployment. @@ -82,13 +82,13 @@ sequenceDiagram H->>H: Validate token, check ownership of hostS42 H->>B: OpenSession: workload JWT + AS-1 access token
+ owner of hostS42 (once) B->>A1: RFC 8693: subject_token = AS-1 access token
requested_token_type = refresh_token
authorization_details: session A42, owner
(5) client mecatl, private_key_jwt - A1->>A1: Token issued by AS-1 for the Mecatl API, active, jti not yet converted
sub_id = reported owner, A42 unowned or owned by the same user + A1->>A1: Token issued by AS-1 for the Mecatl API, active, jti not used for another session
sub_id = reported owner, A42 unowned or owned by the same user A1-->>B: RT-A42 (bound to mecatl, A42, Alice, hard expiry, no rotation) B-->>H: SessionSnapshot (BrokerSessionRef A42) rect rgb(245, 245, 245) Note over H,I: (4) Main bootstrap, before R41 - H->>I: registerInstance(main, definition reference), workload JWT + H->>I: registerInstance(main, definition reference, A42), workload JWT I->>I: Definition loaded, no parent, so it must be root-eligible I-->>H: mainRef + canonical main subject H->>I: requestSVID(mainRef), workload JWT @@ -97,9 +97,9 @@ sequenceDiagram Note over H,G: (6) Main call: pull_request_list H->>B: InvokeTool: workload JWT, A42, run R41, run owner Alice,
op-list-prs-001, agentJwtSvid = mainSVID - B->>B: Check session, workload, actor, run owner = grant subject, route
(ceiling with the authority work) + B->>B: Check session, workload, actor, run owner = grant owner, route
(ceiling with the authority work) opt No cached V for (A42, main, gateway, Alice) - B->>A1: (7) RFC 8693: subject_token = RT-A42
actor_token = mainSVID
requested_token_type = id-jag, audience = AS-2 + B->>A1: (7) RFC 8693: subject_token = RT-A42
actor_token = mainSVID
requested_token_type = id-jag, audience = AS-2
(5) client mecatl, private_key_jwt A1->>A1: RT-A42 bound to mecatl, session open, hard expiry not passed
upstream IdP re-checked if due
main registered to A42, build act (iss = AS-1) A1-->>B: J-main B->>A2: (8) RFC 7523: assertion = J-main, client mecatl @@ -116,15 +116,15 @@ sequenceDiagram rect rgb(245, 245, 245) Note over H,I: Reviewer bootstrap, after the list succeeds H->>H: Delegate review: D42 / R42 - H->>I: registerInstance(reviewer, definition reference project/code-reviewer,
parentRef = mainRef), workload JWT + H->>I: registerInstance(reviewer, definition reference global/code-reviewer,
parentRef = mainRef, A42), workload JWT I-->>H: reviewerRef - H->>B: Report delegation D42: R41 to R42, run owner Alice + H->>B: ReportDelegation D42: A42, R41 to R42, run owner Alice B->>B: Child run owner = parent run owner, parent in the same session tree H->>I: requestSVID(reviewerRef), workload JWT I-->>H: reviewerSVID end - H->>B: InvokeTool: A42, run R42, run owner Alice, op-pr42-001,
agentJwtSvid = reviewerSVID + H->>B: InvokeTool: workload JWT, A42, run R42, run owner Alice,
op-pr42-001, agentJwtSvid = reviewerSVID Note over B,G: Same checks and cache-miss path with actor_token = reviewerSVID
J-reviewer and V-reviewer: act = reviewer B-->>H: Sanitized outcome H-->>U: Review outcome @@ -137,17 +137,23 @@ The diagram follows the successful path. Other outcomes: - **Denial or failure.** Any denial before dispatch prevents the provider call. Retrying requires authorization again. - **Unknown outcome.** If dispatch may have happened but no completion was observed, the outcome is unknown and is not replayed automatically. -Every later tool call repeats the broker checks, including calls served from the `V` cache. +Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists every failure, and [renewal triggers](#renewal-triggers) when each credential is replaced. + +Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §5; broker calls are gRPC and use gRPC status codes. ### (1) AS-1 access token AS-1 is Mecatl's login authorization server. mecak8s's protected-resource metadata names AS-1, and the TUI and Studio already discover their issuer there, so neither client changes. AS-1 does not hold passwords: it redirects Alice to the company IdP as an ordinary OIDC client. The company IdP administrator registers one web application, AS-1, with AS-1's callback as redirect URI. -The client receives an access token for the Mecatl API and keeps its own refresh token: the TUI in the OS keyring, Studio in sealed server-side cookies. A fictional decoded access token: +AS-1 asks the company IdP for a refresh token at sign-in, because the [upstream re-check](#sign-out-and-revocation) needs one. How depends on the IdP: the `offline_access` scope, which OIDC Core §11 requires to be sent with `prompt=consent`; `access_type=offline` at Google; or IdP configuration that issues refresh tokens to the AS-1 application. + +The client receives an access token for the Mecatl API and keeps its own refresh token: the TUI in the OS keyring, Studio in sealed server-side cookies. + +**Input.** Each Mecatl API request carries `Authorization: Bearer `. A fictional decoded access token: ```json +{"typ": "at+jwt", "alg": "ES256", "kid": "as1-key-1"} { - "typ": "at+jwt", "iss": "https://broker.example.com/as", "sub": "u-alice-91", "sub_id": {"format": "iss_sub", "iss": "https://login.example.com", "sub": "alice-204"}, @@ -159,15 +165,30 @@ The client receives an access token for the Mecatl API and keeps its own refresh } ``` -mecak8s validates it as a JWT access token (RFC 9068): `typ` is `at+jwt`, `iss` is AS-1, the signature verifies against AS-1's keys with an allowed algorithm, `exp` has not passed, `aud` is the Mecatl API, and `client_id` is the TUI or Studio. A failure returns 401 `invalid_token` (RFC 6750). It then checks that the company identity in `sub_id` owns `hostS42`; a failure there returns 403. An `id_token` is not accepted here: its audience is the client, not the API. +**Checks, at mecak8s** (RFC 9068 §4): + +1. The header `typ` is `at+jwt`, and `alg` is on the configured allowlist, which excludes `none` and every symmetric algorithm. +2. `kid` selects a key from AS-1's configured JWKS, and the signature verifies. A token never chooses where keys come from. +3. `iss` is exactly AS-1's issuer. +4. `exp` has not passed and `iat` is not in the future, each allowing the configured clock skew. +5. `aud` contains the Mecatl API identifier. +6. `client_id` is the TUI's or Studio's. +7. `sub_id` is present with format `iss_sub`. +8. The company identity in `sub_id` owns `hostS42`. + +**Output.** An authenticated request from owner `(https://login.example.com, alice-204)`. + +**Failures.** Checks 1–7 return HTTP 401 with `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750 §3.1); the client refreshes its access token and retries. Check 8 returns 403 and is not retryable. -Session ownership is keyed by the company identity `(https://login.example.com, alice-204)` that AS-1 carries in `sub_id`, not by AS-1's own `(iss, sub)`. Existing owners keep matching without migration, and the owner stays Alice's stable human identity. AS-1 must always set `sub_id`; mecak8s rejects an AS-1 token without it. The company IdP sees only AS-1 as the application: per-application IdP policies can no longer distinguish the TUI from Studio. +**Why.** An `id_token` is not accepted here: its audience is the client, not the API. Session ownership is keyed by the company identity in `sub_id`, not by AS-1's own `(iss, sub)`, so existing owners keep matching without migration and the owner stays Alice's stable human identity. The company IdP sees only AS-1 as the application, so per-application IdP policies can no longer distinguish the TUI from Studio. -**Evidence:** mecak8s accepted request `req-pr42-001` from owner `(https://login.example.com, alice-204)`, authenticated by AS-1 as `u-alice-91`, for `hostS42`. This records API authentication and ownership only. +**Does not prove.** That the caller is the TUI: both clients are public, so `client_id` names the client the token was issued to, not the one presenting it. + +**Evidence.** mecak8s accepted request `req-pr42-001` from owner `(https://login.example.com, alice-204)`, authenticated by AS-1 as `u-alice-91`, for `hostS42`. This records API authentication and ownership only. ### (2) Workload credential -Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s presents it to the broker and agent-instance issuer over TLS, outside model context: +**Input.** Every call from mecak8s to the broker and the agent-instance issuer carries a projected ServiceAccount token, over TLS and outside model context: ```json { @@ -175,6 +196,7 @@ Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s prese "sub": "system:serviceaccount:mecatl:mecak8s", "aud": ["https://broker.example.com"], "iat": 1799999900, + "nbf": 1799999900, "exp": 1800003500, "kubernetes.io": { "namespace": "mecatl", @@ -184,63 +206,163 @@ Kubernetes supplies mecak8s with a projected ServiceAccount token. mecak8s prese } ``` -The token is short-lived and has a broker-specific audience; tokens with the Kubernetes API server's audience are rejected. The broker validates it offline against the cluster's published keys: signature, issuer, allowed algorithm, broker audience, and expiry, then checks that the ServiceAccount may contact it. This proves the token was issued for that ServiceAccount, not that the presenter is that pod, and offline validation does not notice a deleted pod before `exp`; short lifetimes bound that. It identifies the mecak8s service, not Alice or an agent instance. [SPIRE alternatives](#workload-authentication) replace this credential without changing its role. +**Checks, at the broker and the issuer,** offline against the cluster's keys: + +1. The token is a projected token. A legacy Secret-based token, with issuer `kubernetes/serviceaccount` and no `exp`, is rejected. +2. `alg` is on the allowlist, and the signature verifies against the cluster's JWKS. The JWKS is pinned in configuration or fetched by OIDC discovery from the configured issuer, never from a URL in the token. +3. `iss` is exactly the configured cluster issuer. +4. `aud` contains the broker's audience. A token with the Kubernetes API server's audience is rejected. +5. `nbf`, `iat`, and `exp` are current within the clock skew, and `exp - iat` does not exceed the configured maximum lifetime. Kubernetes accepts `expirationSeconds` down to 600. +6. `sub` is on the configured allowlist of ServiceAccounts that may contact the broker. + +The `kubernetes.io` claims are recorded for audit and never decide anything. + +**Output.** The authenticated workload `system:serviceaccount:mecatl:mecak8s`. + +**Failures.** Checks 1–5 return `UNAUTHENTICATED`; check 6 returns `PERMISSION_DENIED`. The kubelet rewrites the projected token before it expires, so mecak8s retries with the new file contents. + +**Why.** It identifies the mecak8s service, so the broker serves sessions only to the workload they are bound to. [SPIRE alternatives](#workload-authentication) replace this credential without changing its role. + +**Does not prove.** That the presenter is that pod: it is a bearer token. Offline validation also does not notice a deleted pod before `exp`; short lifetimes bound that. It identifies neither Alice nor an agent instance. ### (3) Session open and the broker refresh token -At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, alongside workload authentication. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. +At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, with its workload credential and the owner it holds for `hostS42`. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. Clients refresh their access token before each new session open, so the token is fresh and not yet used to open a session. -The broker exchanges that token at AS-1 with an RFC 8693 token exchange, authenticating as client `mecatl`, and asks for a refresh token. The request names the broker session `A42` and the owner mecak8s holds for `hostS42` in an RFC 9396 `authorization_details` entry of type `mecatl_session`, because RFC 8693 has no standard parameter for either. AS-1 accepts only a token it issued for the Mecatl API that is still active, presented by `mecatl`, converted at most once, and asking for no wider scope than the original grant. One conversion per `jti` stops a leaked access token from opening a second session. A retry of the same open, with the same `jti`, client, and broker session, returns the existing refresh token, so a lost response does not leave the session unopenable; clients refresh their access token before each new session open. The [implementation companion](implementation.md#as-1-grant-and-as-2-user-resolution) lists the exact checks. +**Input.** The broker exchanges the token at AS-1 with RFC 8693, authenticating as client `mecatl` ([step (5)](#5-broker-client-authentication)). RFC 8693 has no parameter for the broker session or its owner, so the request carries both in an RFC 9396 `authorization_details` entry of type `mecatl_session`. The owner field is deployment-defined context for the owner check, not an authorization the user granted. Form encoding is shown decoded: -AS-1 then issues `RT-A42`. The refresh token is opaque to the broker; its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91` and her company identity as the session owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. AS-1 accepts `RT-A42` only as the subject token of an ID-JAG exchange; presented at the refresh-token grant, it returns `invalid_grant`, so the broker cannot mint Mecatl API tokens as Alice. `RT-A42` does not rotate: client authentication already binds it to the broker (RFC 9700 §4.14.2, RFC 9449 §5), and rotation would make concurrent exchanges from several broker replicas look like token reuse. +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:access_token +requested_token_type=urn:ietf:params:oauth:token-type:refresh_token +authorization_details=[{"type": "mecatl_session", "session": "A42", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` -The broker supplies `A42` when the record is created, so the session binding is broker-asserted. Only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. Two checks catch that kind of bug: +**Checks, at AS-1:** -- **Owner match.** With the token, mecak8s sends the owner it holds for `hostS42`. AS-1 converts the token only if the company identity in its `sub_id` equals that owner. -- **Owner fixed at first open.** AS-1 records the owner when `A42` is first opened and refuses any later open of `A42` for a different user. Session IDs are never reused, so a stale pairing cannot reach a new session. +1. Client authentication as `mecatl` ([step (5)](#5-broker-client-authentication)). +2. The client may use the token-exchange grant. +3. The request is well formed: the two token types above, no `actor_token`, and exactly one `mecatl_session` entry with a session and an owner. +4. The subject token is an AS-1 access token: signature, `iss`, `exp`, not revoked at AS-1, `aud` is the Mecatl API, and `client_id` is the TUI or Studio. Accepting a Mecatl-API token from `mecatl` is a narrow policy for this one client, not a general audience bypass. +5. No wider scope than the original grant is requested. +6. The company identity in the token's `sub_id` equals the owner in the entry. +7. Session `A42` has no recorded owner yet, or its recorded owner is that same identity. A session that was opened and then closed is refused: session IDs are never reused. +8. The token's `jti` has not opened a session before, or this is a retry: the same `jti`, client, and session, within the source token's `exp`, while `A42` is open and its refresh token has not been revoked. +9. AS-1 writes the record of the converted `jti` and the refresh-token record in one transaction. On a retry, the same transaction revokes the refresh token issued the first time. -A mismatch refuses the open and is recorded. Both checks compare facts inside Mecatl, so they catch mistakes, not a compromised mecak8s, which could report a consistent lie. +**Output.** RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1). AS-1 echoes the granted `authorization_details` (RFC 9396 §7): -**Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with RFC 9396 `authorization_details` naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs RFC 9396 support at AS-1, a session-bound token per session in the TUI and Studio, and a two-step session open. Neither check changes the ID-JAG or anything downstream of `RT-A42`. +```json +{ + "access_token": "", + "issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token", + "token_type": "N_A", + "expires_in": 43200, + "authorization_details": [{"type": "mecatl_session", "session": "A42", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] +} +``` -**Later: run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still turn any fresh token the user sends into a run of its choosing, one run per token; only a key held by the client and signing each run's start would prevent that. +`expires_in` is illustrative; the hard expiry is not selected. `RT-A42` is opaque to the broker. Its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, to her company identity as the session owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. The broker checks that the echoed entry matches the one it sent. + +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| 1 | 401 `invalid_client` | No; a configuration or key fault. | +| 2 | 400 `unauthorized_client` | No. | +| 3 | 400 `invalid_request`, or `invalid_authorization_details` for a malformed entry | No; a broker bug. | +| 4 | 400 `invalid_request` (RFC 8693 §2.2.2) | After the client refreshes its access token. | +| 5 | 400 `invalid_scope` | No. | +| 6, 7 | 400 `invalid_request`, recorded with the reason | No; mecak8s paired the token with the wrong session. | +| 8, `jti` used for another session | 400 `invalid_request` | After the client refreshes its access token. | +| Lost response | None received | Yes: resend the same request. Check 8 accepts it and replaces the first refresh token. | -The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, and the hard expiry end it, and AS-1's upstream re-check ends it if the company IdP disables her account; see [sign-out and revocation](#sign-out-and-revocation). +mecak8s reports any of these to the client as a failed session open. It answers failures of checks 4 and 8 with 401 `invalid_token`, so the client refreshes its access token and tries again. + +**Why.** One conversion per `jti` stops a leaked access token from opening a second session. A retry issues a new refresh token rather than returning the first, because AS-1 stores only a hash of each refresh token and cannot return it again; revoking the first in the same transaction keeps one live refresh token per session. Writing the `jti` record and the refresh-token record together means a crash cannot leave a converted `jti` with no refresh token, or a refresh token whose conversion was not recorded. + +`RT-A42` is accepted only as the subject token of an ID-JAG exchange; presented at the refresh-token grant, it returns `invalid_grant`, so the broker cannot mint Mecatl API tokens as Alice. `RT-A42` does not rotate: client authentication already binds it to the broker (RFC 9700 §4.14.2, RFC 9449 §5), and rotation would make concurrent exchanges from several broker replicas look like token reuse. + +Checks 6 and 7 exist because the broker supplies `A42`, and only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. AS-1 records the owner at the first open and refuses any later open of `A42` for a different user. Both checks compare facts inside Mecatl, so they catch mistakes, not a compromised mecak8s, which could report a consistent lie. + +**Does not prove.** That mecak8s paired Alice's token with her own session, only that its reports agree with each other. That Alice is present after the open: the refresh token outlives her access token. + +The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, the hard expiry, and AS-1's upstream re-check end it; see [sign-out and revocation](#sign-out-and-revocation). The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. -**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, and is an extension to the ID-JAG profile. +**Evidence.** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry. It records a refused open with its reason, and a retry with the revoked first refresh token. -**Evidence:** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry, and records any refused open with its reason. +**Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with a `mecatl_session` entry naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs `authorization_details` at AS-1's refresh grant, a session-bound token per session in the TUI and Studio, and a two-step session open. It changes nothing downstream of `RT-A42`. + +**Later: run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still turn any fresh token the user sends into a run of its choosing, one run per token; only a key held by the client and signing each run's start would prevent that. + +**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, and is an extension to the ID-JAG profile. ### (4) Agent-instance JWT-SVID -For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and selected definition, with no parent. For the reviewer, it supplies the child's persisted incarnation, the already registered reviewer definition, and the registered main-instance reference, not the main's token. The agent-instance issuer constructs each canonical subject under the [registration checks](agent-definitions.md#registering-instances). A registration with no parent, including after a fork or clear, succeeds only for a root-eligible definition. Definition names that would need encoding in a SPIFFE path are rejected, and nesting depth is capped so a subject stays under the 2048-byte limit of SPIFFE-ID §2.3; registration past the cap is refused. Registration records identity, not permission to spawn, run, or call tools. +**Input.** For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and the selected definition reference, with no parent. For the reviewer, it supplies the child's persisted incarnation, the definition reference `global/code-reviewer`, and the registered main-instance reference, not the main's token. Both calls carry the workload credential. + +**Registration checks, at the agent-instance issuer:** + +1. The workload is authenticated ([step (2)](#2-workload-credential)). +2. The definition reference names a definition the loader recorded, or, for a definition the broker did not load, is accepted as a mecak8s report and marked as such; see [definition provenance](agent-definitions.md#definition-provenance). +3. With no parent, including after a fork or clear, the definition is root-eligible. With a parent, the parent is registered in the same session tree. +4. The definition category and name match `[A-Za-z0-9._-]+` and are neither `.` nor `..`. A name that would need encoding is rejected, not rewritten. +5. The nesting depth and the whole URI stay within the caps. Proposed: names up to 63 bytes and depth up to 8, which keeps the longest subject near 1,100 bytes. The issuer also checks the whole URI against the 2,048 bytes SPIFFE-ID §2.3 says implementations should not exceed. -The broker's agent issuer signs each registered instance's JWT-SVID. This payload shows the reviewer's credential for the second call. Mecatl's harness holds it outside model history and presents it to the broker for invocation. The broker forwards it to AS-1 as `actor_token`; AS-2 receives only the actor claim in the ID-JAG. The agent issuer retains its private signing key. This fictional decoded example deliberately uses only required JWT-SVID claims: +Registration records identity, not permission to spawn, run, or call tools. The issuer constructs the canonical subject; see the [registration checks](agent-definitions.md#registering-instances). + +**Output.** The instance reference, its canonical subject, and, on `requestSVID`, a JWT-SVID signed by the agent-instance issuer. The reviewer's, deliberately using only the claims JWT-SVID requires: ```json +{"alg": "ES256", "kid": "agents-2026-10"} { - "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", + "sub": "spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/global/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", "aud": ["https://broker.example.com"], "exp": 1800000600 } ``` -Before issuance, the issuer checks the workload and reviewer instance against the registration; a definition's existence alone is insufficient. This is an assertion, not attestation: the issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. mecak8s checks that the returned subject is the registered reviewer instance. The SVID carries a single audience, the broker's, as JWT-SVID §7.2 recommends; AS-1 is configured to accept that value as its own SVID audience, because the broker presents the same SVID to both. The broker and AS-1 each validate the SVID against the configured agent trust bundle. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and trust domain are distinct from OAuth AS-1 and AS-2; AS-1 uses the validated registered identity to form the ID-JAG's `act` claim. +mecak8s checks that the returned subject is the registered instance. The harness holds the SVID outside model history and presents it to the broker for invocation; the broker forwards it to AS-1 as `actor_token`. AS-2 receives only the actor claim in the ID-JAG. + +**SVID checks, at the broker and at AS-1:** + +1. `alg` is on the allowlist JWT-SVID §3 permits: RS, ES, or PS with SHA-256 or longer, never `none` or HMAC. +2. `kid` selects a key in the agent trust bundle whose `use` is `jwt-svid`. An unknown `kid` triggers one rate-limited bundle refresh, then rejection. +3. The signature verifies. +4. `sub` is a SPIFFE ID in the agent trust domain `agents.example.com`, the trust domain of the bundle that verified it. A platform SVID with the same audience is rejected, because it belongs to a different trust domain and bundle. +5. `aud` contains the broker's audience. +6. `exp` has not passed, allowing the clock skew, and is no further in the future than the configured maximum SVID lifetime. +7. `sub` matches the full canonical subject of a registered instance byte for byte, and that instance is not deregistered. +8. The instance is registered to the session in use: `A42` from the request at the broker, the session of `RT-A42`'s record at AS-1. -See [credential lifetimes and renewal](#credential-lifetimes-and-renewal) for renewal across runs. +**Failures.** Registration failures return `INVALID_ARGUMENT` for checks 4 and 5 and `PERMISSION_DENIED` otherwise; none is retryable. An SVID that fails at the broker denies the call with `PERMISSION_DENIED`; at AS-1 it returns 400 `invalid_request` (RFC 8693 §2.2.2). An expired SVID is renewed and the call retried; any other SVID failure is not retryable. -The agent-instance JWT-SVID stays the actor input because it gives agent identity its own SPIFFE trust domain name and signing key, so verifiers can tell it apart from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. While the broker runs inside mecak8s, the issuer shares a process, and therefore its security controls, with AS-1 and the broker. While agents share one harness process, the SVID asserts the agent rather than authenticating it: the issuer is in the broker, and the broker presents the SVID. AS-1 checks the broker's registry that the instance belongs to the session; that catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings are possible but not selected: +**Why.** The SVID gives agent identity its own SPIFFE trust domain and signing key, so verifiers can tell it apart from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. The SVID carries a single audience, the broker's, as JWT-SVID §7.2 recommends. AS-1 is configured to accept that value as its own SVID audience, because the broker presents the same SVID to both. That holds only while AS-1 is a broker module; an AS-1 outside the broker needs its own audience and a second SVID. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and its trust domain are distinct from OAuth AS-1 and AS-2. + +**Does not prove.** This is an assertion, not attestation: the issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. While the broker runs inside mecak8s, the issuer shares a process, and therefore its security controls, with AS-1 and the broker. While agents share one harness process, the SVID asserts the agent rather than authenticating it. AS-1's registration check catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings are possible but not selected: - **Session claim.** The SVID names the session it was issued for, so AS-1 can check that the SVID and the subject token belong to the same session without relying on the broker's pairing. It is still issued on mecak8s's word. - **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession, so a stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. +**Evidence.** The issuer records each registration as a [lineage event](audit.md#lineage), marked as reported by mecak8s, and each SVID issuance with the instance and expiry. + +See [renewal triggers](#renewal-triggers) for renewal across runs. + ### (5) Broker client authentication -The subject tokens the broker presents are bearer tokens: they prove whose authority is used, not who presents them. The broker therefore authenticates as OAuth client `mecatl` on every token-endpoint request, at AS-1 and at AS-2, with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion): +The subject tokens the broker presents are bearer tokens: they prove whose authority is used, not who presents them. The broker therefore authenticates as OAuth client `mecatl` on every token-endpoint request, at AS-1 and at AS-2, with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion). + +**Input.** Each request carries `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` and a fresh assertion: ```json {"alg": "ES256", "kid": "broker-2026-10"} @@ -251,15 +373,28 @@ The subject tokens the broker presents are bearer tokens: they prove whose autho } ``` -`aud` is the authorization server's issuer identifier as its only value, not its token-endpoint URL, as [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) requires. The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. +**Checks, at AS-1 and AS-2:** + +1. `alg` is on the allowlist, and `kid` selects a key registered for `mecatl`, from its `jwks_uri` or static configuration. +2. The signature verifies. +3. `iss` and `sub` are both `mecatl`. +4. `aud` is the authorization server's issuer identifier as its only value, not its token-endpoint URL, as [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) requires. +5. `exp` has not passed, and `exp - iat` is within the configured maximum, here 60 seconds. +6. `jti` has not been seen; the server keeps it until `exp`. + +**Output.** An authenticated client `mecatl`. -This makes "accept only from the broker" enforceable. A leaked `RT-A42` is useless without the key: a refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5). A leaked AS-1 access token cannot be converted directly either, but mecak8s converts the token it receives at session open, so a thief who presents a stolen token to the Mecatl API can open a session with it, once per token. A `client_secret` is rejected: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. +**Failures.** Any failed check returns 401 `invalid_client`. Only a lost response is retryable, with a new assertion and `jti`. -One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. +**Why.** It makes "accept only from the broker" enforceable. A leaked `RT-A42` is useless without the key: a refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5). The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. + +**Does not prove.** Which agent asked: `mecatl` is the broker's registration, not an actor. Nor does it stop a thief with a stolen AS-1 access token from presenting it to the Mecatl API, where mecak8s converts it at session open, once per token. ### (6) Per-operation authorization -Local permissions and hooks run before each call. For the reviewer's call, trusted harness client code sends `InvokeTool` with workload authorization metadata, `A42`, `call.id=op-pr42-001`, run `R42` with its [run owner](run-owner.md) Alice, and `agentJwtSvid=reviewerSVID`. The model supplies only the tool content: +Local permissions and hooks run before each call. + +**Input.** Trusted harness client code sends `InvokeTool` with the workload credential, `A42`, `call.id=op-pr42-001`, run `R42` with its [run owner](run-owner.md) Alice, and `agentJwtSvid=reviewerSVID`. The model supplies only the tool content: ```json { @@ -268,25 +403,72 @@ Local permissions and hooks run before each call. For the reviewer's call, trust } ``` -Before any exchange or dispatch, the broker checks: +**Checks, at the broker,** before any exchange or dispatch: -- the session `A42` is open and bound to the authenticated workload and `hostS42`; -- the SVID is unexpired and names an instance registered to `A42`; -- the reported run owner equals the subject of `RT-A42`'s record; a mismatch denies the call and is recorded, and the run owner never selects the grant; -- the route to the gateway; -- once the authority work is in place, the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors. +1. The workload is authenticated ([step (2)](#2-workload-credential)). +2. Session `A42` is open and bound to that workload and `hostS42`. +3. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid) and names an instance registered to `A42`. +4. The reported run owner equals the owner recorded with `RT-A42`, the company identity `(https://login.example.com, alice-204)`, not AS-1's subject `u-alice-91`. The run owner never selects the grant. +5. A route to the gateway exists for the tool. +6. Once the authority work is in place, the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors. -A cache hit skips the token exchanges, never these checks. User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). +The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hit skips steps (7) and (8), never these checks. -The broker does not check provider connections. It authorizes the session, workload, actor, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. +**Output.** An allowed operation, dispatched in [step (9)](#9-vmcp-decision-and-upstream-credential). -**Evidence:** `op-pr42-001` passed the session, workload, actor, and run-owner checks, and, once the authority work is in place, the effective-ceiling check. This decision proves neither dispatch nor success. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. +**Failures.** Check 1 returns `UNAUTHENTICATED`. Checks 2–6 deny the call with `PERMISSION_DENIED`, record the denial, and dispatch nothing. None is retried automatically: retrying requires authorization again. + +**Why.** User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). The broker does not check provider connections. It authorizes the session, workload, actor, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. + +**Does not prove.** That mecak8s reported the right run or run owner: both are mecak8s reports, checked for consistency with the grant. Neither dispatch nor success. + +**Delegation report.** When main delegates the review, mecak8s calls `ReportDelegation` with session `A42`, delegation `D42`, the delegating run `R41` and instance `mainRef`, the receiving run `R42` and instance `reviewerRef`, and the receiving run's owner. The broker checks that `R41` belongs to `A42`, that both instances are registered to `A42`, and that the receiving run's owner equals the delegating run's. A mismatch returns `PERMISSION_DENIED`; the receiving run cannot call tools until a consistent report arrives. + +**Evidence.** `op-pr42-001` passed the session, workload, actor, and run-owner checks, and, once the authority work is in place, the effective-ceiling check. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. ### (7) ID-JAG from AS-1 -On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with an RFC 8693 token exchange: `RT-A42` is the subject token, `reviewerSVID` the actor token, and AS-2 the audience. The broker authenticates as `mecatl`. +On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)). + +**Input.** An RFC 8693 token exchange with `RT-A42` as subject token, `reviewerSVID` as actor token, and AS-2 as audience. The operation and attempt IDs travel as request parameters for AS-1's record, as reported trace context: + +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:refresh_token +actor_token= +actor_token_type=urn:ietf:params:oauth:token-type:jwt +requested_token_type=urn:ietf:params:oauth:token-type:id-jag +audience=https://gateway.example.com +mecatl_operation=op-pr42-001 +mecatl_attempt=1 +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` + +**Checks, at AS-1:** -AS-1 resolves `RT-A42` to its record and checks that the session is open, the hard expiry has not passed, and the token is bound to `mecatl`. If the re-check interval has passed, it re-checks Alice's company-IdP grant first ([upstream re-check](#sign-out-and-revocation)). It validates the SVID and checks that the reviewer is registered to `A42`. ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); this registration check is that missing binding. AS-1 authorizes the combination of client, subject, actor, and target AS-2, then issues: +1. Client authentication as `mecatl`. +2. The request is well formed: the three token types above and an `audience`. +3. `audience` is one of the AS-2 issuers configured for `mecatl`. +4. `RT-A42` resolves to a `mecatl_session` record bound to `mecatl` and not revoked; session `A42` is open; the hard expiry has not passed. A schedule's record is refused here. +5. If the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first ([upstream re-check](#sign-out-and-revocation)). +6. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid) and names an instance registered to `A42`. +7. AS-1 authorizes the combination of client, subject, actor, and target AS-2. + +**Output.** + +```json +{ + "access_token": "", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A", + "expires_in": 120 +} +``` + +`J-reviewer`, decoded: ```json {"typ": "oauth-id-jag+jwt", "alg": "ES256", "kid": "as1-key-1"} @@ -295,40 +477,84 @@ AS-1 resolves `RT-A42` to its record and checks that the session is open, the ha "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, + "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 } ``` -- `act` names only the acting instance, with an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name. `agent:` is a private URI scheme. AS-1 generates a random identifier for each instance and stores the mapping from SPIFFE ID; stability lets the vMCP bind a session to the instance and audit correlate its calls. `act.iss` names AS-1, which minted it, so the identifier stays attributable to AS-1 inside `V`, whose own `iss` is AS-2. The full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. +- `sub` comes from `RT-A42`'s record, never from the request. +- `act` names only the acting instance, with an opaque, stable, per-instance identifier: no SPIFFE path, no definition name, and no colon, so it reads as a plain string rather than a URI. AS-1 generates a random identifier for each registered instance and stores it under the instance's SPIFFE ID, so the identifier survives SVID renewal. Stability lets the vMCP bind a session to the instance and audit correlate its calls. `act.iss` names AS-1, which minted it, so the identifier stays attributable to AS-1 inside `V`, whose own `iss` is AS-2. The full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. +- `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. -- AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different trust domains; external mode fits it, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). -ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. +**Failures.** + +| Check | Response | Broker outcome and retry | +| --- | --- | --- | +| 1 | 401 `invalid_client` | Call fails; not retryable. | +| 2 | 400 `invalid_request` | Call fails; a broker bug. | +| 3 | 400 `invalid_target` | Call fails; a configuration fault. | +| 4 | 400 `invalid_request` | The session has lost its authority: calls fail closed until a new session open. | +| 5, IdP returns `invalid_grant` | 400 `invalid_request` | AS-1 has revoked Alice's grants, or ended them with "needs owner action"; see [upstream re-check](#sign-out-and-revocation). | +| 5, IdP unreachable or 5xx | 503, nothing revoked | Call fails closed; the next call retries the re-check. | +| 6 | 400 `invalid_request` | Call denied; retried only after the SVID is renewed, if it had expired. | +| 7 | 400 `invalid_request` | Call denied. | +| Lost response | None received | Request a new ID-JAG. An unused ID-JAG expires harmlessly. | -**Evidence:** AS-1 records the ID-JAG issuance: user, session `A42`, validated actor, target, and `jti`. +**Why.** ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); check 6 is that missing binding. ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. + +AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different administrative domains; external mode fits it, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). + +**Does not prove.** That the reviewer, rather than other code in the harness process, asked for the call: the SVID is an assertion. + +**Evidence.** AS-1 records the ID-JAG issuance: user, session `A42`, the validated instance's SPIFFE ID and its `ag_` identifier, target, `jti`, and the reported operation and attempt. ### (8) AS-2 access token -The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), authenticating again as `mecatl`. AS-2 checks the `typ`, that `aud` is its own issuer, and that `client_id` matches the authenticated client (§4.4.1). It also rejects a reused `jti` (RFC 7523 §3) and keeps used identifiers until they expire. Single use is Mecatl policy, stricter than ID-JAG §4.4.3, which lets a client re-submit an ID-JAG. It then resolves the native user: +**Input.** The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), naming the vMCP with an RFC 8707 `resource`: + +```text +POST https://gateway.example.com/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer +assertion= +resource=https://gateway.example.com/mcp +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` + +**Checks, at AS-2:** + +1. Client authentication as `mecatl`. +2. The ID-JAG's header `typ` is `oauth-id-jag+jwt`; `iss` is a trusted AS-1; `alg` is on the allowlist and the signature verifies against that issuer's keys. +3. `aud` is AS-2's own issuer; `exp` and `iat` are current within the clock skew. +4. `client_id` matches the authenticated client (ID-JAG §4.4.1). +5. `jti` has not been redeemed (RFC 7523 §3). AS-2 keeps used identifiers until they expire. +6. `resource` is a vMCP this client and actor may reach. +7. AS-2 resolves the native user: | Mode | How AS-2 resolves and authorizes the user | | --- | --- | | Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit. AS-1 and AS-2 are distinct issuers with distinct keys, so no issuer redeems its own ID-JAG; embedded mode is still outside §9.3's cross-domain scope. | -| External | Identity chaining across trust domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` and client `mecatl` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | +| External | Identity chaining across administrative domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` and client `mecatl` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | -The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). AS-2 never resolves users through `sub_id` (§9.5). +AS-2 never resolves users through `sub_id` (§9.5), email, or display name. -AS-2 issues a short-lived access token and no refresh token (§4.4.3): +**Output.** A short-lived access token and no refresh token (§4.4.3): + +```json +{"access_token": "", "token_type": "Bearer", "expires_in": 115} +``` + +`V-reviewer`, decoded: ```json {"typ": "at+jwt", "alg": "RS256", "kid": "gateway-example-key-1"} { "iss": "https://gateway.example.com", "sub": "usr-alice-7c1d", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, + "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, "client_id": "mecatl", "aud": "https://gateway.example.com/mcp", "iat": 1800000005, @@ -337,19 +563,75 @@ AS-2 issues a short-lived access token and no refresh token (§4.4.3): } ``` -The broker caches `V-reviewer` for `(A42, reviewer, gateway, Alice)`. It treats `V` as expired a small skew margin before `exp`; on a 401 `invalid_token` from the vMCP it drops `V` and retries the exchange once. A cache hit still needs a currently valid SVID for the same instance. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, and the broker stores each session ID under the instance, gateway, and user, so each agent instance has its own vMCP session per user. +**Failures.** Check 1 returns 401 `invalid_client`. Checks 2–5 return 400 `invalid_grant` (RFC 7523 §3.1) and are not retryable with the same ID-JAG. Check 6 returns 400 `invalid_target`. An unresolved subject, including a missing consent link in external mode, returns `invalid_grant`; the broker returns the existing pending outcome, and how an external gateway signals "consent required" in a structured way is [open](implementation.md#as-1-grant-and-as-2-user-resolution). A lost response is retried with a new ID-JAG from step (7), because the first one's `jti` may already be spent. + +**Why.** Single use is Mecatl policy, stricter than ID-JAG §4.4.3, which lets a client re-submit an ID-JAG; it keeps one redemption per issuance in the audit trail. The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). + +**Does not prove.** Anything about the agent's ceiling: `V` carries no scope for it yet, so a gateway allow says nothing about the ceiling. -**Evidence:** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. +**Caching.** The broker caches `V-reviewer` under `(A42, reviewer, gateway, Alice)` and treats it as expired a configurable skew margin before `exp`. On a 401 `invalid_token` from the vMCP, it drops `V` and repeats steps (7) and (8) once. A cache hit still needs a currently valid SVID for the same instance. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, and the broker stores each session ID under the instance, gateway, and user, so each agent instance has its own vMCP session per user. + +**Evidence.** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. ### (9) vMCP decision and upstream credential -vMCP validates `V-reviewer` against AS-2's keys, `typ`, audience, and expiry, and authorizes the operation. It then loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. One record exists per user and provider. A delegated `V` comes from no interactive gateway login, so every gateway, including a standalone one, must find the credential from the user alone. +**Input.** `tools/call` with `Authorization: Bearer `, the vMCP session ID, and the operation and attempt IDs in the request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`. + +**Checks, at the vMCP:** -If no credential is stored, vMCP answers "needs authorization" and the user connects GitHub through the existing authorization flow. A later call after connection passes every check again. +1. `V`'s `typ` is `at+jwt`, `iss` is AS-2, the signature verifies, `aud` is this vMCP, and `exp` has not passed (RFC 9068 §4). +2. The session ID, if present, is bound to the same `iss`, `sub`, and `act.sub`. +3. Its own operation policy allows the tool. +4. An upstream credential exists under **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. -**Evidence:** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, and outcome. Neither a successful MCP response nor an authorization record proves provider-side effects. +**Output.** The GitHub call made with Alice's GitHub token, and its result. One credential record exists per user and provider. A delegated `V` comes from no interactive gateway login, so every gateway, including a standalone one, must find the credential from the user alone. -**Correlation:** a cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. To tie a gateway record to a single call, the broker propagates the operation and attempt IDs, here `op-pr42-001`, in each MCP request's `_meta`, and vMCP logs them as reported, unverified trace context. This propagation is required for correlation. Because `act` names only the reviewer, this operation ID is what lets an investigator reach delegation `D42` from a gateway log. +**Failures.** Check 1 returns 401 `invalid_token`; the broker drops `V` and retries once with a new one. Check 2 rejects the session; the broker opens a new one. Check 3 returns a tool error and is not retried. Check 4 answers "needs authorization"; the user connects GitHub through the existing authorization flow, and a later call passes every check again. + +**Does not prove.** Provider-side effects: neither a successful MCP response nor an authorization record proves them. + +**Evidence.** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, outcome, and the reported operation and attempt. + +**Correlation.** A cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. The operation ID in `_meta`, logged by vMCP as reported, unverified trace context, ties a gateway record to a single call. Because `act` names only the reviewer, it is also what lets an investigator reach delegation `D42` from a gateway log. + +### Failure matrix + +Where each failure is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. + +| Failure | Detected at | Response | User-visible outcome | Revokes | +| --- | --- | --- | --- | --- | +| Expired or invalid AS-1 access token | mecak8s | 401 `invalid_token` | Client refreshes silently; signs in again if its refresh token is gone | Nothing | +| Not the owner of `hostS42` | mecak8s | 403 | Request refused | Nothing | +| Workload token expired or not allowed | Broker, issuer | `UNAUTHENTICATED`, `PERMISSION_DENIED` | Request fails; an operator fault if it persists | Nothing | +| Owner mismatch or session owned by another user at open | AS-1 | `invalid_request`, recorded | Session open fails | Nothing | +| Access token already opened another session | AS-1 | `invalid_request` | Client refreshes and retries | Nothing | +| Root registration of a non-root-eligible definition | Issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | +| SVID expired | Broker, AS-1 | `PERMISSION_DENIED`, `invalid_request` | None: renewed and retried | Nothing | +| Run owner differs from the grant's owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | +| `RT-A42` revoked, expired, or session closed | AS-1 | `invalid_request` | Calls fail until a new session open | Already revoked | +| Company IdP refuses the refresh before its expiry | AS-1 | `invalid_request` | Every agent and schedule for Alice stops | All of Alice's grants | +| Company IdP refresh token reached its own expiry | AS-1 | `invalid_request` | "Needs owner action": Alice signs in again | All of Alice's grants | +| Company IdP unreachable or 5xx | AS-1 | 503 | This call fails; later calls retry | Nothing | +| No consent link (external) | AS-2 | `invalid_grant` | `mcp_authorization_pending` through "Allow Mecatl" | Nothing | +| ID-JAG replayed | AS-2 | `invalid_grant` | None if the broker re-mints; otherwise call fails | Nothing | +| `V` rejected by vMCP | vMCP | 401 `invalid_token` | None: one retry with a new `V` | Cached `V` dropped | +| No upstream credential | vMCP | "Needs authorization" | `mcp_authorization_pending`; Alice connects GitHub | Nothing | +| Dispatch happened, no completion observed | Broker | Unknown outcome | Reported as unknown; not replayed | Nothing | + +### Renewal triggers + +| Credential | Renewed when | By | If renewal fails | +| --- | --- | --- | --- | +| AS-1 access token | Before expiry, and before each session open | Client, with its refresh token | Sign in again | +| Workload token | At 80% of its lifetime | Kubelet; mecak8s rereads the file | Calls fail with `UNAUTHENTICATED` | +| Agent-instance SVID | When less than half its lifetime remains, on the next use; the old SVID stays valid until its `exp` | Issuer, for the same registered instance; the `ag_` identifier is unchanged | Calls from that instance fail | +| Client assertion | Every token-endpoint request | Broker | Exchange fails with `invalid_client` | +| `RT-A42` | Never | — | A new session open with a fresh access token | +| Company-IdP refresh token | At most once per re-check interval, when an ID-JAG is requested; single-flight per user, storing the newest token if the IdP rotates it | AS-1 | See [upstream re-check](#sign-out-and-revocation) | +| ID-JAG | Never; one per `V` | — | — | +| `V` | A skew margin before `exp`, or on a 401 from the vMCP, at the next call | Broker, through steps (7) and (8) | The call fails with step (7) or (8)'s outcome | +| Agent and AS-1 key sets | On an unknown `kid`, rate-limited, and periodically | Each verifier | Tokens signed with the new key are rejected until the refresh succeeds | +| Schedule authority | Before its horizon, after a notification | Alice | Runs end with "needs owner action" | ## User identity @@ -359,7 +641,7 @@ No mecak8s code path can name a user who did not sign in at AS-1. An attacker wi AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. -The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. Each call also carries the [run owner](run-owner.md), which the broker checks against the grant it uses; today it equals the session owner. +The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. Each call also carries the [run owner](run-owner.md), which the broker checks against the owner recorded with the grant it uses; today it equals the session owner. ## Credential custody @@ -390,7 +672,7 @@ An agent instance keeps its identity across runs. These lifetimes are independen | AS-1 access token | AS-1 policy; short. Expiry ends API authentication, not the broker session. | The client refreshes with its own refresh token. | | Client refresh token | AS-1 policy and upstream sign-in status. | Sign in again. | | Workload JWT | Expired credentials are rejected. | Platform rotation for the same workload. | -| `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the ceiling does not widen. | +| `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the `ag_` identifier and the ceiling do not change. | | `RT-A42` | Hard expiry, session close, sign out everywhere, a failed upstream re-check, or revocation. It does not rotate. | A new session open with a fresh AS-1 access token. | | ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | | `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | @@ -414,9 +696,19 @@ Signing out of one client and stopping Mecatl's agents are different actions. | **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval. | Transcripts. | | **Closing a session** | That session's refresh token. | Other sessions and schedules. | -**Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker revokes Alice's grants in those sessions at AS-1, cancels their in-flight tool calls, and drops her cached ID-JAGs and `V`; with one owner per session, that ends each broker session. Revocation is by user, so a [shared session](run-owner.md) would keep running for its other members. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. +**Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker revokes Alice's grants in those sessions at AS-1, cancels their in-flight tool calls, and drops her cached ID-JAGs, `V`, and vMCP session IDs. Today every session has one owner, so this ends each of her broker sessions. Revocation is by user, so a later [shared session](run-owner.md) would keep running for its other members. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. + +**Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes the upstream token if it has not done so within the re-check interval. Refreshes are single-flight per user, so concurrent exchanges from several replicas cause one refresh, and AS-1 stores the newest token if the IdP rotates them. The IdP's answer decides what happens: + +| IdP answer | AS-1 action | Outcome | +| --- | --- | --- | +| Success | Stores the new token, records the time | The exchange continues. | +| `invalid_grant` before the recorded expiry | Revokes every grant it holds for Alice, including schedule authority | The account was disabled or its grants revoked: the same as sign out everywhere. | +| `invalid_grant` at or after the recorded expiry | Ends every grant it holds for Alice | "Needs owner action": Alice signs in again. Not reported as a disabled account. | +| `invalid_grant` with no recorded expiry | Revokes, as before the expiry | Treated as revocation, because AS-1 cannot tell the two apart. | +| Network error or 5xx | Nothing | That exchange fails closed; the next one retries. | -**Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes that upstream token if it has not done so within the re-check interval. AS-1 requests `offline_access` at sign-in so the IdP issues it a refresh token, and stores the newest one if the IdP rotates them. Only an `invalid_grant` from the IdP, meaning the account is disabled or its grants were revoked, makes AS-1 revoke every grant it holds for Alice, including schedule authority, and fail the exchange. A network error or a server error fails that exchange closed and revokes nothing. An upstream refresh token that merely reached its own maximum lifetime ends Alice's authority with a "needs owner action" outcome, not as a disabled account. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. +This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. **What remains after revocation.** Issued ID-JAGs and AS-2 access tokens cannot be revoked. The broker stops using them at once, but a `V` already issued stays valid at the gateway until it expires, and the vMCP does not check for revocation. Keep both lifetimes short. @@ -440,7 +732,7 @@ An operation that may have been dispatched but has no recorded completion has an A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, in the same one-time conversion as at session open, but as a separate kind of record bound to the schedule rather than a session. A session cannot redeem a schedule's authority, and a schedule cannot redeem a session's refresh token. A scheduled run's owner is checked against the schedule's authority. It has an explicit horizon. Deleting the schedule, signing out everywhere, and a failed upstream re-check revoke it. Each run draws on that authority. +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, with the same exchange as at [session open](#3-session-open-and-the-broker-refresh-token): her client refreshes its access token first, and the `authorization_details` entry has type `mecatl_schedule` and names the schedule and its owner. The result is a separate kind of record, bound to the schedule rather than a session. A session cannot redeem a schedule's authority, and a schedule cannot redeem a session's refresh token. A scheduled run's owner is checked against the owner recorded with the schedule's authority. It has an explicit horizon. Deleting the schedule, signing out everywhere, and a failed upstream re-check revoke it. Each run draws on that authority. When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. @@ -507,7 +799,7 @@ spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj A reviewer instance created by that main: ```text -spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/project/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m +spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_cejrv7re4uy3ubxyzj2gfxfp2u/child/global/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m ``` A new root instance after a fork or clear: diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index e1d246ad97..abc4cb533a 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -18,7 +18,7 @@ Verify that scheduled invocations preserve owner attribution without treating sa Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Agent-instance incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. -Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs; a Mecatl cap and compatible name/depth bounds remain undecided. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. +Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs. The proposed caps are names up to 63 bytes and nesting depth up to 8, which keeps the longest subject near 1,100 bytes; registration past either cap is refused. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. ## AS-1 grant and AS-2 user resolution @@ -30,7 +30,7 @@ The flow has four integration points: AS-1 sign-in, the session-open exchange, t **Session open.** At `OpenSession`, mecak8s passes the user's current AS-1 access token to the broker once, alongside workload authentication. It replaces the harness-reported `human {issuer, subject}`. The broker exchanges it at AS-1 with RFC 8693: `subject_token_type=urn:ietf:params:oauth:token-type:access_token`, `requested_token_type=urn:ietf:params:oauth:token-type:refresh_token`. AS-1 accepts the token only if: -- AS-1 issued it, its `aud` is the Mecatl API, it is still active, and its `jti` has not been converted before; +- AS-1 issued it, its `aud` is the Mecatl API, it is still active, and its `jti` has not opened another session; - the presenter is the authenticated client `mecatl`; - the request asks for no wider scope than the original grant; - the company identity in the token's `sub_id` equals the session owner that mecak8s sends with it; @@ -44,6 +44,8 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token= subject_token_type=urn:ietf:params:oauth:token-type:access_token requested_token_type=urn:ietf:params:oauth:token-type:refresh_token +authorization_details=[{"type": "mecatl_session", "session": "A42", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] client_id=mecatl client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer client_assertion= @@ -58,14 +60,15 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Owner | `(https://login.example.com, alice-204)` | The session owner, recorded at the first open; later opens for another user are refused. | | Broker session reference | `A42` | The session binding. | | Host session and incarnation | `hostS42` | Restart and recovery continuity. | -| Source access-token `jti` | | One conversion per session. | +| Source access-token `jti` | | Refuses a second session opened with the same token. | | Issue time and hard expiry | | Authority horizon. | -The broker supplies the session reference. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. +The broker supplies the session reference. AS-1 echoes the granted `mecatl_session` entry in its response (RFC 9396 §7), and the broker checks it matches. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record and the refresh-token record in one transaction. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: -1. resolves the refresh token to its record and checks that it is bound to `mecatl`, that the session is open, and that the hard expiry has not passed; +1. resolves the refresh token to its record and checks that it is a `mecatl_session` record bound to `mecatl`, that the session is open, and that the hard expiry has not passed; +1. if the re-check interval has passed, refreshes the user's company-IdP grant, as [upstream re-check](credentials-and-lifecycle.md#sign-out-and-revocation) describes; 2. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; 3. authorizes the combination of client, subject, actor, and target AS-2; 4. issues the ID-JAG. @@ -84,9 +87,9 @@ audience=https://gateway.example.com client_id=mecatl + client assertion ``` -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance `agent:` identifier for the acting instance, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance identifier for the acting instance, such as `ag_7hq2x9`: a plain string with no colon, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. -**AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, that `client_id` matches the authenticated client, and that `jti` is unused (§4.4.1). It then maps the AS-1 subject to a native user: +**AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, and that `client_id` matches the authenticated client (§4.4.1), and rejects a reused `jti` (RFC 7523 §3). A lost redemption response is recovered with a new ID-JAG. It then maps the AS-1 subject to a native user: | Mode | Mapping | | --- | --- | @@ -99,7 +102,7 @@ AS-2 issues `V`: `typ=at+jwt`, `sub` = native user, the ID-JAG's `act` unchanged **vMCP and the upstream credential.** vMCP validates `V` under RFC 9068 and, if the request uses a `resource` parameter, RFC 8707. It binds its session to the user and `act.sub`. It loads the upstream credential keyed by (gateway, native user, provider). A missing credential answers "needs authorization", which the broker returns as the existing `mcp_authorization_pending` outcome. Consent is checked at redemption, not here. There is one credential record per (user, provider), refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. -The broker must propagate the operation ID, such as `op-pr42-001`, with each MCP request, and vMCP must log it with its decision as reported, unverified trace context. A cached `V` serves several calls, so its `(iss, jti)` cannot identify one operation; with a single-level `act`, the operation ID is also what leads from a gateway record to the delegation in Mecatl's records. The mechanism, a trace header or MCP request `_meta`, is open, and so is the persistence ordering of these correlation records. +The broker must propagate the operation ID, such as `op-pr42-001`, with each MCP request, and vMCP must log it with its decision as reported, unverified trace context. A cached `V` serves several calls, so its `(iss, jti)` cannot identify one operation; with a single-level `act`, the operation ID is also what leads from a gateway record to the delegation in Mecatl's records. The broker sends them in each MCP request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`, and as the request parameters `mecatl_operation` and `mecatl_attempt` at AS-1. The persistence ordering of these correlation records is open. **Open.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome. No callback or private mapping-response fields are specified. Candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. @@ -116,13 +119,15 @@ Test the session-open exchange: - AS-1 rejects access tokens it did not issue, tokens for another audience, expired or revoked tokens, a second conversion of the same `jti`, and requests for wider scope. - AS-1 accepts a Mecatl-API-audience token only from client `mecatl`; another client presenting the same token is rejected. -- The refresh token record names the session and its owner; session close revokes it; presenting it at the refresh-token grant returns `invalid_grant`; a retried open with the same `jti`, client, and session returns the existing refresh token. +- The refresh token record names the session and its owner; session close revokes it; presenting it at the refresh-token grant returns `invalid_grant`; a retried open with the same `jti`, client, and session revokes the first refresh token and returns a new one; a retry after the session closed, after its refresh token was revoked, past the source token's `exp`, or for another session is refused. +- A crash between writing the converted-`jti` record and the refresh-token record leaves neither. +- The response echoes the `mecatl_session` entry; an owner that differs from the token's `sub_id`, or a session owned by another user, is refused. Test ID-JAG issuance: - AS-1 rejects an expired, revoked, or foreign-client refresh token, and a refresh token whose session is closed or past its hard expiry. - AS-1 rejects an untrusted or expired SVID, an SVID with an audience not accepted by both AS-1 and the broker, and an instance not registered to the refresh token's session. -- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `agent:` identifier of the validated actor and which has no nested `act` and has `act.iss` set to AS-1's issuer. A caller cannot change `sub` or `act` through request parameters. +- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `ag_` identifier of the validated actor and which has no nested `act` and has `act.iss` set to AS-1's issuer. A caller cannot change `sub` or `act` through request parameters. Test AS-2 redemption: @@ -130,6 +135,19 @@ Test AS-2 redemption: - In external mode, AS-2 rejects a subject with no consent link and a revoked link. A link for another AS-1 issuer or client does not match. - AS-2 never resolves through `sub_id` or email; a matching `sub_id` without a mapping fails closed. - `V` carries the native user, the ID-JAG's single-level `act` unchanged, `client_id=mecatl`, and the vMCP audience, with no refresh token. +- After a lost redemption response, the broker mints a new ID-JAG instead of resending the first. + +Test the `V` cache: + +- The broker treats `V` as expired a configured skew margin before `exp`. +- On a 401 `invalid_token` from the vMCP, the broker drops `V` and repeats the exchange once; a second 401 fails the call. +- The `ag_` identifier in `act` is unchanged after the instance's SVID is renewed. + +Test schedule authority: + +- Setting up a schedule converts a fresh access token with a `mecatl_schedule` entry into a separate record. +- A schedule's record is refused as the subject token of a session's ID-JAG exchange, and a session's refresh token is refused for a scheduled run. +- A scheduled run whose owner differs from the owner recorded with the schedule's authority is refused. Test vMCP and the upstream credential: @@ -167,9 +185,12 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | AS-1 and AS-2 audit records following the [AuditEvent profile](audit.md#auditevent-profile), including sign-in, access-token issuance, and refresh-token revocation at AS-1 | No; the authorization server records no audit events. The toolhive-core audit logger and its sink already exist | Not sized | Identity work | | Revoke a broker refresh token and its record, by an RFC 7009 endpoint or, while the broker runs inside mecak8s, in process | No; storage revokes by refresh-token family, but no endpoint is wired | Small | Identity work | | Revoke all of a user's grants, including schedule authority | No; no index from user to refresh-token families | Medium | Identity work | -| Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | +| Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out, its expiry recorded, and refreshes single-flight per user | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | | AS-1 and the vMCP record the operation and attempt IDs the broker passes with each exchange and MCP request, marked as reported | No | Small | Identity work | | Session-open owner check: compare the session owner the broker passes with the token's `sub_id`, and keep the owner on the refresh-token record | No | Small | Identity work | +| Parse `mecatl_session` and `mecatl_schedule` `authorization_details` at token exchange and echo the granted entry | No | Small | Identity work | +| Accept a client assertion whose `aud` is the issuer identifier alone, as draft-ietf-oauth-rfc7523bis requires | Unknown; verify | None if accepted | Identity work | +| Request a refresh token from the upstream IdP at sign-in (`offline_access`, or the IdP's equivalent) | Unknown; verify per IdP | Configuration | Identity work | | Reject a token whose upstream session records were deleted | No; the lookup returns an empty credential map instead of failing | Small | Identity work | | Refresh token bound to the AS-1 login session (`sid`), so that signing out at AS-1 revokes it | No; `tsid` is per authorization request, not per login | Large | Later | | Receive logout and account events from the company IdP (OIDC Back-Channel Logout, Okta event hooks, Shared Signals) | No | Large | Later | @@ -215,7 +236,7 @@ In external mode the consent link maps AS-1's subject to AS-2's native user and ## Definition provenance -- Mecatl: `AgentDefSource` stays storage-independent: provenance profiles travel as metadata, not as additions to the core interface. Each profile defines required fields, schema, hashing rules, and revision semantics. `AgentDefSource` resolution returns source evidence; the trusted resolver produces the [provenance record](agent-definitions.md#definition-provenance) with both digests, rejects profiles the operator has not allowed, and registration carries the record. Prove a locally modified file yields a source digest that does not match its claimed commit's content. +- Mecatl: `AgentDefSource` stays storage-independent: provenance profiles travel as metadata, not as additions to the core interface. Each profile defines required fields, schema, hashing rules, and revision semantics. `AgentDefSource` resolution returns source evidence; the definition loader produces the [provenance record](agent-definitions.md#definition-provenance) with both digests, rejects profiles the operator has not allowed, and registration carries the record. Prove a locally modified file yields a source digest that does not match its claimed commit's content. - Broker: validate the record against its profile contract and the operator's evidence requirements, retain it with the instance record, and mark verified versus asserted facts in audit. ## Workload-specific verification @@ -284,7 +305,7 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to ## Run owner checks -- The broker refuses a call whose reported run owner differs from the subject of the grant in use, and records the denial. +- The broker refuses a call whose reported run owner differs from the owner recorded with the grant in use, the company identity, and records the denial. - A child run's owner equals its parent run's owner; the broker refuses a delegation that changes it. - Two users' calls through the same instance never share a cached `V`, ID-JAG, or vMCP session ID. - Signing out everywhere revokes only that user's grants and cache entries. @@ -297,7 +318,10 @@ Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#si - Signing out everywhere cancels live runs, parked tool calls, and in-flight tool calls; deletes the broker sessions; revokes their refresh tokens at AS-1; drops cached ID-JAGs and `V`; and revokes every schedule's authority. Transcripts remain. - It reaches sessions served by another replica. - After it, an ID-JAG exchange with any revoked refresh token fails, and each schedule's next run ends with "needs owner action". -- When the upstream IdP refuses a refresh, AS-1 revokes all the user's grants, including schedule authority, and the exchange fails. A client's own sign-out does not remove the upstream token AS-1 re-checks. Re-checks happen at most once per interval. +- When the upstream IdP returns `invalid_grant` before the upstream token's recorded expiry, or when no expiry is recorded, AS-1 revokes all the user's grants, including schedule authority, and the exchange fails. +- When it returns `invalid_grant` at or after the recorded expiry, AS-1 ends the user's grants with a "needs owner action" outcome, not a revocation. +- A network error or 5xx fails that exchange and revokes nothing. +- Concurrent exchanges for one user cause one upstream refresh. A client's own sign-out does not remove the upstream token AS-1 re-checks. Re-checks happen at most once per interval. ## Work breakdown @@ -318,7 +342,7 @@ The **identity work** establishes who acts and records it: The **authority work** decides what each agent may do: -- the definition loader resolving each definition's ceiling into permitted MCP server/tool pairs, and the operator's loader configuration capping the ceiling of definitions from other sources; +- the definition loader resolving each loaded definition's ceiling into permitted MCP server/tool pairs; a definition the broker did not load gets no ceiling of its own and inherits the ceiling of the instance that creates it; - each instance's effective ceiling, its definition ceiling narrowed by its parent's; - Cedar policy generation and a ceiling check on every call; - grants scoped to a run the user started, replacing the session's standing refresh token, so that sessions of absent users carry no authority; diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 080fc3b602..e1430b5961 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -18,7 +18,7 @@ For every call, the broker evaluates the exact registered server/tool pair that Workload authentication establishes which mecak8s service is calling. The instance JWT-SVID establishes which registered agent instance is acting. The broker validates both credentials against configured trust, audiences, expiry, and permitted presenters, and checks the instance's registered session association. Neither credential alone establishes the initiating user, current run, or delegated authority. -The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. The [run owner](run-owner.md) is checked against the subject of the grant in use and never selects it. mecak8s reports each delegation to the broker when it happens, so the broker can record it as a [lineage event](audit.md#lineage). Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. +The broker binds each call to its **execution context**: workload, acting instance, session, initiating user, run, and any delegation. Workload, instance, and session are verified from credentials and registration. The initiating user comes from Alice's sign-in at AS-1, not from a harness report. At session open the broker obtains a session-bound broker refresh token from AS-1, and every later ID-JAG names the user from that token's AS-1 record, not from caller input. See [session open](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token). User, run, and delegation are not inputs to the ceiling check. Run and delegation context remain trusted harness reports. The [run owner](run-owner.md) is checked against the owner recorded with the grant in use and never selects it. mecak8s reports each delegation to the broker when it happens, so the broker can record it as a [lineage event](audit.md#lineage). Enforcing task-specific delegated bounds requires a verified per-operation binding for those reported fields. Missing or inconsistent context fails closed. [Instance registration](agent-definitions.md#registering-instances) supplies identity records, not authorization for a run or tool call. ## Delegated authority @@ -30,7 +30,7 @@ For example, a read-only reviewer spawns a child from a `deployer` definition wh The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Root eligibility bounds that inflation: every parent chain ends at a root the operator allows, so no child can exceed that root's ceiling, which the [definition loader](agent-definitions.md#loading-definitions) set. Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. -The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `agent:` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). +The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `ag_` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). ### Chained exchange @@ -65,7 +65,7 @@ Agent-instance identity does not prevent a prompt-injected agent from misusing o ## User token and the authorized request -Mecatl validates Alice's AS-1 access token for its API and checks current session ownership. The broker turns it into a session-bound broker refresh token at AS-1; no company IdP token reaches AS-1 or the broker. AS-1 issues an ID-JAG naming Alice and the acting instance only while the session is open and the instance is registered to it, and the broker redeems that ID-JAG at AS-2 for `V`. Neither the AS-1 access token, the refresh token, nor the SVID reaches AS-2. Model-generated arguments cannot choose or override identity evidence. The exchange itself is described [below](#authorization-credentials-and-dispatch); see [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization) and the steps after it for token types, fields, and validation. +Mecatl validates Alice's AS-1 access token for its API and checks current session ownership. The broker turns it into a session-bound broker refresh token at AS-1. AS-1 keeps the company IdP's refresh token for its upstream re-check; no company IdP token leaves AS-1. AS-1 issues an ID-JAG naming Alice and the acting instance only while the session is open and the instance is registered to it, and the broker redeems that ID-JAG at AS-2 for `V`. Neither the AS-1 access token, the refresh token, nor the SVID reaches AS-2. Model-generated arguments cannot choose or override identity evidence. The exchange itself is described [below](#authorization-credentials-and-dispatch); see [per-operation authorization](credentials-and-lifecycle.md#6-per-operation-authorization) and the steps after it for token types, fields, and validation. The broker checks the actual MCP server/tool pair against the effective ceiling before any exchange or dispatch. User and connection authorization are enforced downstream: AS-2 redeems the ID-JAG only for a mapped user (embedded) or a consent-linked user (external), and vMCP dispatches only with the user's stored credential, keyed by (gateway, native user, provider). The broker does not check provider connections itself: it could only copy the gateway's data, and the provider enforces its own permissions on the request. The final request, including arguments, must remain bound to the checks and dispatch, but recording or binding arguments does not mean the ceiling check authorizes their resource-specific meaning. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index de8a636ef4..5e1fefebeb 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -37,11 +37,11 @@ The broker authenticates at both servers as one OAuth client, `mecatl`, with `pr AS-2 receives neither Alice's AS-1 access token, `RT-A42`, nor the SVID. It receives only the ID-JAG. -AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different trust domains, and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one trust domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across trust domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. +AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different administrative domains, and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one administrative domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across trust domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. ## Following one call -Alice, AS-1 subject `u-alice-91`, owns harness session `hostS42`. Her session is already open: the broker holds `RT-A42` for broker session `A42`. Her reviewer agent instance asks to read PR 42 in `acme/payments`. The broker checks the session, workload, actor, effective ceiling, and route. With no cached `V` for `(A42, reviewer, gateway, Alice)`, it exchanges `RT-A42` and the reviewer SVID at AS-1 for an ID-JAG, redeems the ID-JAG at AS-2 for `V`, and calls the vMCP. The gateway in this example is external, at `https://gateway.example.com`. +Alice, AS-1 subject `u-alice-91`, owns harness session `hostS42`. Her session is already open: the broker holds `RT-A42` for broker session `A42`. Her reviewer agent instance asks to read PR 42 in `acme/payments`. The broker checks the session, workload, actor, run owner, route, and, with the authority work, the effective ceiling. With no cached `V` for `(A42, reviewer, gateway, Alice)`, it exchanges `RT-A42` and the reviewer SVID at AS-1 for an ID-JAG, redeems the ID-JAG at AS-2 for `V`, and calls the vMCP. The gateway in this example is external, at `https://gateway.example.com`. ```mermaid sequenceDiagram @@ -52,11 +52,11 @@ sequenceDiagram participant P as GitHub Note over B: Holds RT-A42 from session open - B->>B: Broker checks
Session, workload, actor, effective ceiling and route + B->>B: Broker checks
Session, workload, actor, run owner, route
(ceiling with the authority work) Note over B,P: Any denial before provider dispatch stops the request opt No cached V for (A42, reviewer, gateway, Alice) B->>A1: (1) RFC 8693 + private_key_jwt
subject_token = RT-A42, actor_token = reviewer SVID
requested_token_type = id-jag, audience = AS-2 - A1->>A1: Resolve RT-A42 record: session open, not expired
Validate SVID, reviewer registered to A42
Build act + A1->>A1: Resolve RT-A42 record: session open, not expired
Re-check company-IdP grant if due
Validate SVID, reviewer registered to A42
Build act A1-->>B: (2) ID-JAG (sub u-alice-91, act, client_id mecatl) B->>A2: (3) RFC 7523 assertion = ID-JAG + private_key_jwt A2->>A2: Check typ, aud, client_id, jti
Resolve native user
External mode: require consent link @@ -95,6 +95,7 @@ AS-1 checks: - **The client.** The assertion verifies against `mecatl`'s registered key. - **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user through this request. AS-1 runs in the same process as the broker core, so this guards against broker bugs, not against a compromised broker. +- **The upstream grant.** If the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first; see [upstream re-check](credentials-and-lifecycle.md#sign-out-and-revocation). - **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. - **The target.** `gateway.example.com` is an allowed AS-2 for this client. @@ -122,7 +123,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM "sub": "u-alice-91", "aud": "https://gateway.example.com", "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, + "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, "jti": "jag-pr42-001", "iat": 1800000001, "exp": 1800000121 @@ -131,7 +132,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. -`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `agent:` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted the `agent:` identifier, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. +`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `ag_` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted the `agent:` identifier, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. `aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). @@ -150,14 +151,14 @@ client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer client_assertion= ``` -The `resource` value identifies the vMCP using [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html). AS-2 checks, following ID-JAG §4.4.1: `typ` is `oauth-id-jag+jwt`; the signature verifies against the trusted AS-1 issuer's keys; `aud` is AS-2's own issuer; `client_id` matches the authenticated client `mecatl`; `jti` is unused; and the lifetime is current. It also applies its actor and resource policy. [RFC 7523 §3](https://www.rfc-editor.org/rfc/rfc7523.html#section-3) defines the base JWT-bearer validation. +The `resource` value identifies the vMCP using [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html). AS-2 checks, following ID-JAG §4.4.1: `typ` is `oauth-id-jag+jwt`; the signature verifies against the trusted AS-1 issuer's keys; `aud` is AS-2's own issuer; `client_id` matches the authenticated client `mecatl`; and the lifetime is current. It rejects a reused `jti` ([RFC 7523 §3](https://www.rfc-editor.org/rfc/rfc7523.html#section-3)), as Mecatl policy. It also applies its actor and resource policy. [RFC 7523 §3](https://www.rfc-editor.org/rfc/rfc7523.html#section-3) defines the base JWT-bearer validation. AS-2 then resolves the native user. This is where the two modes differ: | Mode | How AS-2 resolves and authorizes the user | | --- | --- | -| Embedded | ID-JAG as specified. Users log in to AS-2 through AS-1, so AS-1 is the SSO IdP AS-2 trusts (§4.1) and the subject is the shared AS-1 `sub` (§5). `u-alice-91` maps directly to native user `usr-alice-7c1d`. Consent is implicit: AS-1, AS-2, and the credential store share one trust domain. | -| External | Identity chaining across trust domains ([chaining §§2.1, 2.5](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html#section-2.1)) using the ID-JAG token profile. AS-2's users log in through the gateway's own IdP, so the §4.1 precondition, that the resource AS already trusts the IdP for SSO, does not hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer. It redeems only when the ID-JAG's `(iss, sub)` and `client_id` match a consent link, which supplies the native user (chaining §2.2, deployment-specific resolution). No just-in-time provisioning. | +| Embedded | ID-JAG as specified. Users log in to AS-2 through AS-1, so AS-1 is the SSO IdP AS-2 trusts (§4.1) and the subject is the shared AS-1 `sub` (§5). `u-alice-91` maps directly to native user `usr-alice-7c1d`. Consent is implicit: AS-1, AS-2, and the credential store share one administrative domain. | +| External | Identity chaining across administrative domains ([chaining §§2.1, 2.5](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html#section-2.1)) using the ID-JAG token profile. AS-2's users log in through the gateway's own IdP, so the §4.1 precondition, that the resource AS already trusts the IdP for SSO, does not hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer. It redeems only when the ID-JAG's `(iss, sub)` and `client_id` match a consent link, which supplies the native user (chaining §2.2, deployment-specific resolution). No just-in-time provisioning. | The external consent link records (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, grant time, and scope or providers). Alice creates it once per gateway, through AS-2's own login, by approving "Allow Mecatl" on a consent screen AS-2 generates. It is keyed on the AS-1 `sub` in the ID-JAG, never on a `sub_id` (§9.5). It stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Admin pre-consent without an independent witness of the user is rejected for the same reason. The link's creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). The gateway UI can revoke the link. @@ -172,7 +173,7 @@ An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked "sub": "usr-alice-7c1d", "aud": "https://gateway.example.com/mcp", "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "agent:7hq2x9"}, + "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, "iat": 1800000005, "exp": 1800000120, "jti": "access-pr42-001" @@ -185,7 +186,7 @@ This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT acces The broker sends the checked `pull_request_read` request with `V` to the vMCP. The vMCP validates `V` against AS-2's keys, `typ`, audience, and expiry, applies its own operation policy, and loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. GitHub sees Alice's GitHub token, not the ID-JAG, the SVID, or `V`. If no credential is stored, the vMCP answers "needs authorization" and the broker returns the existing `mcp_authorization_pending` outcome. The design does not claim that downstream credentials establish a backend-to-SaaS audit trail. -AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. Their audit references distinguish ID-JAG `jti` `jag-pr42-001` from access-token `jti` `access-pr42-001`, and neither replaces operation `op-pr42-001`. The broker propagates `op-pr42-001` with the MCP request, and the vMCP logs it as reported trace context; this is required for correlation, and the mechanism is still open. Audit requirements and evidence limits are described in [Audit](audit.md). +AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. Their audit references distinguish ID-JAG `jti` `jag-pr42-001` from access-token `jti` `access-pr42-001`, and neither replaces operation `op-pr42-001`. The broker propagates `op-pr42-001` with the MCP request, in its `_meta`, and the vMCP logs it as reported trace context; this is required for correlation. Audit requirements and evidence limits are described in [Audit](audit.md). ## Gateway administrator configuration @@ -215,7 +216,7 @@ Embedded mode runs AS-1, AS-2, and the vMCP in one deployment. It uses the same This keeps one protocol path for both modes. It does not add isolation: anyone who compromises the process holds both keys. -Embedded mode is outside the cross-domain scope that ID-JAG §9.3 states, because all three parties share one trust domain. It reuses the ID-JAG format so both modes share one protocol, not to claim a trust boundary. The section's requirement still holds: AS-1 never issues an access token from its own ID-JAG, because AS-2 issues `V`. The scope broadening §9.3 guards against is bounded because AS-2 issues `V` only for the vMCP resource and issues no refresh token. Distinct issuers, audiences, and `typ` values keep the ID-JAG from being used as an AS-2 access token. +Embedded mode is outside the cross-domain scope that ID-JAG §9.3 states, because all three parties share one administrative domain. It reuses the ID-JAG format so both modes share one protocol, not to claim a trust boundary. The section's requirement still holds: AS-1 never issues an access token from its own ID-JAG, because AS-2 issues `V`. The scope broadening §9.3 guards against is bounded because AS-2 issues `V` only for the vMCP resource and issues no refresh token. Distinct issuers, audiences, and `typ` values keep the ID-JAG from being used as an AS-2 access token. ## Outside this chapter @@ -225,9 +226,9 @@ Embedded mode is outside the cross-domain scope that ID-JAG §9.3 states, becaus ## Integration requirements and evidence limits -AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state, hard expiry, and the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `agent:` identifier. +AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state and hard expiry, re-checks the company-IdP grant when due, checks the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `ag_` identifier. -AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. +AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, reject a reused `jti` (RFC 7523 §3), and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. The vMCP must validate `V`, bind its session to the user and `act.sub`, and load provider credentials keyed by (gateway, native user, provider). Do not add a callback or response fields that expose AS-2's private native mappings to the broker. The design does not claim a backend-to-SaaS audit trail. diff --git a/docs/drafts/agent-identity/run-owner.md b/docs/drafts/agent-identity/run-owner.md index 8778f8e94f..b5652340de 100644 --- a/docs/drafts/agent-identity/run-owner.md +++ b/docs/drafts/agent-identity/run-owner.md @@ -11,7 +11,7 @@ The **run owner** is the person who started a run. mecak8s records it when the r The identity work adds the run owner as follows: 1. **On every call.** mecak8s sends the run ID and the run owner with each `InvokeTool`, beside the session reference, operation ID, and agent credential. Both become part of the call's [execution context](mcp-authorization.md#authentication-and-execution-context). -2. **Checked, never used as a source.** The ID-JAG's `sub` still comes only from the record of the grant the broker uses. The broker checks that the reported run owner equals that grant's subject; today, that is the session owner. A mismatch refuses the call and is recorded as a denial. A run-owner report therefore cannot choose whose credentials are used. The check is a single function, so a later membership check replaces only its body. +2. **Checked, never used as a source.** The ID-JAG's `sub` still comes only from the record of the grant the broker uses. The broker checks that the reported run owner equals the owner recorded with that grant, the company identity, not AS-1's subject; today, that is the session owner. A mismatch refuses the call and is recorded as a denial. A run-owner report therefore cannot choose whose credentials are used. The check is a single function, so a later membership check replaces only its body. 3. **Grants keyed by session and user.** The broker and AS-1 store the session's broker refresh token under (session, user), with exactly one entry per session for now. The session's creator is recorded as a separate fact rather than as a uniqueness rule on the grant. 4. **The user in every per-user key.** The broker's cache of `V` and of ID-JAGs, and its store of vMCP session IDs, are keyed by user as well as session, instance, and gateway. The user comes from the grant's record, not from the reported run owner, and revoking a grant drops its entries. The vMCP already binds each session to the user. 5. **Revocation by user.** [Sign out everywhere](credentials-and-lifecycle.md#sign-out-and-revocation) revokes the user's grants and drops their cache entries. Closing a session is a separate action that revokes every grant in it. From a429ae1c6c7bdeba9edf56185b20a7a65b2f6050 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:37:01 +0200 Subject: [PATCH 44/50] docs: align run ownership in the agent definitions chapter The run owner is the owner recorded with the grant, the company identity, not AS-1's subject. A child run's owner is reported by mecak8s and checked against the delegating run's, not copied. The broker does not admit runs in the identity work: runs and delegations are reported and recorded as lineage, and delegation ceilings are a task-specific delegation bound in the authority work. Co-Authored-By: mecatl --- docs/drafts/agent-identity/agent-definitions.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 12da567bde..fe9bae0d4f 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -60,7 +60,7 @@ The table shows how the design handles each event. Creation ancestry and execution delegation are different relationships. The creation-parent link records which instance created a child. A delegation links the run that assigns work to the run that performs it. The child's identity stays stable across its runs. Each run supplies its own execution context. -The broker narrows authority along both links. At registration, it stores each instance's effective ceiling. The effective ceiling is the instance's definition ceiling intersected with the effective ceiling of its creation parent. It is the upper bound for the instance's whole life, and it never widens. At each delegation, the broker records a delegation ceiling. The delegation ceiling is the delegatee's effective ceiling intersected with the delegating run's ceiling. A root run's ceiling is its instance's effective ceiling. A child run's ceiling is its delegation ceiling. The broker checks a call made under that delegation against the delegation ceiling. See [registering instances](#registering-instances). +Once the [authority work](README.md#order-of-work) is in place, the broker narrows authority along the creation link. At registration, it stores each instance's effective ceiling. The effective ceiling is the instance's definition ceiling intersected with the effective ceiling of its creation parent. It is the upper bound for the instance's whole life, and it never widens. Narrowing along the delegation link comes later, as a task-specific delegation bound: a delegation ceiling, the delegatee's effective ceiling intersected with the delegating run's ceiling, checked for calls made under that delegation. Until then, the harness's delegation restrictions are the only bound on a delegated task below the instance's effective ceiling. See [registering instances](#registering-instances). The [domain model](../../architecture/mecatl.modelith.md) gives the broader execution model. This chapter focuses on agent identity and continuity. @@ -126,14 +126,7 @@ When the main creates the reviewer, the harness creates the child session, and m Registration establishes identity records. It does not authorize spawning, run execution, or a tool call. The harness manages execution. The broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). Creation ancestry and delegation are different links. See [relationships and lifetimes](#relationships-and-lifetimes). -The broker also keeps a run record for each run that it accepts. A root run starts when a user speaks to the main agent or a schedule fires. The owner of the run is the subject of the grant that the run uses. A child run starts when mecak8s reports a delegation and the broker accepts it. The broker copies the child run's owner from the delegating run's record, not from the report. It stores the delegation ceiling in the child run's record. A run record ends in any of these cases: the task ends, the parent run ends, an instance is revoked, the session closes, or the lease expires. A lost run record denies calls. - -Every protected call must name a run that the broker accepted. The broker denies the call unless all of these are true: - -- The run is open. -- The run belongs to the session. -- The run is bound to the instance in the presented credential. -- The subject of the grant in use owns the run. +The broker does not admit runs. mecak8s reports the run with every call and reports each delegation when it happens, through the [delegation report](credentials-and-lifecycle.md#6-per-operation-authorization); the broker records both as [lineage events](audit.md#lineage), marked as reported. A root run starts when a user speaks to the main agent or a schedule fires. Its owner is the owner recorded with the grant the run uses: the company identity, not AS-1's subject. For a child run, mecak8s reports the owner, and the broker refuses a delegation whose reported owner differs from the delegating run's. On every protected call, the broker checks that the reported run owner equals the owner recorded with the grant in use; see [run owner](run-owner.md). Run grants and task-specific delegation bounds would make runs something the broker admits; both are [authority work](README.md#order-of-work). The broker trusts the lifecycle assertions that mecak8s sends with its workload credential. mecak8s is the registrant. A ServiceAccount cannot tell trusted lifecycle code from compromised code in the same process. This design does not isolate them. The user does not come from a mecak8s report. AS-1 saw Alice sign in. See [deployment and threats](README.md#deployment-and-threats) for what a compromised mecak8s can do. A SPIRE agent inspects a workload before it issues an identity to it. The broker cannot observe mecak8s's instances in this way. It signs on mecak8s's registration. The registration is an assertion, not attestation. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. @@ -145,9 +138,9 @@ The table shows how the design handles each event. Today, a turn that was in pro | Event | What stays fixed or must be checked | | --- | --- | -| New run on the same instance | The incarnation and the definition stay fixed. The new run has its own run record. The instance credential alone does not authorize it. | +| New run on the same instance | The incarnation and the definition stay fixed. The new run has its own run ID, reported by mecak8s. The instance credential alone does not authorize it. | | Definition source edited | The instance keeps the revision that it registered with. Restoration never uses the revision that is current at the source. The broker keeps the stored ceiling and the revision record, not the source bytes. If the harness cannot rebuild the registered revision, the instance fails closed and does not resume. The user starts a new instance on the current revision. | -| Process restart or session restoration | Restore the original incarnation, definition reference, creation parent, stored effective ceiling, and run records with their delegation ceilings. Recover the unfinished execution context and its user, actor, and connection association, or fail closed. A run record that cannot be restored denies calls. | +| Process restart or session restoration | Restore the original incarnation, definition reference, creation parent, stored effective ceiling, and each unfinished run's owner. Recover the unfinished execution context and its user, actor, and connection association, or fail closed. An unfinished run whose owner cannot be restored denies calls. | | Instance credential expires | Renew the credential for the registered identity after the required checks. Renewal does not widen authority. | | Fork or clear | Register a distinct identity. A fork or a clear creates a new root instance. | | Fresh incarnation | Register a distinct identity. Reusing a session name or a definition name does not inherit the old instance's authority. | From 35acb1fe347bcaa8cfea7c01c6f1949eaf9df86c Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:44:37 +0200 Subject: [PATCH 45/50] docs: settle the credential flow's failure handling and edge cases Apply the third review round across the credential chapters: - Scheduled runs open their own broker session from the schedule grant, so the per-call exchange and cache key are unchanged. - Refusals that share invalid_request carry a reason code, mapped to a broker gRPC status and a mecak8s outcome, including a new mcp_owner_action_required outcome. - The upstream re-check keeps one IdP refresh token per user, refreshes under a lease with an in-flight marker, treats invalid_grant after an ambiguous refresh as needing owner action, and triggers the broker's sign-out-everywhere path on revocation. A revoked grant is never revived, whatever its reason. - A session has one live refresh token, losing it closes the session, and closed sessions leave a record so they are never reopened. - The ID-JAG carries resource; single use is required of embedded AS-2 and requested of external gateways. - Quotes of ID-JAG and identity chaining keep the drafts' "trust domain" with one gloss. Also: a delegation report sub-step enforced by the per-call checks, registration checks the session, exact SVID audience and bundle selection, retries with fresh client assertions, replay records kept past the clock skew, a fuller failure matrix, a configuration list, and the matching tests and ToolHive ledger rows. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 4 +- .../agent-identity/agent-definitions.md | 2 +- docs/drafts/agent-identity/audit.md | 4 +- .../credentials-and-lifecycle.md | 373 +++++++++++------- docs/drafts/agent-identity/implementation.md | 47 ++- .../agent-identity/mcp-gateway-modes.md | 6 +- docs/drafts/agent-identity/threat-model.md | 2 +- 7 files changed, 285 insertions(+), 153 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 9073905c66..ceeb57cd3a 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -108,9 +108,9 @@ The [threat model](threat-model.md) has the full table, the facts mecak8s report The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. That key belongs to the broker, ideally in a key management service; ToolHive's authorization server also accepts a SPIFFE JWT-SVID as client authentication, which could later replace the key with the broker's own workload identity. -The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different administrative domains; embedded mode, where all parties share one, reuses the format to keep one protocol path. +The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains in the OAuth sense, which this design calls administrative domains to keep "trust domain" for SPIFFE; embedded mode, where all parties share one, reuses the format to keep one protocol path. -Agent credentials are SPIFFE JWT-SVIDs. Each protected call carries two identities, as the WIMSE architecture describes: the platform workload is authenticated, and the acting agent is asserted by the broker's issuer. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. +Agent credentials are SPIFFE JWT-SVIDs. Each protected call carries two identities, comparable to the layered workload authentication the WIMSE architecture describes, except that only the first is authenticated: the platform workload is authenticated, and the acting agent is asserted by the agent-instance issuer. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. ## Order of work diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index fe9bae0d4f..6b7d9eef26 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -118,7 +118,7 @@ The broker's agent-instance issuer validates the registration records that the w - If there is a parent, the parent is registered to the same broker session. - A registration with no parent names a definition that can be a session's root. - For a definition that the broker loaded, the reference matches the broker's record. -- The subject follows the SPIFFE rules. Each segment uses only `[a-zA-Z0-9._-]`. The whole ID is at most 2048 bytes. The nesting is within the depth limit. A registration that breaks a rule fails closed. +- The subject follows the SPIFFE rules. Each definition category and name uses only `[A-Za-z0-9._-]`, is neither `.` nor `..`, and is at most 63 bytes. The nesting is at most 8 child levels below the root. The whole ID stays within the 2,048 bytes SPIFFE-ID §2.3 says not to exceed. A registration that breaks a rule fails closed; see the [SVID step](credentials-and-lifecycle.md#4-agent-instance-jwt-svid) of the credential flow. If the checks pass, the issuer associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any. It stores the effective ceiling that it computes from them. The session aggregate mints the incarnation. mecak8s persists it and reports it. The broker does not allocate incarnations. A known definition, or a name that the loop supplies, is not enough for issuance. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index c47ba71d08..d3e214fbe1 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -38,7 +38,7 @@ Every component records evidence as a [toolhive-core](https://github.com/stacklo | Who acted, and for whom | `Subjects` | `user_iss` and `user_id`: the user as the recording component's own issuer knows them. `owner_iss` and `owner_id`: the company identity from `sub_id`. `workload_iss` and `workload_sub`. `agent_spiffe_id` (inside Mecatl only), `agent_actor_id` (the opaque `ag_` identifier in `act`), and `agent_instance_ref`. `run_owner_iss` and `run_owner_id`: the [run owner](run-owner.md), reported by mecak8s. A credential reference is qualified by its issuer, and `client_id` by its authorization server. An identity the recorder did not verify never appears as `user_id`. | | Verified or reported | `Metadata.Extra["mecatl.provenance"]` | A map keyed by the `Subjects` key: `verified`, `credential-verified` when the recorder verified a credential but mecak8s reported which instance it belongs to, `record:` when taken from a retained record, `reported:`, or `unavailable`. A missing fact is marked `unavailable`, never left out silently. | | Where it originated | `Source` | The network origin, as in ToolHive: the peer address, plus `request_id` and `session_id` for joining. The calling workload's identity belongs in `Subjects`, not here. | -| Where it was recorded | `Component`, `Metadata.Extra` | `mecatl-broker`, `mecatl-as1`, or the ToolHive component name, plus the recording replica and trust domain. | +| Where it was recorded | `Component`, `Metadata.Extra` | `mecatl-broker`, `mecatl-as1`, or the ToolHive component name, plus the recording replica and deployment; components with a SPIFFE identity also record its trust domain. | | Operation and target | `Target` | toolhive-core's keys: `type=tool`, `name`, `method=tools/call`, and the MCP server. For token-endpoint events, the requested audience and resource. Request metadata appears only if allowlisted for that type. | | When | `LoggedAt`, `Metadata.Extra` | `LoggedAt` is the recorder's clock when it created the record, in UTC; a re-send keeps it. `mecatl.occurred_at` (RFC 3339 with offset) and `mecatl.clock` record when the event happened and whose clock said so. Events from different components are ordered by attempt and a per-component sequence number, never by timestamp alone. | | Correlation | `Metadata.Extra` | `mecatl.operation_id` and `mecatl.attempt_id`. A retry is a new attempt. A result that arrives after an `unknown` outcome is a new event that references the earlier one; records are never edited. The broker passes the operation and attempt IDs to AS-1 and the vMCP, which record them as reported. | @@ -62,7 +62,7 @@ The identity work needs these event families: - definition load, instance registration, and agent-instance credential issuance; - each delegation from one run to another; - broker session open and close, AS-1's conversion record at session open, and any open refused because the token's user does not own the session; per-call events point to the session-open record for the user; -- sign-out everywhere, with each run stopped, broker session deleted, refresh token revoked, and schedule authority revoked; +- sign-out everywhere, with each run stopped, broker session closed, refresh token revoked, and schedule authority revoked, and the revocation reason; - an upstream re-check failure and the revocation that follows; - ID-JAG issuance and denial at AS-1; - redemption and denial at AS-2, and consent-link creation and revocation in external mode; diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 7165fce1fd..6b4bd486cb 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -34,11 +34,12 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that | --- | --- | --- | | Identity | Company Alice `(https://login.example.com, alice-204)` | Alice at the company IdP. AS-1 represents her as subject `u-alice-91`; AS-2 as native user `usr-alice-7c1d`. | | Identity | `mecatl` | The broker's OAuth client at AS-1 and AS-2. Not a user or an agent. | -| State | `hostS42`, `A42` | Host session ID, and prose alias for the existing opaque `BrokerSessionRef`. Neither is a credential. | +| State | `hostS42`, `A42` | Host session ID, and prose alias for the broker session reference, today the opaque `mcpbroker.SessionRef`. Neither is a credential. | | State | `main`, `reviewer`, `R41`, `R42`, `D42` | Registered agent instances, their runs, and the delegation from main to reviewer. | | Reference | `op-list-prs-001`, `op-pr42-001` | Correlation IDs for two tool operations. They identify operations, not people, and authorize nothing. | | Credential | `mainSVID`, `reviewerSVID` | Registered agent-instance credentials. | | Credential | `RT-A42` | The broker refresh token for session `A42`. | +| State | Grant | AS-1's record of authority Alice delegated: the record behind a broker refresh token, or a schedule's authority. Each grant records its owner, the company identity. | | Credential | `J-main`, `J-reviewer` | ID-JAGs naming Alice and each acting agent. | | Credential | `V-main`, `V-reviewer` | AS-2 access tokens naming native Alice and each acting agent. | @@ -80,7 +81,7 @@ sequenceDiagram Note over U,A1: (2)(3) Session open, on the session's first request U->>H: PR review request + AS-1 access token H->>H: Validate token, check ownership of hostS42 - H->>B: OpenSession: workload JWT + AS-1 access token
+ owner of hostS42 (once) + H->>B: OpenSession: workload credential + AS-1 access token
+ owner of hostS42 (once) B->>A1: RFC 8693: subject_token = AS-1 access token
requested_token_type = refresh_token
authorization_details: session A42, owner
(5) client mecatl, private_key_jwt A1->>A1: Token issued by AS-1 for the Mecatl API, active, jti not used for another session
sub_id = reported owner, A42 unowned or owned by the same user A1-->>B: RT-A42 (bound to mecatl, A42, Alice, hard expiry, no rotation) @@ -88,15 +89,15 @@ sequenceDiagram rect rgb(245, 245, 245) Note over H,I: (4) Main bootstrap, before R41 - H->>I: registerInstance(main, definition reference, A42), workload JWT + H->>I: registerInstance(main, definition reference, A42), workload credential I->>I: Definition loaded, no parent, so it must be root-eligible I-->>H: mainRef + canonical main subject - H->>I: requestSVID(mainRef), workload JWT + H->>I: requestSVID(mainRef), workload credential I-->>H: mainSVID end Note over H,G: (6) Main call: pull_request_list - H->>B: InvokeTool: workload JWT, A42, run R41, run owner Alice,
op-list-prs-001, agentJwtSvid = mainSVID + H->>B: InvokeTool: workload credential, A42, run R41, run owner Alice,
op-list-prs-001, agentJwtSvid = mainSVID B->>B: Check session, workload, actor, run owner = grant owner, route
(ceiling with the authority work) opt No cached V for (A42, main, gateway, Alice) B->>A1: (7) RFC 8693: subject_token = RT-A42
actor_token = mainSVID
requested_token_type = id-jag, audience = AS-2
(5) client mecatl, private_key_jwt @@ -116,15 +117,15 @@ sequenceDiagram rect rgb(245, 245, 245) Note over H,I: Reviewer bootstrap, after the list succeeds H->>H: Delegate review: D42 / R42 - H->>I: registerInstance(reviewer, definition reference global/code-reviewer,
parentRef = mainRef, A42), workload JWT + H->>I: registerInstance(reviewer, definition reference global/code-reviewer,
parentRef = mainRef, A42), workload credential I-->>H: reviewerRef H->>B: ReportDelegation D42: A42, R41 to R42, run owner Alice B->>B: Child run owner = parent run owner, parent in the same session tree - H->>I: requestSVID(reviewerRef), workload JWT + H->>I: requestSVID(reviewerRef), workload credential I-->>H: reviewerSVID end - H->>B: InvokeTool: workload JWT, A42, run R42, run owner Alice,
op-pr42-001, agentJwtSvid = reviewerSVID + H->>B: InvokeTool: workload credential, A42, run R42 via D42, run owner Alice,
op-pr42-001, agentJwtSvid = reviewerSVID Note over B,G: Same checks and cache-miss path with actor_token = reviewerSVID
J-reviewer and V-reviewer: act = reviewer B-->>H: Sanitized outcome H-->>U: Review outcome @@ -139,13 +140,13 @@ The diagram follows the successful path. Other outcomes: Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists every failure, and [renewal triggers](#renewal-triggers) when each credential is replaced. -Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §5; broker calls are gRPC and use gRPC status codes. +Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §8; broker calls are gRPC and use gRPC status codes. Where one error code covers several causes, a reason code tells them apart; see [error reasons](#error-reasons). ### (1) AS-1 access token -AS-1 is Mecatl's login authorization server. mecak8s's protected-resource metadata names AS-1, and the TUI and Studio already discover their issuer there, so neither client changes. AS-1 does not hold passwords: it redirects Alice to the company IdP as an ordinary OIDC client. The company IdP administrator registers one web application, AS-1, with AS-1's callback as redirect URI. +AS-1 is Mecatl's login authorization server. mecak8s's protected-resource metadata names AS-1, and the TUI and Studio already discover their issuer there, so neither client changes. AS-1 does not hold passwords: it redirects Alice to the company IdP as an ordinary OIDC client. The company IdP administrator registers one web application, AS-1, with AS-1's callback as redirect URI. The company IdP therefore sees only AS-1 as the application, and per-application IdP policies can no longer distinguish the TUI from Studio. -AS-1 asks the company IdP for a refresh token at sign-in, because the [upstream re-check](#sign-out-and-revocation) needs one. How depends on the IdP: the `offline_access` scope, which OIDC Core §11 requires to be sent with `prompt=consent`; `access_type=offline` at Google; or IdP configuration that issues refresh tokens to the AS-1 application. +AS-1 asks the company IdP for a refresh token at sign-in, because the [upstream re-check](#upstream-re-check) needs one. How depends on the IdP: the `offline_access` scope, which OIDC Core §11 requires to be sent with `prompt=consent`; `access_type=offline` at Google; or IdP configuration that issues refresh tokens to the AS-1 application. The client receives an access token for the Mecatl API and keeps its own refresh token: the TUI in the OS keyring, Studio in sealed server-side cookies. @@ -167,7 +168,7 @@ The client receives an access token for the Mecatl API and keeps its own refresh **Checks, at mecak8s** (RFC 9068 §4): -1. The header `typ` is `at+jwt`, and `alg` is on the configured allowlist, which excludes `none` and every symmetric algorithm. +1. The header `typ` is `at+jwt` or `application/at+jwt`, and `alg` is on the configured allowlist, which excludes `none` and every symmetric algorithm. 2. `kid` selects a key from AS-1's configured JWKS, and the signature verifies. A token never chooses where keys come from. 3. `iss` is exactly AS-1's issuer. 4. `exp` has not passed and `iat` is not in the future, each allowing the configured clock skew. @@ -178,9 +179,9 @@ The client receives an access token for the Mecatl API and keeps its own refresh **Output.** An authenticated request from owner `(https://login.example.com, alice-204)`. -**Failures.** Checks 1–7 return HTTP 401 with `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750 §3.1); the client refreshes its access token and retries. Check 8 returns 403 and is not retryable. +**Failures.** Checks 1–7 return HTTP 401 with `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750 §3.1). The client refreshes its access token and retries once; a second failure is shown to the user. Check 8 returns 403 with the Mecatl API's `not_session_owner` error body and is not retryable. RFC 6750 has no error code for an ownership failure, so the body carries it. -**Why.** An `id_token` is not accepted here: its audience is the client, not the API. Session ownership is keyed by the company identity in `sub_id`, not by AS-1's own `(iss, sub)`, so existing owners keep matching without migration and the owner stays Alice's stable human identity. The company IdP sees only AS-1 as the application, so per-application IdP policies can no longer distinguish the TUI from Studio. +**Why.** An `id_token` is not accepted here: its audience is the client, not the API. Session ownership is keyed by the company identity in `sub_id`, not by AS-1's own `(iss, sub)`, so existing owners keep matching without migration and the owner stays Alice's stable human identity. **Does not prove.** That the caller is the TUI: both clients are public, so `client_id` names the client the token was issued to, not the one presenting it. @@ -188,7 +189,7 @@ The client receives an access token for the Mecatl API and keeps its own refresh ### (2) Workload credential -**Input.** Every call from mecak8s to the broker and the agent-instance issuer carries a projected ServiceAccount token, over TLS and outside model context: +**Input.** Every call from mecak8s to the broker and the agent-instance issuer carries the workload credential, a projected ServiceAccount token, over TLS and outside model context: ```json { @@ -206,9 +207,9 @@ The client receives an access token for the Mecatl API and keeps its own refresh } ``` -**Checks, at the broker and the issuer,** offline against the cluster's keys: +**Checks, at the broker and the agent-instance issuer,** offline against the cluster's keys: -1. The token is a projected token. A legacy Secret-based token, with issuer `kubernetes/serviceaccount` and no `exp`, is rejected. +1. The token is a projected token. A legacy Secret-based token, which has no `exp`, is rejected. 2. `alg` is on the allowlist, and the signature verifies against the cluster's JWKS. The JWKS is pinned in configuration or fetched by OIDC discovery from the configured issuer, never from a URL in the token. 3. `iss` is exactly the configured cluster issuer. 4. `aud` contains the broker's audience. A token with the Kubernetes API server's audience is rejected. @@ -219,17 +220,19 @@ The `kubernetes.io` claims are recorded for audit and never decide anything. **Output.** The authenticated workload `system:serviceaccount:mecatl:mecak8s`. -**Failures.** Checks 1–5 return `UNAUTHENTICATED`; check 6 returns `PERMISSION_DENIED`. The kubelet rewrites the projected token before it expires, so mecak8s retries with the new file contents. +**Failures.** Checks 1–5 return `UNAUTHENTICATED`; check 6 returns `PERMISSION_DENIED`. Only an expired token is retryable: the kubelet rewrites the projected token before it expires, and mecak8s retries with the new file contents. Every other failure is a configuration fault. **Why.** It identifies the mecak8s service, so the broker serves sessions only to the workload they are bound to. [SPIRE alternatives](#workload-authentication) replace this credential without changing its role. -**Does not prove.** That the presenter is that pod: it is a bearer token. Offline validation also does not notice a deleted pod before `exp`; short lifetimes bound that. It identifies neither Alice nor an agent instance. +**Does not prove.** That the presenter is that pod: it is a bearer token. Offline validation also does not notice a deleted pod before `exp`; short lifetimes bound that. Kubernetes TokenReview, called on each request, would notice it; it is not selected. The credential identifies neither Alice nor an agent instance. + +**Evidence.** The broker and the agent-instance issuer record the authenticated ServiceAccount and pod with each decision they make. ### (3) Session open and the broker refresh token -At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, with its workload credential and the owner it holds for `hostS42`. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. Clients refresh their access token before each new session open, so the token is fresh and not yet used to open a session. +At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, with its workload credential and the owner it holds for `hostS42`. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. Clients refresh their access token before each new session open, so the token is fresh and not yet used to open a session. The broker runs at most one `OpenSession` per broker session at a time. -**Input.** The broker exchanges the token at AS-1 with RFC 8693, authenticating as client `mecatl` ([step (5)](#5-broker-client-authentication)). RFC 8693 has no parameter for the broker session or its owner, so the request carries both in an RFC 9396 `authorization_details` entry of type `mecatl_session`. The owner field is deployment-defined context for the owner check, not an authorization the user granted. Form encoding is shown decoded: +**Input.** The broker exchanges the token at AS-1 with RFC 8693, authenticating as client `mecatl` ([step (5)](#5-broker-client-authentication)). RFC 8693 has no parameter for the broker session or its owner, so the request carries both in an RFC 9396 `authorization_details` entry of type `mecatl_session`. RFC 9396 does not mention token exchange, but allows `authorization_details` wherever `scope` is used (§3) and with any grant type (§11.2). The owner field is deployment-defined context for the owner check, not an authorization the user granted. The `mecatl_session` type requires AS-1 to echo the entry unchanged. Form encoding is shown decoded: ```text POST https://broker.example.com/as/oauth/token @@ -247,15 +250,13 @@ client_assertion= 1. Client authentication as `mecatl` ([step (5)](#5-broker-client-authentication)). 2. The client may use the token-exchange grant. -3. The request is well formed: the two token types above, no `actor_token`, and exactly one `mecatl_session` entry with a session and an owner. +3. The request is well formed: the two token types above, no `actor_token`, no `scope`, and exactly one `mecatl_session` entry with a session and an owner. 4. The subject token is an AS-1 access token: signature, `iss`, `exp`, not revoked at AS-1, `aud` is the Mecatl API, and `client_id` is the TUI or Studio. Accepting a Mecatl-API token from `mecatl` is a narrow policy for this one client, not a general audience bypass. -5. No wider scope than the original grant is requested. -6. The company identity in the token's `sub_id` equals the owner in the entry. -7. Session `A42` has no recorded owner yet, or its recorded owner is that same identity. A session that was opened and then closed is refused: session IDs are never reused. -8. The token's `jti` has not opened a session before, or this is a retry: the same `jti`, client, and session, within the source token's `exp`, while `A42` is open and its refresh token has not been revoked. -9. AS-1 writes the record of the converted `jti` and the refresh-token record in one transaction. On a retry, the same transaction revokes the refresh token issued the first time. +5. The company identity in the token's `sub_id` equals the owner in the entry. +6. Session `A42` has never been closed, and has no recorded owner yet or its recorded owner is that same identity. Session IDs are never reused. +7. The token's `jti` has not opened a session before, and `A42` has no live refresh token. The one exception is a retry: the same `jti`, client, and session, within the source token's `exp`, while `A42` is open and its refresh token has not been revoked. -**Output.** RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1). AS-1 echoes the granted `authorization_details` (RFC 9396 §7): +**Output.** In one transaction, AS-1 records the converted `jti`, records the owner of `A42` if this is the first open, writes the refresh-token record, and, on a retry, revokes the refresh token it issued the first time. RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1), and AS-1 echoes the `mecatl_session` entry (RFC 9396 §7): ```json { @@ -268,7 +269,9 @@ client_assertion= } ``` -`expires_in` is illustrative; the hard expiry is not selected. `RT-A42` is opaque to the broker. Its meaning lives in an AS-1 record that binds it to client `mecatl`, to Alice as `u-alice-91`, to her company identity as the session owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. The broker checks that the echoed entry matches the one it sent. +`expires_in` is illustrative; the hard expiry is not selected. `RT-A42` is opaque to the broker. Its meaning lives in an AS-1 record, a **grant**, that binds it to client `mecatl`, to Alice as `u-alice-91`, to her company identity as the grant's recorded owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. + +The broker checks that the echoed entry equals the one it sent, then stores `RT-A42` with the echoed owner in its shared session storage, so any replica can use it and run the [run-owner check](#6-per-operation-authorization) without asking AS-1. If the echo differs, the broker revokes `RT-A42` and fails the open. The existing broker session record under `A42` keeps the workload and host-session binding; the human authority lives in `RT-A42`, not in a broker-local human record. **Failures.** @@ -276,26 +279,26 @@ client_assertion= | --- | --- | --- | | 1 | 401 `invalid_client` | No; a configuration or key fault. | | 2 | 400 `unauthorized_client` | No. | -| 3 | 400 `invalid_request`, or `invalid_authorization_details` for a malformed entry | No; a broker bug. | +| 3 | 400 `invalid_request`, `invalid_scope` for a `scope` parameter, or `invalid_authorization_details` for a malformed entry | No; a broker bug. | | 4 | 400 `invalid_request` (RFC 8693 §2.2.2) | After the client refreshes its access token. | -| 5 | 400 `invalid_scope` | No. | -| 6, 7 | 400 `invalid_request`, recorded with the reason | No; mecak8s paired the token with the wrong session. | -| 8, `jti` used for another session | 400 `invalid_request` | After the client refreshes its access token. | -| Lost response | None received | Yes: resend the same request. Check 8 accepts it and replaces the first refresh token. | +| 5 | 400 `invalid_request`, recorded with the reason | No; mecak8s paired the token with the wrong session. RFC 8693 calls a subject token unacceptable by policy `invalid_request`. | +| 6 | 400 `invalid_authorization_details` (RFC 9396 §5), recorded with the reason | No; the session belongs to someone else or is closed. | +| 7 | 400 `invalid_request` | After the client refreshes its access token. | +| Lost response | None received | Yes: resend the same `subject_token` and `authorization_details` with a new client assertion. Check 7 accepts it as a retry and replaces the first refresh token. | -mecak8s reports any of these to the client as a failed session open. It answers failures of checks 4 and 8 with 401 `invalid_token`, so the client refreshes its access token and tries again. +mecak8s reports any of these to the client as a failed session open. It answers failures of checks 4 and 7 with 401 `invalid_token`, so the client refreshes its access token and tries once more. -**Why.** One conversion per `jti` stops a leaked access token from opening a second session. A retry issues a new refresh token rather than returning the first, because AS-1 stores only a hash of each refresh token and cannot return it again; revoking the first in the same transaction keeps one live refresh token per session. Writing the `jti` record and the refresh-token record together means a crash cannot leave a converted `jti` with no refresh token, or a refresh token whose conversion was not recorded. +If the broker crashes after AS-1 issued `RT-A42` but before storing it, nothing holds that refresh token. mecak8s retries the open when the broker returns: within the source token's `exp` the retry revokes it; later, AS-1 refuses the open and the orphan dies when the session is closed or its hard expiry passes. -`RT-A42` is accepted only as the subject token of an ID-JAG exchange; presented at the refresh-token grant, it returns `invalid_grant`, so the broker cannot mint Mecatl API tokens as Alice. `RT-A42` does not rotate: client authentication already binds it to the broker (RFC 9700 §4.14.2, RFC 9449 §5), and rotation would make concurrent exchanges from several broker replicas look like token reuse. +**Why.** One conversion per `jti` stops a leaked access token from opening a second session. A retry issues a new refresh token rather than returning the first, because AS-1 stores only a hash of each refresh token and cannot return it again. Revoking the first in the same transaction keeps one live refresh token per session, and writing all the records together means a crash cannot leave a converted `jti` with no refresh token, or a refresh token whose conversion was not recorded. -Checks 6 and 7 exist because the broker supplies `A42`, and only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. AS-1 records the owner at the first open and refuses any later open of `A42` for a different user. Both checks compare facts inside Mecatl, so they catch mistakes, not a compromised mecak8s, which could report a consistent lie. +`RT-A42` is accepted only as the subject token of an ID-JAG exchange; presented at the refresh-token grant, it returns `invalid_grant`, so the broker cannot mint Mecatl API tokens as Alice. `RT-A42` does not rotate, because [client authentication](#5-broker-client-authentication) already binds it to the broker, and rotation would make concurrent exchanges from several broker replicas look like token reuse. -**Does not prove.** That mecak8s paired Alice's token with her own session, only that its reports agree with each other. That Alice is present after the open: the refresh token outlives her access token. +Checks 5 and 6 exist because the broker supplies `A42`, and only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. AS-1 records the owner at the first open and refuses any later open of `A42` for a different user. Both checks compare facts inside Mecatl, so they catch mistakes, not a compromised mecak8s, which could report a consistent lie. -The refresh token is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Signing out everywhere, closing the session, the hard expiry, and AS-1's upstream re-check end it; see [sign-out and revocation](#sign-out-and-revocation). +**Session end.** `RT-A42` is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Closing the session, signing out everywhere, the hard expiry, and AS-1's upstream re-check revoke it. Losing `RT-A42` for any of these reasons closes `A42`: mecak8s opens a new broker session for `hostS42` with a fresh access token on Alice's next request. A closed session leaves a record, its ID, owner, and closing time, so check 6 can refuse to reopen it. The converted `jti` is kept until the source token's `exp` plus the clock skew. -The existing broker session record under `A42` keeps the workload and host-session binding. The human authority now lives in `RT-A42`, not in a broker-local human record. +**Does not prove.** That mecak8s paired Alice's token with her own session, only that its reports agree with each other. That Alice is present after the open: the refresh token outlives her access token. **Evidence.** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry. It records a refused open with its reason, and a retry with the revoked first refresh token. @@ -307,17 +310,18 @@ The existing broker session record under `A42` keeps the workload and host-sessi ### (4) Agent-instance JWT-SVID -**Input.** For the diagram's main registration, mecak8s supplies the persisted main agent-instance incarnation and the selected definition reference, with no parent. For the reviewer, it supplies the child's persisted incarnation, the definition reference `global/code-reviewer`, and the registered main-instance reference, not the main's token. Both calls carry the workload credential. +**Input.** For the diagram's main registration, mecak8s supplies session `A42`, the persisted main agent-instance incarnation, and the selected definition reference, with no parent. For the reviewer, it supplies `A42`, the child's persisted incarnation, the definition reference `global/code-reviewer`, and the registered main-instance reference, not the main's token. Both calls carry the workload credential. **Registration checks, at the agent-instance issuer:** 1. The workload is authenticated ([step (2)](#2-workload-credential)). -2. The definition reference names a definition the loader recorded, or, for a definition the broker did not load, is accepted as a mecak8s report and marked as such; see [definition provenance](agent-definitions.md#definition-provenance). -3. With no parent, including after a fork or clear, the definition is root-eligible. With a parent, the parent is registered in the same session tree. -4. The definition category and name match `[A-Za-z0-9._-]+` and are neither `.` nor `..`. A name that would need encoding is rejected, not rewritten. -5. The nesting depth and the whole URI stay within the caps. Proposed: names up to 63 bytes and depth up to 8, which keeps the longest subject near 1,100 bytes. The issuer also checks the whole URI against the 2,048 bytes SPIFFE-ID §2.3 says implementations should not exceed. +2. Session `A42` is open and bound to that workload. +3. The definition reference names a definition the loader recorded, or, for a definition the broker did not load, is accepted as a mecak8s report and marked as such; see [definition provenance](agent-definitions.md#definition-provenance). +4. With no parent, including after a fork or clear, the definition is root-eligible. With a parent, the parent is registered in the same session tree. +5. The definition category and name match `[A-Za-z0-9._-]+` and are neither `.` nor `..` (SPIFFE-ID §2.2). A name that would need encoding is rejected, not rewritten. +6. The name and the nesting depth stay within the caps. Proposed: names up to 63 bytes and up to 8 child levels below the root, which keeps the longest subject near 1,400 bytes. The agent-instance issuer also checks the whole URI against the 2,048 bytes SPIFFE-ID §2.3 says implementations should not generate beyond. -Registration records identity, not permission to spawn, run, or call tools. The issuer constructs the canonical subject; see the [registration checks](agent-definitions.md#registering-instances). +Registration records identity, not permission to spawn, run, or call tools. The agent-instance issuer constructs the canonical subject; see the [registration checks](agent-definitions.md#registering-instances). In the same transaction the broker creates the instance's **`ag_` identifier**: a random, opaque value such as `ag_7hq2x9`, stored under the SPIFFE ID with a uniqueness constraint. AS-1 later writes it into `act`. **Output.** The instance reference, its canonical subject, and, on `requestSVID`, a JWT-SVID signed by the agent-instance issuer. The reviewer's, deliberately using only the claims JWT-SVID requires: @@ -334,27 +338,31 @@ mecak8s checks that the returned subject is the registered instance. The harness **SVID checks, at the broker and at AS-1:** -1. `alg` is on the allowlist JWT-SVID §3 permits: RS, ES, or PS with SHA-256 or longer, never `none` or HMAC. -2. `kid` selects a key in the agent trust bundle whose `use` is `jwt-svid`. An unknown `kid` triggers one rate-limited bundle refresh, then rejection. -3. The signature verifies. -4. `sub` is a SPIFFE ID in the agent trust domain `agents.example.com`, the trust domain of the bundle that verified it. A platform SVID with the same audience is rejected, because it belongs to a different trust domain and bundle. -5. `aud` contains the broker's audience. +1. `sub` parses as a SPIFFE ID in the agent trust domain `agents.example.com`. +2. The verifier selects that trust domain's bundle and, in it, the keys whose `use` is `jwt-svid`. The header must carry `kid`, which selects one of them; JWT-SVID §2.2 makes `kid` optional, so requiring it is Mecatl policy. An unknown `kid` triggers one rate-limited bundle refresh, then rejection. A platform SVID with the same audience fails here, because it belongs to another trust domain and bundle. +3. `alg` is on the allowlist JWT-SVID §2.1 permits: RS, ES, or PS with SHA-256 or longer, never `none` or HMAC. +4. The signature verifies. +5. `aud` is exactly the broker's audience, as its only value. 6. `exp` has not passed, allowing the clock skew, and is no further in the future than the configured maximum SVID lifetime. 7. `sub` matches the full canonical subject of a registered instance byte for byte, and that instance is not deregistered. 8. The instance is registered to the session in use: `A42` from the request at the broker, the session of `RT-A42`'s record at AS-1. -**Failures.** Registration failures return `INVALID_ARGUMENT` for checks 4 and 5 and `PERMISSION_DENIED` otherwise; none is retryable. An SVID that fails at the broker denies the call with `PERMISSION_DENIED`; at AS-1 it returns 400 `invalid_request` (RFC 8693 §2.2.2). An expired SVID is renewed and the call retried; any other SVID failure is not retryable. +**Failures.** Registration check 1 returns `UNAUTHENTICATED`, checks 2–4 `PERMISSION_DENIED`, and checks 5 and 6 `INVALID_ARGUMENT`; none is retryable. An expired SVID returns `UNAUTHENTICATED` with reason `svid_expired` at the broker and 400 `invalid_request` with that reason at AS-1. mecak8s then renews the SVID, one renewal per instance at a time, and retries the call once. Every other SVID failure returns `PERMISSION_DENIED` at the broker and `invalid_request` at AS-1 (RFC 8693 §2.2.2), and is not retryable. + +**Why.** The SVID gives agent identity its own SPIFFE trust domain and signing key, so verifiers can tell it apart from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. + +The SVID carries a single audience, the broker's, as JWT-SVID §7.2 recommends. AS-1 is configured to accept that value as its own SVID audience, because the broker presents the same SVID to both. That holds only while AS-1 is a broker module; an AS-1 outside the broker needs its own audience and a second SVID. -**Why.** The SVID gives agent identity its own SPIFFE trust domain and signing key, so verifiers can tell it apart from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. The SVID carries a single audience, the broker's, as JWT-SVID §7.2 recommends. AS-1 is configured to accept that value as its own SVID audience, because the broker presents the same SVID to both. That holds only while AS-1 is a broker module; an AS-1 outside the broker needs its own audience and a second SVID. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and its trust domain are distinct from OAuth AS-1 and AS-2. +JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and its trust domain are distinct from OAuth AS-1 and AS-2. -**Does not prove.** This is an assertion, not attestation: the issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. While the broker runs inside mecak8s, the issuer shares a process, and therefore its security controls, with AS-1 and the broker. While agents share one harness process, the SVID asserts the agent rather than authenticating it. AS-1's registration check catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings are possible but not selected: +**Does not prove.** This is an assertion, not attestation: the agent-instance issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. While the broker runs inside mecak8s, the agent-instance issuer shares a process, and therefore its security controls, with AS-1 and the broker. AS-1's registration check catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings are possible but not selected: - **Session claim.** The SVID names the session it was issued for, so AS-1 can check that the SVID and the subject token belong to the same session without relying on the broker's pairing. It is still issued on mecak8s's word. - **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession, so a stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. -**Evidence.** The issuer records each registration as a [lineage event](audit.md#lineage), marked as reported by mecak8s, and each SVID issuance with the instance and expiry. +**Evidence.** The agent-instance issuer records each registration as a [lineage event](audit.md#lineage), marked as reported by mecak8s, with the instance's `ag_` identifier, and each SVID issuance with the instance and expiry. See [renewal triggers](#renewal-triggers) for renewal across runs. @@ -362,10 +370,10 @@ See [renewal triggers](#renewal-triggers) for renewal across runs. The subject tokens the broker presents are bearer tokens: they prove whose authority is used, not who presents them. The broker therefore authenticates as OAuth client `mecatl` on every token-endpoint request, at AS-1 and at AS-2, with `private_key_jwt` (OIDC Core §9, RFC 7523 client assertion). -**Input.** Each request carries `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` and a fresh assertion: +**Input.** Each request carries `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` and a fresh assertion. The `typ` follows draft-ietf-oauth-rfc7523bis, which says client-authentication JWTs should be explicitly typed; servers do not reject an untyped one: ```json -{"alg": "ES256", "kid": "broker-2026-10"} +{"typ": "client-authentication+jwt", "alg": "ES256", "kid": "broker-2026-10"} { "iss": "mecatl", "sub": "mecatl", "aud": "https://broker.example.com/as", @@ -380,21 +388,27 @@ The subject tokens the broker presents are bearer tokens: they prove whose autho 3. `iss` and `sub` are both `mecatl`. 4. `aud` is the authorization server's issuer identifier as its only value, not its token-endpoint URL, as [draft-ietf-oauth-rfc7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/) requires. 5. `exp` has not passed, and `exp - iat` is within the configured maximum, here 60 seconds. -6. `jti` has not been seen; the server keeps it until `exp`. +6. `jti` has not been seen. The server keeps it until `exp` plus the clock skew, in storage shared by all its replicas. **Output.** An authenticated client `mecatl`. **Failures.** Any failed check returns 401 `invalid_client`. Only a lost response is retryable, with a new assertion and `jti`. -**Why.** It makes "accept only from the broker" enforceable. A leaked `RT-A42` is useless without the key: a refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5). The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. +**Why.** It makes "accept only from the broker" enforceable. A leaked `RT-A42` is useless without the key: a refresh token issued to an authenticated confidential client is already sender-constrained (RFC 9449 §5) and usable only by that client (RFC 9700 §4.14.2). + +The private key never leaves the broker and can live in a KMS or HSM. In external mode the gateway administrator registers the broker's `jwks_uri` once. A `client_secret` is rejected: it is shared, replayable once leaked, and rotated by hand. mTLS client authentication (RFC 8705) is an equivalent alternative. + +One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. **Does not prove.** Which agent asked: `mecatl` is the broker's registration, not an actor. Nor does it stop a thief with a stolen AS-1 access token from presenting it to the Mecatl API, where mecak8s converts it at session open, once per token. +**Evidence.** AS-1 and AS-2 record the authenticated client and the assertion's `kid` with each token-endpoint decision. + ### (6) Per-operation authorization Local permissions and hooks run before each call. -**Input.** Trusted harness client code sends `InvokeTool` with the workload credential, `A42`, `call.id=op-pr42-001`, run `R42` with its [run owner](run-owner.md) Alice, and `agentJwtSvid=reviewerSVID`. The model supplies only the tool content: +**Input.** Trusted harness client code sends `InvokeTool` with the workload credential, `A42`, `call.id=op-pr42-001`, run `R42` with its delegation `D42` and its [run owner](run-owner.md) Alice, and `agentJwtSvid=reviewerSVID`. A root run names no delegation. The model supplies only the tool content: ```json { @@ -407,30 +421,51 @@ Local permissions and hooks run before each call. 1. The workload is authenticated ([step (2)](#2-workload-credential)). 2. Session `A42` is open and bound to that workload and `hostS42`. -3. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid) and names an instance registered to `A42`. -4. The reported run owner equals the owner recorded with `RT-A42`, the company identity `(https://login.example.com, alice-204)`, not AS-1's subject `u-alice-91`. The run owner never selects the grant. -5. A route to the gateway exists for the tool. -6. Once the authority work is in place, the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors. +3. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid). +4. The run belongs to `A42`. A run naming no delegation is a root run of `A42`, recorded on first use. A run naming a delegation needs that delegation already [reported](#delegation-report) in `A42` with this run as its receiving run. +5. The reported run owner equals the grant's recorded owner, the company identity `(https://login.example.com, alice-204)` the broker stored with `RT-A42`, not AS-1's subject `u-alice-91`. The run owner never selects the grant. +6. A route to the gateway exists for the tool. +7. Once the authority work is in place, the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors. -The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hit skips steps (7) and (8), never these checks. +The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hit skips steps (7) and (8), never these checks. Concurrent misses for one key share a single exchange. **Output.** An allowed operation, dispatched in [step (9)](#9-vmcp-decision-and-upstream-credential). -**Failures.** Check 1 returns `UNAUTHENTICATED`. Checks 2–6 deny the call with `PERMISSION_DENIED`, record the denial, and dispatch nothing. None is retried automatically: retrying requires authorization again. +**Failures.** Check 1 returns `UNAUTHENTICATED`. An expired SVID at check 3 returns `UNAUTHENTICATED` with reason `svid_expired`, and mecak8s renews and retries once. Every other failure of checks 2–7 denies the call with `PERMISSION_DENIED`, records the denial, and dispatches nothing; none is retried automatically, and retrying requires authorization again. + +**Why.** User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). The broker does not check provider connections. It authorizes the session, workload, actor, run, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. + +**Does not prove.** That mecak8s reported the right run or run owner: both are mecak8s reports, checked for consistency with the grant and the reported delegations. Neither dispatch nor success. -**Why.** User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). The broker does not check provider connections. It authorizes the session, workload, actor, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. +**Evidence.** `op-pr42-001` passed the session, workload, actor, run, and run-owner checks, and, once the authority work is in place, the effective-ceiling check. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. -**Does not prove.** That mecak8s reported the right run or run owner: both are mecak8s reports, checked for consistency with the grant. Neither dispatch nor success. +### Delegation report -**Delegation report.** When main delegates the review, mecak8s calls `ReportDelegation` with session `A42`, delegation `D42`, the delegating run `R41` and instance `mainRef`, the receiving run `R42` and instance `reviewerRef`, and the receiving run's owner. The broker checks that `R41` belongs to `A42`, that both instances are registered to `A42`, and that the receiving run's owner equals the delegating run's. A mismatch returns `PERMISSION_DENIED`; the receiving run cannot call tools until a consistent report arrives. +**Input.** When main delegates the review, mecak8s calls `ReportDelegation` with the workload credential, session `A42`, delegation `D42`, the delegating run `R41` and instance `mainRef`, the receiving run `R42` and instance `reviewerRef`, and the receiving run's owner. -**Evidence.** `op-pr42-001` passed the session, workload, actor, and run-owner checks, and, once the authority work is in place, the effective-ceiling check. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. +**Checks, at the broker:** + +1. The workload is authenticated, and `A42` is open and bound to it. +2. `R41` belongs to `A42`. If `R41` was itself delegated, its delegation's receiving run is `R41`. +3. `mainRef` and `reviewerRef` are both registered to `A42`. +4. The reported owner of `R42` equals the owner of `R41`. +5. `D42` has not been reported before, or was reported with exactly the same content. + +**Output.** A recorded delegation, which lets `R42` pass [check 4](#6-per-operation-authorization) of every later call. + +**Failures.** Check 1 returns `UNAUTHENTICATED` or `PERMISSION_DENIED`; checks 2–4 return `PERMISSION_DENIED`; a repeat of `D42` with different content returns `ALREADY_EXISTS`. A repeat with the same content succeeds, so mecak8s can retry a lost response. Until a consistent report arrives, `R42` cannot call tools. + +**Why.** The broker sees delegations only through these reports. Checking each one when it arrives keeps a later call from naming a run the broker never heard of, or a run whose owner changed on the way down. + +**Does not prove.** That main actually delegated to the reviewer: it is a mecak8s report, checked for consistency. + +**Evidence.** The broker records each accepted or refused report as a [delegation lineage event](audit.md#lineage), marked as reported by mecak8s. ### (7) ID-JAG from AS-1 On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)). -**Input.** An RFC 8693 token exchange with `RT-A42` as subject token, `reviewerSVID` as actor token, and AS-2 as audience. The operation and attempt IDs travel as request parameters for AS-1's record, as reported trace context: +**Input.** An RFC 8693 token exchange with `RT-A42` as subject token, `reviewerSVID` as actor token, AS-2 as audience, and the vMCP as `resource`. The operation and attempt IDs travel as request parameters for AS-1's record, as reported trace context: ```text POST https://broker.example.com/as/oauth/token @@ -441,6 +476,7 @@ actor_token= actor_token_type=urn:ietf:params:oauth:token-type:jwt requested_token_type=urn:ietf:params:oauth:token-type:id-jag audience=https://gateway.example.com +resource=https://gateway.example.com/mcp mecatl_operation=op-pr42-001 mecatl_attempt=1 client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer @@ -450,12 +486,12 @@ client_assertion= **Checks, at AS-1:** 1. Client authentication as `mecatl`. -2. The request is well formed: the three token types above and an `audience`. -3. `audience` is one of the AS-2 issuers configured for `mecatl`. -4. `RT-A42` resolves to a `mecatl_session` record bound to `mecatl` and not revoked; session `A42` is open; the hard expiry has not passed. A schedule's record is refused here. -5. If the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first ([upstream re-check](#sign-out-and-revocation)). -6. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid) and names an instance registered to `A42`. -7. AS-1 authorizes the combination of client, subject, actor, and target AS-2. +2. The request is well formed: the three token types above, an `audience`, and a `resource`. +3. `audience` is one of the AS-2 issuers configured for `mecatl`, and `resource` is a vMCP configured for that AS-2. +4. `RT-A42` resolves to a `mecatl_session` grant bound to `mecatl` and not revoked; session `A42` is open; the hard expiry has not passed. +5. The upstream grant is current: its recorded expiry has not passed, and, if the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first ([upstream re-check](#upstream-re-check)). +6. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid). +7. AS-1 authorizes the combination of client, subject, actor, and target. **Output.** @@ -476,6 +512,7 @@ client_assertion= "iss": "https://broker.example.com/as", "sub": "u-alice-91", "aud": "https://gateway.example.com", + "resource": "https://gateway.example.com/mcp", "client_id": "mecatl", "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, "jti": "jag-pr42-001", @@ -484,9 +521,10 @@ client_assertion= } ``` -- `sub` comes from `RT-A42`'s record, never from the request. -- `act` names only the acting instance, with an opaque, stable, per-instance identifier: no SPIFFE path, no definition name, and no colon, so it reads as a plain string rather than a URI. AS-1 generates a random identifier for each registered instance and stores it under the instance's SPIFFE ID, so the identifier survives SVID renewal. Stability lets the vMCP bind a session to the instance and audit correlate its calls. `act.iss` names AS-1, which minted it, so the identifier stays attributable to AS-1 inside `V`, whose own `iss` is AS-2. The full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. +- `sub` comes from `RT-A42`'s grant, never from the request. +- `act.sub` is the instance's `ag_` identifier, created at registration: no SPIFFE path, no definition name, and no colon, so it reads as a plain string rather than a URI. It stays the same across SVID renewals, so the vMCP can bind a session to the instance and audit can correlate its calls. `act.iss` names AS-1, which minted `act`, so the identifier stays attributable to AS-1 inside `V`, whose own `iss` is AS-2. The full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. - `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. +- `resource` is signed by AS-1, so AS-2 checks the redemption's `resource` against a claim, not only against a broker parameter. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. **Failures.** @@ -496,18 +534,20 @@ client_assertion= | 1 | 401 `invalid_client` | Call fails; not retryable. | | 2 | 400 `invalid_request` | Call fails; a broker bug. | | 3 | 400 `invalid_target` | Call fails; a configuration fault. | -| 4 | 400 `invalid_request` | The session has lost its authority: calls fail closed until a new session open. | -| 5, IdP returns `invalid_grant` | 400 `invalid_request` | AS-1 has revoked Alice's grants, or ended them with "needs owner action"; see [upstream re-check](#sign-out-and-revocation). | -| 5, IdP unreachable or 5xx | 503, nothing revoked | Call fails closed; the next call retries the re-check. | -| 6 | 400 `invalid_request` | Call denied; retried only after the SVID is renewed, if it had expired. | -| 7 | 400 `invalid_request` | Call denied. | -| Lost response | None received | Request a new ID-JAG. An unused ID-JAG expires harmlessly. | +| 4 | 400 `invalid_request`, reason `session_closed` | `A42` is closed; mecak8s opens a new session on Alice's next request. | +| 5, upstream revoked | 400 `invalid_request`, reason `upstream_revoked` | AS-1 has revoked all of Alice's grants; see [upstream re-check](#upstream-re-check). | +| 5, owner action needed | 400 `invalid_request`, reason `owner_action_required` | Alice must sign in again. | +| 5, IdP unreachable or 5xx | 503 with `Retry-After`, nothing revoked | Call fails closed; a later call retries. A 503 is plain HTTP, not an OAuth error, and never means revocation. | +| 6, expired | 400 `invalid_request`, reason `svid_expired` | mecak8s renews the SVID and retries once. | +| 6, otherwise | 400 `invalid_request`, reason `denied` | Call denied. | +| 7 | 400 `invalid_request`, reason `denied` | Call denied. | +| Lost response | None received | Request a new ID-JAG with a new client assertion. An unused ID-JAG expires harmlessly. | -**Why.** ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); check 6 is that missing binding. ID-JAG is a profile of [identity chaining across trust domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. +**Why.** ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); SVID check 8 is that missing binding. ID-JAG is a profile of [identity chaining across domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. -AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different administrative domains; external mode fits it, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). +AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different trust domains, in the OAuth sense of a security and administrative boundary (§2.2). This design calls those administrative domains, to keep "trust domain" for SPIFFE. External mode fits §9.3, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). -**Does not prove.** That the reviewer, rather than other code in the harness process, asked for the call: the SVID is an assertion. +**Does not prove.** That the reviewer, rather than other code in the harness process, asked for the call: the SVID is asserted, not attested. **Evidence.** AS-1 records the ID-JAG issuance: user, session `A42`, the validated instance's SPIFFE ID and its `ag_` identifier, target, `jti`, and the reported operation and attempt. @@ -530,17 +570,15 @@ client_assertion= 2. The ID-JAG's header `typ` is `oauth-id-jag+jwt`; `iss` is a trusted AS-1; `alg` is on the allowlist and the signature verifies against that issuer's keys. 3. `aud` is AS-2's own issuer; `exp` and `iat` are current within the clock skew. 4. `client_id` matches the authenticated client (ID-JAG §4.4.1). -5. `jti` has not been redeemed (RFC 7523 §3). AS-2 keeps used identifiers until they expire. -6. `resource` is a vMCP this client and actor may reach. -7. AS-2 resolves the native user: +5. `jti` has not been redeemed (RFC 7523 §3). AS-2 keeps used identifiers until `exp` plus the clock skew. +6. The requested `resource` is the ID-JAG's `resource` claim, and this client and actor may reach it. +7. AS-2 resolves the native user as the table below shows, never through `sub_id` (§9.5), email, or display name. | Mode | How AS-2 resolves and authorizes the user | | --- | --- | -| Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit. AS-1 and AS-2 are distinct issuers with distinct keys, so no issuer redeems its own ID-JAG; embedded mode is still outside §9.3's cross-domain scope. | +| Embedded | Users log in to AS-2 through AS-1, so the AS-1 subject maps directly. AS-1 is the SSO IdP AS-2 trusts (§4.1). Consent is implicit. AS-1 and AS-2 are distinct issuers with distinct keys, so no issuer redeems its own ID-JAG; embedded mode is still outside §9.3's scope. | | External | Identity chaining across administrative domains. AS-2's users log in through their own IdP, so the §4.1 SSO precondition does not hold. AS-2 redeems only for an AS-1 `(iss, sub)` and client `mecatl` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | -AS-2 never resolves users through `sub_id` (§9.5), email, or display name. - **Output.** A short-lived access token and no refresh token (§4.4.3): ```json @@ -563,30 +601,49 @@ AS-2 never resolves users through `sub_id` (§9.5), email, or display name. } ``` -**Failures.** Check 1 returns 401 `invalid_client`. Checks 2–5 return 400 `invalid_grant` (RFC 7523 §3.1) and are not retryable with the same ID-JAG. Check 6 returns 400 `invalid_target`. An unresolved subject, including a missing consent link in external mode, returns `invalid_grant`; the broker returns the existing pending outcome, and how an external gateway signals "consent required" in a structured way is [open](implementation.md#as-1-grant-and-as-2-user-resolution). A lost response is retried with a new ID-JAG from step (7), because the first one's `jti` may already be spent. +**Failures.** + +| Check | Response | Broker outcome and retry | +| --- | --- | --- | +| 1 | 401 `invalid_client` | Call fails; not retryable. | +| 2–5 | 400 `invalid_grant` (RFC 7523 §3.1) | Not retryable with the same ID-JAG. | +| 6 | 400 `invalid_target` | Call fails; a configuration fault. | +| 7 | 400 `invalid_grant` | The existing `mcp_authorization_pending` outcome. How an external gateway signals "consent required" in a structured way is [open](implementation.md#as-1-grant-and-as-2-user-resolution). | +| Lost response | None received | Request a new ID-JAG from step (7): the first one's `jti` may already be spent. | -**Why.** Single use is Mecatl policy, stricter than ID-JAG §4.4.3, which lets a client re-submit an ID-JAG; it keeps one redemption per issuance in the audit trail. The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). +**Why.** The broker never re-submits an ID-JAG. AS-2 enforcing single use is required in embedded mode and requested of external gateways, because ID-JAG §4.4.3 lets clients re-submit and a third-party gateway may allow it. Single use keeps one redemption per issuance in the audit trail. The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). **Does not prove.** Anything about the agent's ceiling: `V` carries no scope for it yet, so a gateway allow says nothing about the ceiling. -**Caching.** The broker caches `V-reviewer` under `(A42, reviewer, gateway, Alice)` and treats it as expired a configurable skew margin before `exp`. On a 401 `invalid_token` from the vMCP, it drops `V` and repeats steps (7) and (8) once. A cache hit still needs a currently valid SVID for the same instance. vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, and the broker stores each session ID under the instance, gateway, and user, so each agent instance has its own vMCP session per user. +**Caching.** The broker caches `V-reviewer` under `(A42, reviewer, gateway, Alice)` and treats it as expired a configurable skew margin before `exp`; at startup it checks that the margin is shorter than the `V` lifetime AS-2 issues. + +- On a 401 `invalid_token` from the vMCP, the broker drops the cached `V`, but only if it is still the one that failed, and repeats steps (7) and (8) once within the same call. +- A cache hit still needs a currently valid SVID for the same instance. +- vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, and the broker stores each session ID under the instance, gateway, and user, so each agent instance has its own vMCP session per user. **Evidence.** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. ### (9) vMCP decision and upstream credential -**Input.** `tools/call` with `Authorization: Bearer `, the vMCP session ID, and the operation and attempt IDs in the request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`. +**Input.** `tools/call` with `Authorization: Bearer `, the vMCP session ID, and the operation and attempt IDs in the request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`. The attempt number increases with each dispatch attempt of the same operation. **Checks, at the vMCP:** -1. `V`'s `typ` is `at+jwt`, `iss` is AS-2, the signature verifies, `aud` is this vMCP, and `exp` has not passed (RFC 9068 §4). +1. `V`'s `typ` is `at+jwt` or `application/at+jwt`, `iss` is AS-2, the signature verifies, `aud` is this vMCP, and `exp` has not passed (RFC 9068 §4). 2. The session ID, if present, is bound to the same `iss`, `sub`, and `act.sub`. 3. Its own operation policy allows the tool. 4. An upstream credential exists under **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. **Output.** The GitHub call made with Alice's GitHub token, and its result. One credential record exists per user and provider. A delegated `V` comes from no interactive gateway login, so every gateway, including a standalone one, must find the credential from the user alone. -**Failures.** Check 1 returns 401 `invalid_token`; the broker drops `V` and retries once with a new one. Check 2 rejects the session; the broker opens a new one. Check 3 returns a tool error and is not retried. Check 4 answers "needs authorization"; the user connects GitHub through the existing authorization flow, and a later call passes every check again. +**Failures.** + +| Check | Response | Broker outcome and retry | +| --- | --- | --- | +| 1 | 401 `invalid_token` | The broker drops `V` and retries once with a new one. | +| 2 | 404 for the session | The broker opens a new vMCP session and retries once. | +| 3 | Tool error | Returned as a denial; not retried. | +| 4 | "Needs authorization" | The existing `mcp_authorization_pending` outcome; after Alice connects GitHub, a later call passes every check again. | **Does not prove.** Provider-side effects: neither a successful MCP response nor an authorization record proves them. @@ -596,43 +653,77 @@ AS-2 never resolves users through `sub_id` (§9.5), email, or display name. ### Failure matrix -Where each failure is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. +Where each failure is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. Configuration faults, such as `invalid_client`, `unauthorized_client`, and `invalid_target`, fail every affected call until an operator fixes them, and are not listed again. | Failure | Detected at | Response | User-visible outcome | Revokes | | --- | --- | --- | --- | --- | -| Expired or invalid AS-1 access token | mecak8s | 401 `invalid_token` | Client refreshes silently; signs in again if its refresh token is gone | Nothing | -| Not the owner of `hostS42` | mecak8s | 403 | Request refused | Nothing | -| Workload token expired or not allowed | Broker, issuer | `UNAUTHENTICATED`, `PERMISSION_DENIED` | Request fails; an operator fault if it persists | Nothing | -| Owner mismatch or session owned by another user at open | AS-1 | `invalid_request`, recorded | Session open fails | Nothing | -| Access token already opened another session | AS-1 | `invalid_request` | Client refreshes and retries | Nothing | -| Root registration of a non-root-eligible definition | Issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | -| SVID expired | Broker, AS-1 | `PERMISSION_DENIED`, `invalid_request` | None: renewed and retried | Nothing | -| Run owner differs from the grant's owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | -| `RT-A42` revoked, expired, or session closed | AS-1 | `invalid_request` | Calls fail until a new session open | Already revoked | -| Company IdP refuses the refresh before its expiry | AS-1 | `invalid_request` | Every agent and schedule for Alice stops | All of Alice's grants | -| Company IdP refresh token reached its own expiry | AS-1 | `invalid_request` | "Needs owner action": Alice signs in again | All of Alice's grants | +| Expired or invalid AS-1 access token | mecak8s | 401 `invalid_token` | Client refreshes silently once; signs in again if that fails | Nothing | +| Not the owner of `hostS42` | mecak8s | 403 `not_session_owner` | Request refused | Nothing | +| Workload credential expired | Broker, agent-instance issuer | `UNAUTHENTICATED` | None: mecak8s rereads the rotated token | Nothing | +| Workload not allowed | Broker, agent-instance issuer | `PERMISSION_DENIED` | Every request fails; an operator fault | Nothing | +| Owner mismatch at open | AS-1 | `invalid_request`, recorded | Session open fails | Nothing | +| Session owned by another user, or closed | AS-1 | `invalid_authorization_details`, recorded | Session open fails | Nothing | +| Access token already opened a session | AS-1 | `invalid_request` | Client refreshes and retries once | Nothing | +| Echoed entry differs | Broker | Open fails | Session open fails | The new `RT-A42` | +| Registration into a closed or foreign session | Agent-instance issuer | `PERMISSION_DENIED` | Agent cannot start | Nothing | +| Root registration of a non-root-eligible definition | Agent-instance issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | +| Definition name or depth beyond the caps | Agent-instance issuer | `INVALID_ARGUMENT` | Child agent cannot start | Nothing | +| SVID expired | Broker, AS-1 | `UNAUTHENTICATED`, `invalid_request` with `svid_expired` | None: renewed and retried once | Nothing | +| SVID invalid or instance not registered to the session | Broker, AS-1 | `PERMISSION_DENIED`, `invalid_request` with `denied` | Call denied | Nothing | +| Run unknown, or delegation not reported | Broker | `PERMISSION_DENIED` | Call denied | Nothing | +| Run owner differs from the grant's recorded owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | +| No route, or ceiling denies the tool | Broker | `PERMISSION_DENIED` | Call denied | Nothing | +| Inconsistent delegation report | Broker | `PERMISSION_DENIED`, `ALREADY_EXISTS` | Delegated task cannot call tools | Nothing | +| `RT-A42` revoked, expired, or session closed | AS-1 | `invalid_request` with `session_closed` | A new session opens on Alice's next request | Already revoked | +| Company IdP refuses the refresh | AS-1 | `invalid_request` with `upstream_revoked` | Every agent and schedule for Alice stops | All of Alice's grants | +| Company IdP grant needs Alice's action | AS-1 | `invalid_request` with `owner_action_required` | "Needs owner action": Alice signs in again | All of Alice's grants | | Company IdP unreachable or 5xx | AS-1 | 503 | This call fails; later calls retry | Nothing | +| AS-1 refuses the combination | AS-1 | `invalid_request` with `denied` | Call denied | Nothing | | No consent link (external) | AS-2 | `invalid_grant` | `mcp_authorization_pending` through "Allow Mecatl" | Nothing | | ID-JAG replayed | AS-2 | `invalid_grant` | None if the broker re-mints; otherwise call fails | Nothing | | `V` rejected by vMCP | vMCP | 401 `invalid_token` | None: one retry with a new `V` | Cached `V` dropped | +| vMCP session rejected | vMCP | 404 | None: one retry with a new session | Stored session ID dropped | +| vMCP policy denies the tool | vMCP | Tool error | Call denied | Nothing | | No upstream credential | vMCP | "Needs authorization" | `mcp_authorization_pending`; Alice connects GitHub | Nothing | | Dispatch happened, no completion observed | Broker | Unknown outcome | Reported as unknown; not replayed | Nothing | +### Error reasons + +RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 puts a reason code first in `error_description`, for example `error_description=session_closed`; the broker carries it in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome: + +| Reason | AS-1 response | Broker gRPC status | mecak8s outcome | +| --- | --- | --- | --- | +| `session_closed` | 400 `invalid_request` | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. | +| `upstream_revoked` | 400 `invalid_request` | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. | +| `owner_action_required` | 400 `invalid_request` | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. | +| `svid_expired` | 400 `invalid_request` | `UNAUTHENTICATED` | Renew the SVID and retry once; not shown to the user. | +| `denied` | 400 `invalid_request` | `PERMISSION_DENIED` | Deny the call. | + ### Renewal triggers | Credential | Renewed when | By | If renewal fails | | --- | --- | --- | --- | | AS-1 access token | Before expiry, and before each session open | Client, with its refresh token | Sign in again | -| Workload token | At 80% of its lifetime | Kubelet; mecak8s rereads the file | Calls fail with `UNAUTHENTICATED` | -| Agent-instance SVID | When less than half its lifetime remains, on the next use; the old SVID stays valid until its `exp` | Issuer, for the same registered instance; the `ag_` identifier is unchanged | Calls from that instance fail | +| Workload credential | Before it expires | Kubelet; mecak8s rereads the file | Calls fail with `UNAUTHENTICATED` | +| Agent-instance SVID | When less than half its lifetime remains, on the next use; the old SVID stays valid until its `exp` | Agent-instance issuer, for the same registered instance; one renewal per instance at a time; the `ag_` identifier is unchanged | Calls from that instance fail | | Client assertion | Every token-endpoint request | Broker | Exchange fails with `invalid_client` | | `RT-A42` | Never | — | A new session open with a fresh access token | -| Company-IdP refresh token | At most once per re-check interval, when an ID-JAG is requested; single-flight per user, storing the newest token if the IdP rotates it | AS-1 | See [upstream re-check](#sign-out-and-revocation) | +| Company-IdP refresh token | At most once per re-check interval, when an ID-JAG is requested | AS-1, one refresh per user at a time | See [upstream re-check](#upstream-re-check) | | ID-JAG | Never; one per `V` | — | — | -| `V` | A skew margin before `exp`, or on a 401 from the vMCP, at the next call | Broker, through steps (7) and (8) | The call fails with step (7) or (8)'s outcome | -| Agent and AS-1 key sets | On an unknown `kid`, rate-limited, and periodically | Each verifier | Tokens signed with the new key are rejected until the refresh succeeds | +| `V` | A skew margin before `exp`, at the next call; or within the same call after a 401 from the vMCP | Broker, through steps (7) and (8), one exchange per cache key at a time | The call fails with step (7) or (8)'s outcome | +| Key sets: AS-1's, AS-2's, the agent trust bundle, the cluster JWKS, and the broker's `jwks_uri` at AS-1 and AS-2 | On an unknown `kid`, rate-limited, and periodically | Each verifier | Tokens signed with a new key are rejected until the refresh succeeds | | Schedule authority | Before its horizon, after a notification | Alice | Runs end with "needs owner action" | +### Configuration + +None of these values is selected. Each bounds a risk the operator should know: + +- **Lifetimes:** `RT-A42`'s hard expiry, a schedule's authority horizon, the ID-JAG lifetime, and the maximum SVID, workload-credential, and client-assertion lifetimes. +- **Clock skew**, and the margins derived from it: the `V` skew margin and how long used `jti` values are kept past `exp`. +- **Upstream re-check:** the re-check interval, the maximum upstream refresh-token lifetime per IdP when the IdP sends no `refresh_expires_in`, and the lease timeout for a refresh in flight. +- **Key refresh:** the rate limit on refreshing a key set after an unknown `kid`. +- **IdP outage:** with no tolerance, every exchange fails closed once the re-check interval has passed while the company IdP is down. A grace period would keep agents working through a short outage, at the cost of a disabled account acting for that much longer. + ## User identity AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s authenticates the API request with the AS-1 token and takes the session owner from its `sub_id`, the company identity. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. @@ -641,7 +732,7 @@ No mecak8s code path can name a user who did not sign in at AS-1. An attacker wi AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. -The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. Each call also carries the [run owner](run-owner.md), which the broker checks against the owner recorded with the grant it uses; today it equals the session owner. +The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. Each call also carries the [run owner](run-owner.md); see [step (6)](#6-per-operation-authorization). ## Credential custody @@ -654,7 +745,7 @@ The following custody boundaries do not imply a selected persistence implementat | Workload credential | Platform-managed; mecak8s presents it to the issuer and broker. | Projected credential or configured SPIRE identity. Authenticates the workload, not a user or agent. | | Agent-instance JWT-SVID | Harness holds `mainSVID` / `reviewerSVID` outside model context; broker forwards it to AS-1 as `actor_token`. | Agent-instance issuer keeps its signing key. Renewal requires the same registered identity and does not widen authority. | | Broker session `A42` (state) | Broker owns the workload and host-session binding. | Recovered under workload and continuity checks. Possession of the reference grants nothing. | -| Broker refresh token `RT-A42` | Broker; presented only to AS-1. | Bound to `mecatl`, `A42`, and Alice; does not rotate; hard expiry; revoked when the session closes. | +| Broker refresh token `RT-A42` | Broker, in shared session storage with the grant's recorded owner; presented only to AS-1. | Bound to `mecatl`, `A42`, and Alice; does not rotate; hard expiry; revoked when the session closes. | | Broker private key | Broker; signs client assertions for AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | | ID-JAG | Broker, from issuance to redemption. | Single-use, short-lived. | | AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, gateway, and user; per-call checks still apply. No refresh token. | @@ -671,19 +762,19 @@ An agent instance keeps its identity across runs. These lifetimes are independen | --- | --- | --- | | AS-1 access token | AS-1 policy; short. Expiry ends API authentication, not the broker session. | The client refreshes with its own refresh token. | | Client refresh token | AS-1 policy and upstream sign-in status. | Sign in again. | -| Workload JWT | Expired credentials are rejected. | Platform rotation for the same workload. | +| Workload credential | Expired credentials are rejected. | Platform rotation for the same workload. | | `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the `ag_` identifier and the ceiling do not change. | -| `RT-A42` | Hard expiry, session close, sign out everywhere, a failed upstream re-check, or revocation. It does not rotate. | A new session open with a fresh AS-1 access token. | +| `RT-A42` | Hard expiry, session close, sign out everywhere, or the upstream re-check. It does not rotate. Losing it closes `A42`. | A new broker session for `hostS42`, opened with a fresh AS-1 access token. | | ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | | `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | | Broker private key | Operator rotation. | New key published through `jwks_uri`. | -| Schedule authority | Its horizon, schedule deletion, sign out everywhere, or a failed upstream re-check. | The owner renews it. | +| Schedule authority | Its horizon, schedule deletion, sign out everywhere, or the upstream re-check. | The owner renews it. | | Consent link | Revocation at AS-2. | The user links again. | | Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The values of `RT-A42`'s hard expiry and of a schedule's authority horizon are not selected. They bound how long Mecatl acts without a fresh sign-in, and the company IdP administrator should know them. -Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. +Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. AS-1 keeps a record of the closed session so it is never reopened. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. ## Sign-out and revocation @@ -693,20 +784,32 @@ Signing out of one client and stopping Mecatl's agents are different actions. | --- | --- | --- | | **Sign out** in the TUI or Studio | That client's own AS-1 tokens, revoked at AS-1. | Running agents and schedules. Their authority is held by the broker, not by the client. | | **Sign out everywhere** | Every agent acting for Alice: live runs stop, tool calls parked for authorization are cancelled, broker sessions and their refresh tokens are revoked, and the authority of each of her schedules is revoked. | Transcripts. Each schedule stays configured; its next run ends with "needs owner action" until Alice renews it. | -| **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval. | Transcripts. | +| **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval: AS-1 triggers the broker's sign-out-everywhere path. | Transcripts. | | **Closing a session** | That session's refresh token. | Other sessions and schedules. | **Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker revokes Alice's grants in those sessions at AS-1, cancels their in-flight tool calls, and drops her cached ID-JAGs, `V`, and vMCP session IDs. Today every session has one owner, so this ends each of her broker sessions. Revocation is by user, so a later [shared session](run-owner.md) would keep running for its other members. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. -**Upstream re-check.** AS-1 keeps the company-IdP refresh token behind each broker refresh token and each schedule's authority; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. When the broker exchanges a refresh token for an ID-JAG, AS-1 refreshes the upstream token if it has not done so within the re-check interval. Refreshes are single-flight per user, so concurrent exchanges from several replicas cause one refresh, and AS-1 stores the newest token if the IdP rotates them. The IdP's answer decides what happens: +### Upstream re-check + +AS-1 keeps one company-IdP refresh token per user, from her most recent sign-in, and re-checks it on behalf of every grant it holds for her; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. Every ID-JAG exchange compares the current time with that recorded expiry, which needs no call to the IdP. + +When the broker exchanges a refresh token for an ID-JAG and the re-check interval has passed, AS-1 refreshes the upstream token first: + +1. AS-1 takes a lease on Alice's upstream token and records that a refresh is in flight before calling the IdP. Other exchanges for Alice wait for the lease; if it times out, they fail closed with a 503 and retry later. +2. AS-1 calls the IdP, then stores the new token, rotated or not, and clears the in-flight record. +3. If AS-1 crashed or lost the response while a refresh was in flight, it cannot know whether the IdP rotated the token. The next refresh's `invalid_grant` is then treated as "needs owner action", never as revocation. + +The IdP's answer decides what happens: | IdP answer | AS-1 action | Outcome | | --- | --- | --- | | Success | Stores the new token, records the time | The exchange continues. | -| `invalid_grant` before the recorded expiry | Revokes every grant it holds for Alice, including schedule authority | The account was disabled or its grants revoked: the same as sign out everywhere. | -| `invalid_grant` at or after the recorded expiry | Ends every grant it holds for Alice | "Needs owner action": Alice signs in again. Not reported as a disabled account. | -| `invalid_grant` with no recorded expiry | Revokes, as before the expiry | Treated as revocation, because AS-1 cannot tell the two apart. | -| Network error or 5xx | Nothing | That exchange fails closed; the next one retries. | +| `invalid_grant` before the recorded expiry, after a clean previous refresh | Revokes every grant it holds for Alice, with reason `upstream_revoked`, and triggers the broker's sign-out-everywhere path | The account was disabled or its grants revoked: the same as sign out everywhere. | +| `invalid_grant` with no recorded expiry | The same | Treated as revocation, because AS-1 cannot tell the two apart. | +| `invalid_grant` at or after the recorded expiry, or after an ambiguous previous refresh | Revokes every grant it holds for Alice, with reason `owner_action_required` | "Needs owner action": Alice signs in again. Not reported as a disabled account. | +| Network error or 5xx | Nothing | That exchange fails closed with a 503; the next one retries. | + +A revoked grant stays revoked whatever its reason: signing in again starts a new session or renews a schedule, and never revives the old grant. The reason only decides what Alice is told. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. @@ -732,7 +835,13 @@ An operation that may have been dispatched but has no recorded completion has an A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own delegated authority from Alice. Mecatl obtains it when she sets up the schedule, with the same exchange as at [session open](#3-session-open-and-the-broker-refresh-token): her client refreshes its access token first, and the `authorization_details` entry has type `mecatl_schedule` and names the schedule and its owner. The result is a separate kind of record, bound to the schedule rather than a session. A session cannot redeem a schedule's authority, and a schedule cannot redeem a session's refresh token. A scheduled run's owner is checked against the owner recorded with the schedule's authority. It has an explicit horizon. Deleting the schedule, signing out everywhere, and a failed upstream re-check revoke it. Each run draws on that authority. +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own grant from Alice: + +- **Setup.** Mecatl obtains it when she sets up the schedule, with the same exchange as at [session open](#3-session-open-and-the-broker-refresh-token): her client refreshes its access token first, and the `authorization_details` entry has type `mecatl_schedule` and names the schedule and its owner. The result is a schedule grant with an explicit horizon. +- **Each run.** When the schedule fires, mecak8s opens a broker session for the run. The broker exchanges the schedule grant at AS-1, as the subject token, for a session refresh token: the same exchange as session open, with a `mecatl_session` entry naming the new session and the schedule's owner. The session's refresh token expires no later than the schedule's horizon, and revoking the schedule grant revokes it. From there the run is an ordinary session: instances register to it, and steps (6) to (9) apply unchanged, including the `V` cache key. +- **Separation.** A schedule grant is accepted only to open a run's session; presented to an ID-JAG exchange, it is refused. A session's refresh token is never accepted to open a scheduled run. +- **Ownership.** A scheduled run's owner is checked against the owner recorded with the schedule grant. +- **End.** Deleting the schedule, signing out everywhere, and the upstream re-check revoke the schedule grant. When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. @@ -750,7 +859,7 @@ Workload authentication identifies the Mecatl service contacting the broker. Age | --- | --- | | Kubernetes ServiceAccount bearer over TLS | None: workload-only identification | | Kubernetes ServiceAccount bearer over TLS | Broker-issued instance JWT-SVID | -| SPIRE workload JWT-SVID over TLS | Broker-issued instance JWT-SVID | +| SPIRE workload credential-SVID over TLS | Broker-issued instance JWT-SVID | | SPIRE workload X.509-SVID over mTLS | Broker-issued instance JWT-SVID | These are alternatives, not a required implementation sequence. Broker-issued agent-instance JWT-SVIDs do not require SPIRE. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index abc4cb533a..f8747358de 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -18,7 +18,7 @@ Verify that scheduled invocations preserve owner attribution without treating sa Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Agent-instance incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. -Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs. The proposed caps are names up to 63 bytes and nesting depth up to 8, which keeps the longest subject near 1,100 bytes; registration past either cap is refused. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. +Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs. The proposed caps are names up to 63 bytes and up to 8 child levels below the root, which keeps the longest subject near 1,400 bytes; registration past either cap is refused. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. ## AS-1 grant and AS-2 user resolution @@ -63,12 +63,12 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Source access-token `jti` | | Refuses a second session opened with the same token. | | Issue time and hard expiry | | Authority horizon. | -The broker supplies the session reference. AS-1 echoes the granted `mecatl_session` entry in its response (RFC 9396 §7), and the broker checks it matches. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record and the refresh-token record in one transaction. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and deletes the record. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. +The broker supplies the session reference. The `mecatl_session` type requires AS-1 to echo the entry unchanged (RFC 9396 §7); the broker checks it is identical, revokes the refresh token and fails the open if not, and otherwise stores the refresh token with the echoed owner in its shared session storage. AS-1 refuses an open while the session already has a live refresh token. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record, the owner, and the refresh-token record in one transaction. The broker runs at most one open per session at a time, and a retry carries a new client assertion. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and keeps a closed-session record (ID, owner, closing time) so the session is never reopened; the converted `jti` is kept until the source token's `exp` plus the clock skew. Losing the refresh token for any reason closes the session. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: 1. resolves the refresh token to its record and checks that it is a `mecatl_session` record bound to `mecatl`, that the session is open, and that the hard expiry has not passed; -1. if the re-check interval has passed, refreshes the user's company-IdP grant, as [upstream re-check](credentials-and-lifecycle.md#sign-out-and-revocation) describes; +1. if the re-check interval has passed, refreshes the user's company-IdP grant, as [upstream re-check](credentials-and-lifecycle.md#upstream-re-check) describes; 2. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; 3. authorizes the combination of client, subject, actor, and target AS-2; 4. issues the ID-JAG. @@ -87,7 +87,7 @@ audience=https://gateway.example.com client_id=mecatl + client assertion ``` -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance identifier for the acting instance, such as `ag_7hq2x9`: a plain string with no colon, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. AS-1 derives it from the validated `actor_token`; the full SVID stops at AS-1. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. +The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance identifier for the acting instance, such as `ag_7hq2x9`: a plain string with no colon, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. The broker creates it at registration, as a random value stored under the SPIFFE ID with a uniqueness constraint; AS-1 looks it up from the validated `actor_token`, and the full SVID stops at AS-1. The ID-JAG also carries the `resource` the broker requested. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. **AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, and that `client_id` matches the authenticated client (§4.4.1), and rejects a reused `jti` (RFC 7523 §3). A lost redemption response is recovered with a new ID-JAG. It then maps the AS-1 subject to a native user: @@ -117,17 +117,26 @@ Test client authentication at both servers: Test the session-open exchange: -- AS-1 rejects access tokens it did not issue, tokens for another audience, expired or revoked tokens, a second conversion of the same `jti`, and requests for wider scope. +- AS-1 rejects access tokens it did not issue, tokens for another audience, expired or revoked tokens, a second conversion of the same `jti`, and a `scope` parameter. +- AS-1 accepts `typ` `at+jwt` and `application/at+jwt`. - AS-1 accepts a Mecatl-API-audience token only from client `mecatl`; another client presenting the same token is rejected. - The refresh token record names the session and its owner; session close revokes it; presenting it at the refresh-token grant returns `invalid_grant`; a retried open with the same `jti`, client, and session revokes the first refresh token and returns a new one; a retry after the session closed, after its refresh token was revoked, past the source token's `exp`, or for another session is refused. - A crash between writing the converted-`jti` record and the refresh-token record leaves neither. -- The response echoes the `mecatl_session` entry; an owner that differs from the token's `sub_id`, or a session owned by another user, is refused. +- The response echoes the `mecatl_session` entry unchanged; an owner that differs from the token's `sub_id` is refused with `invalid_request`, and a session owned by another user or closed is refused with `invalid_authorization_details`. +- An echoed entry that differs from the request makes the broker revoke the refresh token and fail the open. +- A second open of a session that has a live refresh token, with a different `jti`, is refused. Two concurrent opens with one `jti` for two sessions yield one session. +- A broker crash after AS-1 issued the refresh token, before the broker stored it, is recovered by a retry that revokes the orphan. +- A retry after a lost response, with a new client assertion, succeeds; mecak8s's client refreshes and retries at most once. +- Losing the refresh token closes the session, and a new open of the same session ID is refused. Test ID-JAG issuance: - AS-1 rejects an expired, revoked, or foreign-client refresh token, and a refresh token whose session is closed or past its hard expiry. - AS-1 rejects an untrusted or expired SVID, an SVID with an audience not accepted by both AS-1 and the broker, and an instance not registered to the refresh token's session. -- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `ag_` identifier of the validated actor and which has no nested `act` and has `act.iss` set to AS-1's issuer. A caller cannot change `sub` or `act` through request parameters. +- The issued ID-JAG has `typ=oauth-id-jag+jwt`, the `sub` from the refresh token record, `client_id=mecatl`, AS-2 `aud`, a fresh `jti`, and an `act` with exactly one level, whose `sub` is the opaque `ag_` identifier of the validated actor and which has no nested `act` and has `act.iss` set to AS-1's issuer. A caller cannot change `sub` or `act` through request parameters. The ID-JAG carries the requested `resource`. +- Each refusal carries the reason code in `error_description` that [error reasons](credentials-and-lifecycle.md#error-reasons) assigns, and the broker maps it to the listed gRPC status and mecak8s outcome, including `mcp_owner_action_required`. +- An exchange after the upstream token's recorded expiry is refused without calling the IdP. +- A client assertion or ID-JAG replayed inside the clock-skew window after its `exp` is rejected; `jti` stores are shared across replicas. Test AS-2 redemption: @@ -136,17 +145,21 @@ Test AS-2 redemption: - AS-2 never resolves through `sub_id` or email; a matching `sub_id` without a mapping fails closed. - `V` carries the native user, the ID-JAG's single-level `act` unchanged, `client_id=mecatl`, and the vMCP audience, with no refresh token. - After a lost redemption response, the broker mints a new ID-JAG instead of resending the first. +- A requested `resource` other than the ID-JAG's `resource` claim is refused with `invalid_target`. Test the `V` cache: - The broker treats `V` as expired a configured skew margin before `exp`. -- On a 401 `invalid_token` from the vMCP, the broker drops `V` and repeats the exchange once; a second 401 fails the call. +- On a 401 `invalid_token` from the vMCP, the broker drops `V` and repeats the exchange once within the same call; a second 401 fails the call. A 401 for an older `V` does not drop a newer one another call cached. +- N concurrent calls that miss the cache for one key cause one exchange. +- Startup fails when the skew margin is not shorter than the `V` lifetime. - The `ag_` identifier in `act` is unchanged after the instance's SVID is renewed. Test schedule authority: -- Setting up a schedule converts a fresh access token with a `mecatl_schedule` entry into a separate record. -- A schedule's record is refused as the subject token of a session's ID-JAG exchange, and a session's refresh token is refused for a scheduled run. +- Setting up a schedule converts a fresh access token with a `mecatl_schedule` entry into a schedule grant. +- When the schedule fires, the broker exchanges the schedule grant for a session refresh token whose expiry is no later than the horizon; the run's calls then pass steps (6) to (9). Revoking the schedule grant revokes that session's refresh token. +- A schedule grant is refused as the subject token of an ID-JAG exchange, and a session's refresh token is refused for opening a scheduled run. - A scheduled run whose owner differs from the owner recorded with the schedule's authority is refused. Test vMCP and the upstream credential: @@ -188,7 +201,11 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out, its expiry recorded, and refreshes single-flight per user | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | | AS-1 and the vMCP record the operation and attempt IDs the broker passes with each exchange and MCP request, marked as reported | No | Small | Identity work | | Session-open owner check: compare the session owner the broker passes with the token's `sub_id`, and keep the owner on the refresh-token record | No | Small | Identity work | -| Parse `mecatl_session` and `mecatl_schedule` `authorization_details` at token exchange and echo the granted entry | No | Small | Identity work | +| Parse `mecatl_session` and `mecatl_schedule` `authorization_details` at token exchange and echo the granted entry unchanged | No | Small | Identity work | +| Accept a schedule grant as the subject token for opening a scheduled run's session, capping the new refresh token at the schedule's horizon | No | Small | Identity work | +| A reason code in `error_description` for each token-exchange refusal | No | Small | Identity work | +| Carry the requested `resource` into the ID-JAG and check it at redemption | Unknown; verify | Small | Identity work | +| One upstream refresh token per user, with its expiry and an in-flight marker, refreshed under a lease | No | Small to medium | Identity work | | Accept a client assertion whose `aud` is the issuer identifier alone, as draft-ietf-oauth-rfc7523bis requires | Unknown; verify | None if accepted | Identity work | | Request a refresh token from the upstream IdP at sign-in (`offline_access`, or the IdP's equivalent) | Unknown; verify per IdP | Configuration | Identity work | | Reject a token whose upstream session records were deleted | No; the lookup returns an empty credential map instead of failing | Small | Identity work | @@ -307,6 +324,9 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - The broker refuses a call whose reported run owner differs from the owner recorded with the grant in use, the company identity, and records the denial. - A child run's owner equals its parent run's owner; the broker refuses a delegation that changes it. +- `ReportDelegation` is refused for an unknown delegating run, an instance not registered to the session, or a delegating run that is not its parent delegation's receiving run. A repeat with the same content succeeds; a repeat with different content returns `ALREADY_EXISTS`. +- A call naming a delegation that was not reported is denied. +- Registration into a closed session, or a session bound to another workload, is refused. - Two users' calls through the same instance never share a cached `V`, ID-JAG, or vMCP session ID. - Signing out everywhere revokes only that user's grants and cache entries. @@ -321,7 +341,10 @@ Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#si - When the upstream IdP returns `invalid_grant` before the upstream token's recorded expiry, or when no expiry is recorded, AS-1 revokes all the user's grants, including schedule authority, and the exchange fails. - When it returns `invalid_grant` at or after the recorded expiry, AS-1 ends the user's grants with a "needs owner action" outcome, not a revocation. - A network error or 5xx fails that exchange and revokes nothing. -- Concurrent exchanges for one user cause one upstream refresh. A client's own sign-out does not remove the upstream token AS-1 re-checks. Re-checks happen at most once per interval. +- Concurrent exchanges for one user, across replicas, cause one upstream refresh. When the refresh lease times out, waiting exchanges fail with 503 and revoke nothing. +- After a lost IdP response or a crash during a refresh, a rotating IdP's `invalid_grant` on the next refresh revokes with reason `owner_action_required`, not `upstream_revoked`. +- An upstream revocation runs the broker's sign-out-everywhere path: live runs stop, parked and in-flight calls are cancelled, and cached `V` is dropped. +- A second sign-in replaces the user's upstream refresh token. A client's own sign-out does not remove it. Re-checks happen at most once per interval. ## Work breakdown diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 5e1fefebeb..279c5b733a 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -37,7 +37,7 @@ The broker authenticates at both servers as one OAuth client, `mecatl`, with `pr AS-2 receives neither Alice's AS-1 access token, `RT-A42`, nor the SVID. It receives only the ID-JAG. -AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different administrative domains, and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one administrative domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across trust domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. +AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different trust domains, in the OAuth sense of a security and administrative boundary (§2.2), which this design calls administrative domains to keep "trust domain" for SPIFFE; and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one administrative domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. ## Following one call @@ -95,7 +95,7 @@ AS-1 checks: - **The client.** The assertion verifies against `mecatl`'s registered key. - **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user through this request. AS-1 runs in the same process as the broker core, so this guards against broker bugs, not against a compromised broker. -- **The upstream grant.** If the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first; see [upstream re-check](credentials-and-lifecycle.md#sign-out-and-revocation). +- **The upstream grant.** If the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first; see [upstream re-check](credentials-and-lifecycle.md#upstream-re-check). - **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. - **The target.** `gateway.example.com` is an allowed AS-2 for this client. @@ -132,7 +132,7 @@ Here `access_token` carries the grant for AS-2; it is not an access token the vM This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. -`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `ag_` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted the `agent:` identifier, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. AS-1 derives the identifier from the validated reviewer SVID; the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. +`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `ag_` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted `act`, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. The broker creates the identifier when it registers the instance, as a random value stored under the SPIFFE ID; AS-1 looks it up from the validated reviewer SVID, and the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. `aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). diff --git a/docs/drafts/agent-identity/threat-model.md b/docs/drafts/agent-identity/threat-model.md index c5b0eaa394..60bd7533e1 100644 --- a/docs/drafts/agent-identity/threat-model.md +++ b/docs/drafts/agent-identity/threat-model.md @@ -22,7 +22,7 @@ Where the broker runs matters against one attacker: someone with code execution | Threat from a compromised mecak8s | Broker inside mecak8s | Separate broker service | | --- | --- | --- | -| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes or when its refresh token's hard expiry passes. mecak8s can also open new sessions with fresh tokens from users who are using it now. | +| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes, when its refresh token's hard expiry passes, when the user signs out everywhere, or when AS-1's upstream re-check revokes the user's grants. mecak8s can also open new sessions with fresh tokens from users who are using it now. | | Steal refresh tokens or signing keys | Attack works. A KMS stops the attacker from copying a key out. It does not stop the attacker from signing with the key while the attacker is inside the process. | Attack blocked, if the broker has its own database credentials and only the broker's service account can sign with its KMS keys. | | Read users' provider tokens in embedded mode | Attack works, if the gateway store shares storage credentials with mecak8s. | Attack blocked, if the embedded gateway is its own deployment with its own credentials. | | Tamper with broker and AS-1 audit records | Attack partly blocked. Records that are already in an external append-only store survive. From then on, the compromised process can forge or suppress its own records. | Attack blocked for broker and AS-1 records, because mecak8s does not write them. This holds if the records go to an append-only store that mecak8s cannot write. | From 2470851b81e217dc4107359cdaa84354ae50e223 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 12:58:27 +0200 Subject: [PATCH 46/50] docs: close the credential flow's revocation and session gaps Apply the fourth review round: - Schedule setup and the scheduled-run session open get their own wire bodies and checks; a run's session closes when the run ends. - A user-wide revocation records a revocation time that session open and schedule setup enforce, and also revokes every client's refresh tokens; sign out everywhere follows one path whoever starts it. - A revoked grant returns its stored reason, and the broker returns the same reason and status when it finds a closed session. - A later open by the same owner replaces a session's refresh token, so an orphaned token can no longer block the session. - Replacing a session re-registers live instances with their existing incarnations and ag_ identifiers, and re-reports open delegations. - Each run is bound to one instance and owner; a root run needs a root instance, and a delegated run the delegation's receiving instance. - AS-2 returns reason codes; a missing consent link is consent_required, the default for external gateways without reasons. - Reaching the upstream expiry, or an IdP without refresh tokens, ends the user's authority with owner_action_required. Also: the reason-code grammar and full mapping table, session-open reasons, registration retry rules, failure tables per step, and the matching tests, record fields, and ledger rows. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 6 +- .../agent-identity/agent-definitions.md | 2 +- docs/drafts/agent-identity/audit.md | 2 +- .../credentials-and-lifecycle.md | 286 ++++++++++++------ docs/drafts/agent-identity/implementation.md | 52 +++- .../agent-identity/mcp-gateway-modes.md | 2 +- docs/drafts/agent-identity/threat-model.md | 2 +- 7 files changed, 233 insertions(+), 119 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index ceeb57cd3a..25ba6ba8dc 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -42,7 +42,7 @@ mecak8s hosts the API and the harness. It validates the AS-1 access token of the The **identity broker** establishes who is acting. It is the existing MCP broker, extended, and one trusted component. Once the authority work is in place, it also bounds what each agent can reach. Its modules are: - The **definition loader** loads agent definitions from configured sources, records each one's source and revision, and gives the harness the execution configuration. A session's selected definitions stay fixed, so a later change at the source cannot silently replace them. The operator says which definitions may be a session's root, and the broker refuses to register a root instance outside that set. See [agent definitions](agent-definitions.md#loading-definitions). -- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in an agent trust domain separate from the platform's and from AS-1 and AS-2. It signs on mecak8s's workload-authenticated registration, which is an assertion, not attestation: no evidence about the agent itself is produced or checked. +- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in its own SPIFFE trust domain, separate from the platform's, with a signing key separate from AS-1's and AS-2's; while the broker runs inside mecak8s, all three keys share one process. It signs on mecak8s's workload-authenticated registration, which is an assertion, not attestation: no evidence about the agent itself is produced or checked. - **AS-1**, Mecatl's login authorization server, signs users in through the company identity provider (IdP) and issues ID-JAGs (Identity Assertion JWT Authorization Grants). An ID-JAG is a short-lived, single-use signed grant that names the user and the acting agent. It is addressed only to AS-2, the gateway's authorization server, which redeems it for an access token, and only when the broker's client presents it. - The **authorization and audit** logic checks each protected MCP call and records what it decided and observed. @@ -108,9 +108,9 @@ The [threat model](threat-model.md) has the full table, the facts mecak8s report The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. That key belongs to the broker, ideally in a key management service; ToolHive's authorization server also accepts a SPIFFE JWT-SVID as client authentication, which could later replace the key with the broker's own workload identity. -The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains in the OAuth sense, which this design calls administrative domains to keep "trust domain" for SPIFFE; embedded mode, where all parties share one, reuses the format to keep one protocol path. +The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains in the OAuth sense ([administrative domains](credentials-and-lifecycle.md#7-id-jag-from-as-1)); embedded mode, where all parties share one, reuses the format to keep one protocol path. -Agent credentials are SPIFFE JWT-SVIDs. Each protected call carries two identities, comparable to the layered workload authentication the WIMSE architecture describes, except that only the first is authenticated: the platform workload is authenticated, and the acting agent is asserted by the agent-instance issuer. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. +Agent credentials are SPIFFE JWT-SVIDs. Each protected call carries two identities, comparable to the layered workload authentication the WIMSE architecture describes, except that only the first is authenticated: the platform workload is authenticated, and the acting agent is asserted by the agent-instance issuer. Registration links the two: the broker accepts an agent only if it was registered to a session bound to that workload. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do. ## Order of work diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index 6b7d9eef26..b9cfed559d 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -126,7 +126,7 @@ When the main creates the reviewer, the harness creates the child session, and m Registration establishes identity records. It does not authorize spawning, run execution, or a tool call. The harness manages execution. The broker [authorizes each MCP request](mcp-authorization.md#two-requests-two-authorization-decisions). Creation ancestry and delegation are different links. See [relationships and lifetimes](#relationships-and-lifetimes). -The broker does not admit runs. mecak8s reports the run with every call and reports each delegation when it happens, through the [delegation report](credentials-and-lifecycle.md#6-per-operation-authorization); the broker records both as [lineage events](audit.md#lineage), marked as reported. A root run starts when a user speaks to the main agent or a schedule fires. Its owner is the owner recorded with the grant the run uses: the company identity, not AS-1's subject. For a child run, mecak8s reports the owner, and the broker refuses a delegation whose reported owner differs from the delegating run's. On every protected call, the broker checks that the reported run owner equals the owner recorded with the grant in use; see [run owner](run-owner.md). Run grants and task-specific delegation bounds would make runs something the broker admits; both are [authority work](README.md#order-of-work). +The broker does not admit runs. mecak8s reports the run with every call and reports each delegation when it happens, through the [delegation report](credentials-and-lifecycle.md#delegation-report); the broker records both as [lineage events](audit.md#lineage), marked as reported. A root run starts when a user speaks to the main agent or a schedule fires. Its owner is the owner recorded with the grant the run uses: the company identity, not AS-1's subject. For a child run, mecak8s reports the owner, and the broker refuses a delegation whose reported owner differs from the delegating run's. On every protected call, the broker checks that the reported run owner equals the owner recorded with the grant in use; see [run owner](run-owner.md). Run grants and task-specific delegation bounds would make runs something the broker admits; both are [authority work](README.md#order-of-work). The broker trusts the lifecycle assertions that mecak8s sends with its workload credential. mecak8s is the registrant. A ServiceAccount cannot tell trusted lifecycle code from compromised code in the same process. This design does not isolate them. The user does not come from a mecak8s report. AS-1 saw Alice sign in. See [deployment and threats](README.md#deployment-and-threats) for what a compromised mecak8s can do. A SPIRE agent inspects a workload before it issues an identity to it. The broker cannot observe mecak8s's instances in this way. It signs on mecak8s's registration. The registration is an assertion, not attestation. The [audit evidence table](audit.md#evidence-and-provenance) lists which registration facts the broker verifies and which it accepts as mecak8s assertions. diff --git a/docs/drafts/agent-identity/audit.md b/docs/drafts/agent-identity/audit.md index d3e214fbe1..e777596b5f 100644 --- a/docs/drafts/agent-identity/audit.md +++ b/docs/drafts/agent-identity/audit.md @@ -62,7 +62,7 @@ The identity work needs these event families: - definition load, instance registration, and agent-instance credential issuance; - each delegation from one run to another; - broker session open and close, AS-1's conversion record at session open, and any open refused because the token's user does not own the session; per-call events point to the session-open record for the user; -- sign-out everywhere, with each run stopped, broker session closed, refresh token revoked, and schedule authority revoked, and the revocation reason; +- sign-out everywhere, with each run stopped, broker session closed, refresh token revoked, and schedule grants revoked, and the revocation reason; - an upstream re-check failure and the revocation that follows; - ID-JAG issuance and denial at AS-1; - redemption and denial at AS-2, and consent-link creation and revocation in external mode; diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 6b4bd486cb..fd28866fc0 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -2,7 +2,7 @@ *This chapter describes the intended architecture, not current implementation status.* -An agent instance can participate in multiple runs. Its instance credential identifies it to the broker; presenting that credential does not by itself authorize an MCP tool call. The harness manages run execution. The broker checks the session and execution context. Once the [authority work](README.md#order-of-work) is in place, it also checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar. User, resource, argument, and dynamic delegation policies are deferred. Renewing the instance credential does not itself extend or widen permission to call tools. +An agent instance can participate in multiple runs. Its agent-instance SVID identifies it to the broker; presenting that SVID does not by itself authorize an MCP tool call. The harness manages run execution. On every call the broker checks the workload, the session, the acting instance, the run and its owner, and the route. Once the [authority work](README.md#order-of-work) is in place, it also checks the acting instance's effective server/tool ceiling, its definition ceiling narrowed by its parent's, with Cedar. User, resource, argument, and dynamic delegation policies are deferred. Renewing the agent-instance SVID does not itself extend or widen permission to call tools. The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that sign-in itself, so every later statement about the human traces back to a login AS-1 witnessed, not to a report from the harness. @@ -19,9 +19,10 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that | --- | --- | --- | | **AS-1 access token** | Alice's identity for requests to the Mecatl API. | AS-1 → Alice's client (TUI or Studio) → mecak8s. Passed to the broker once, at session open. | | **Client refresh token** | The client's ability to renew its own AS-1 access token. | AS-1 → client only. Never leaves the client. | -| **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and issuer. It identifies neither Alice nor an agent instance. | +| **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and agent-instance issuer. It identifies neither Alice nor an agent instance. | | **Agent-instance JWT-SVID** | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | | **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl`, the broker session, and the user. Accepted only as the subject of an ID-JAG exchange. | +| **Schedule grant** | Alice's delegated authority for one schedule, until its horizon. | AS-1 → broker → AS-1. Bound to client `mecatl`, the schedule, and the user. Accepted only to open a scheduled run's session. | | **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | | **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | | **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. Cached per session, instance, gateway, and user. | @@ -39,7 +40,7 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that | Reference | `op-list-prs-001`, `op-pr42-001` | Correlation IDs for two tool operations. They identify operations, not people, and authorize nothing. | | Credential | `mainSVID`, `reviewerSVID` | Registered agent-instance credentials. | | Credential | `RT-A42` | The broker refresh token for session `A42`. | -| State | Grant | AS-1's record of authority Alice delegated: the record behind a broker refresh token, or a schedule's authority. Each grant records its owner, the company identity. | +| State | Grant | AS-1's record of authority Alice delegated: the record behind a broker refresh token, or a schedule grant. Each grant records its owner, the company identity, and, once revoked, the reason. | | Credential | `J-main`, `J-reviewer` | ID-JAGs naming Alice and each acting agent. | | Credential | `V-main`, `V-reviewer` | AS-2 access tokens naming native Alice and each acting agent. | @@ -120,7 +121,8 @@ sequenceDiagram H->>I: registerInstance(reviewer, definition reference global/code-reviewer,
parentRef = mainRef, A42), workload credential I-->>H: reviewerRef H->>B: ReportDelegation D42: A42, R41 to R42, run owner Alice - B->>B: Child run owner = parent run owner, parent in the same session tree + Note over H,B: Delegation report + B->>B: R41 in A42 and bound to main, reviewer registered to A42
child run owner = parent run owner H->>I: requestSVID(reviewerRef), workload credential I-->>H: reviewerSVID end @@ -209,7 +211,7 @@ The client receives an access token for the Mecatl API and keeps its own refresh **Checks, at the broker and the agent-instance issuer,** offline against the cluster's keys: -1. The token is a projected token. A legacy Secret-based token, which has no `exp`, is rejected. +1. The token carries the `kubernetes.io` `pod` claim, which only a projected, pod-bound token has. A legacy Secret-based token is rejected. 2. `alg` is on the allowlist, and the signature verifies against the cluster's JWKS. The JWKS is pinned in configuration or fetched by OIDC discovery from the configured issuer, never from a URL in the token. 3. `iss` is exactly the configured cluster issuer. 4. `aud` contains the broker's audience. A token with the Kubernetes API server's audience is rejected. @@ -230,9 +232,9 @@ The `kubernetes.io` claims are recorded for audit and never decide anything. ### (3) Session open and the broker refresh token -At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, with its workload credential and the owner it holds for `hostS42`. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. Clients refresh their access token before each new session open, so the token is fresh and not yet used to open a session. The broker runs at most one `OpenSession` per broker session at a time. +At `OpenSession`, mecak8s passes Alice's current AS-1 access token to the broker once, with its workload credential and the owner it holds for `hostS42`. This replaces the harness reporting `human {issuer, subject}` as plain data: the broker now holds a token AS-1 signed for Alice's client, not mecak8s's statement about her. Clients refresh their access token before each new session open, so the token is fresh and not yet used to open a session. The broker runs at most one `OpenSession` per broker session at a time. A scheduled run opens its session from the schedule grant instead; see [scheduled runs](#scheduled-runs). -**Input.** The broker exchanges the token at AS-1 with RFC 8693, authenticating as client `mecatl` ([step (5)](#5-broker-client-authentication)). RFC 8693 has no parameter for the broker session or its owner, so the request carries both in an RFC 9396 `authorization_details` entry of type `mecatl_session`. RFC 9396 does not mention token exchange, but allows `authorization_details` wherever `scope` is used (§3) and with any grant type (§11.2). The owner field is deployment-defined context for the owner check, not an authorization the user granted. The `mecatl_session` type requires AS-1 to echo the entry unchanged. Form encoding is shown decoded: +**Input.** The broker exchanges the token at AS-1 with RFC 8693, authenticating as client `mecatl` ([step (5)](#5-broker-client-authentication)). RFC 8693 has no parameter for the broker session or its owner, so the request carries both in an RFC 9396 `authorization_details` entry of type `mecatl_session`. RFC 9396 does not mention token exchange; Mecatl applies its §3, which allows `authorization_details` wherever `scope` is used, by analogy. The owner field is deployment-defined context for the owner check, not an authorization the user granted. The `mecatl_session` type requires AS-1 to echo the entry unchanged. Form encoding is shown decoded: ```text POST https://broker.example.com/as/oauth/token @@ -251,12 +253,12 @@ client_assertion= 1. Client authentication as `mecatl` ([step (5)](#5-broker-client-authentication)). 2. The client may use the token-exchange grant. 3. The request is well formed: the two token types above, no `actor_token`, no `scope`, and exactly one `mecatl_session` entry with a session and an owner. -4. The subject token is an AS-1 access token: signature, `iss`, `exp`, not revoked at AS-1, `aud` is the Mecatl API, and `client_id` is the TUI or Studio. Accepting a Mecatl-API token from `mecatl` is a narrow policy for this one client, not a general audience bypass. +4. The subject token is an AS-1 access token: signature, `iss`, `exp`, not revoked at AS-1, `aud` is the Mecatl API, and `client_id` is the TUI or Studio. It was issued after the user's last user-wide revocation, if any; see [the revocation time](#sign-out-and-revocation). Accepting a Mecatl-API token from `mecatl` is a narrow policy for this one client, not a general audience bypass. 5. The company identity in the token's `sub_id` equals the owner in the entry. 6. Session `A42` has never been closed, and has no recorded owner yet or its recorded owner is that same identity. Session IDs are never reused. -7. The token's `jti` has not opened a session before, and `A42` has no live refresh token. The one exception is a retry: the same `jti`, client, and session, within the source token's `exp`, while `A42` is open and its refresh token has not been revoked. +7. The token's `jti` has not opened a session before, or this is a retry: the same `jti`, client, and session, within the source token's `exp`, while `A42` is open. -**Output.** In one transaction, AS-1 records the converted `jti`, records the owner of `A42` if this is the first open, writes the refresh-token record, and, on a retry, revokes the refresh token it issued the first time. RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1), and AS-1 echoes the `mecatl_session` entry (RFC 9396 §7): +**Output.** In one transaction, AS-1 records the converted `jti`, records the owner of `A42` if this is the first open, writes the refresh-token record, and revokes `A42`'s previous refresh token if it has one. A retry, or a later open by the same owner, therefore replaces the session's refresh token and never adds a second one. RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1), and AS-1 echoes the `mecatl_session` entry (RFC 9396 §7): ```json { @@ -269,38 +271,38 @@ client_assertion= } ``` -`expires_in` is illustrative; the hard expiry is not selected. `RT-A42` is opaque to the broker. Its meaning lives in an AS-1 record, a **grant**, that binds it to client `mecatl`, to Alice as `u-alice-91`, to her company identity as the grant's recorded owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. +`expires_in` is illustrative; the hard expiry is not selected. `RT-A42` is opaque to the broker. Its meaning lives in an AS-1 record, a [grant](#example-nomenclature), that binds it to client `mecatl`, to Alice as `u-alice-91`, to her company identity as the grant's recorded owner, and to session `A42`, with a hard expiry. Every ID-JAG issued from it names that subject. -The broker checks that the echoed entry equals the one it sent, then stores `RT-A42` with the echoed owner in its shared session storage, so any replica can use it and run the [run-owner check](#6-per-operation-authorization) without asking AS-1. If the echo differs, the broker revokes `RT-A42` and fails the open. The existing broker session record under `A42` keeps the workload and host-session binding; the human authority lives in `RT-A42`, not in a broker-local human record. +The broker checks that the echoed entry equals the one it sent, then stores `RT-A42` with the echoed owner and its expiry in its shared session storage, so any replica can use it and run the [run-owner check](#6-per-operation-authorization) without asking AS-1. If the echo differs, the broker revokes `RT-A42` (RFC 7009, or in process while AS-1 is a broker module), which closes `A42`, and fails the open. The existing broker session record under `A42` keeps the workload and host-session binding; the human authority lives in `RT-A42`, not in a broker-local human record. -**Failures.** +**Failures.** The reasons are listed under [error reasons](#error-reasons). | Check | Response | Retry | | --- | --- | --- | | 1 | 401 `invalid_client` | No; a configuration or key fault. | | 2 | 400 `unauthorized_client` | No. | | 3 | 400 `invalid_request`, `invalid_scope` for a `scope` parameter, or `invalid_authorization_details` for a malformed entry | No; a broker bug. | -| 4 | 400 `invalid_request` (RFC 8693 §2.2.2) | After the client refreshes its access token. | -| 5 | 400 `invalid_request`, recorded with the reason | No; mecak8s paired the token with the wrong session. RFC 8693 calls a subject token unacceptable by policy `invalid_request`. | -| 6 | 400 `invalid_authorization_details` (RFC 9396 §5), recorded with the reason | No; the session belongs to someone else or is closed. | -| 7 | 400 `invalid_request` | After the client refreshes its access token. | -| Lost response | None received | Yes: resend the same `subject_token` and `authorization_details` with a new client assertion. Check 7 accepts it as a retry and replaces the first refresh token. | +| 4 | 400 `invalid_request` with `subject_invalid` (RFC 8693 §2.2.2), or with the user-wide revocation's reason for a token issued before it | `subject_invalid`: after the client refreshes its access token. A revocation reason: after Alice signs in again. | +| 5 | 400 `invalid_request` with `owner_mismatch`, recorded | No; mecak8s paired the token with the wrong session. RFC 8693 calls a subject token unacceptable by policy `invalid_request`. | +| 6 | 400 `invalid_authorization_details` (RFC 9396 §5) with `session_owned`, recorded | No; the session belongs to someone else or is closed. | +| 7 | 400 `invalid_request` with `token_used` | After the client refreshes its access token. | +| Lost response | None received | Yes: resend the same `subject_token` and `authorization_details` with a new client assertion. Check 7 accepts it as a retry. | -mecak8s reports any of these to the client as a failed session open. It answers failures of checks 4 and 7 with 401 `invalid_token`, so the client refreshes its access token and tries once more. +mecak8s reports any of these to the client as a failed session open. It answers `subject_invalid` and `token_used` with 401 `invalid_token`, so the client refreshes its access token and tries once more. -If the broker crashes after AS-1 issued `RT-A42` but before storing it, nothing holds that refresh token. mecak8s retries the open when the broker returns: within the source token's `exp` the retry revokes it; later, AS-1 refuses the open and the orphan dies when the session is closed or its hard expiry passes. +If the broker crashes after AS-1 issued `RT-A42` but before storing it, nothing holds that refresh token. mecak8s retries the open when the broker returns, and the retry, or any later open of `A42` by the same owner with a fresh token, replaces the orphan. -**Why.** One conversion per `jti` stops a leaked access token from opening a second session. A retry issues a new refresh token rather than returning the first, because AS-1 stores only a hash of each refresh token and cannot return it again. Revoking the first in the same transaction keeps one live refresh token per session, and writing all the records together means a crash cannot leave a converted `jti` with no refresh token, or a refresh token whose conversion was not recorded. +**Why.** One conversion per `jti` stops a leaked access token from opening a second session. A retry issues a new refresh token rather than returning the first, because AS-1 stores only a hash of each refresh token and cannot return it again. Revoking the previous one in the same transaction keeps one live refresh token per session, and writing all the records together means a crash cannot leave a converted `jti` with no refresh token, or a refresh token whose conversion was not recorded. `RT-A42` is accepted only as the subject token of an ID-JAG exchange; presented at the refresh-token grant, it returns `invalid_grant`, so the broker cannot mint Mecatl API tokens as Alice. `RT-A42` does not rotate, because [client authentication](#5-broker-client-authentication) already binds it to the broker, and rotation would make concurrent exchanges from several broker replicas look like token reuse. Checks 5 and 6 exist because the broker supplies `A42`, and only mecak8s checks that Alice owns `hostS42`, so a mecak8s bug could pair her token with another user's session. AS-1 records the owner at the first open and refuses any later open of `A42` for a different user. Both checks compare facts inside Mecatl, so they catch mistakes, not a compromised mecak8s, which could report a consistent lie. -**Session end.** `RT-A42` is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Closing the session, signing out everywhere, the hard expiry, and AS-1's upstream re-check revoke it. Losing `RT-A42` for any of these reasons closes `A42`: mecak8s opens a new broker session for `hostS42` with a fresh access token on Alice's next request. A closed session leaves a record, its ID, owner, and closing time, so check 6 can refuse to reopen it. The converted `jti` is kept until the source token's `exp` plus the clock skew. - **Does not prove.** That mecak8s paired Alice's token with her own session, only that its reports agree with each other. That Alice is present after the open: the refresh token outlives her access token. -**Evidence.** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry. It records a refused open with its reason, and a retry with the revoked first refresh token. +**Evidence.** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry. It records a refused open with its reason, and each refresh token a later open replaced. + +**Session end.** `RT-A42` is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Closing the session, signing out everywhere, the hard expiry, and AS-1's upstream re-check revoke it. Losing `RT-A42` for any of these reasons closes `A42`. On Alice's next request, mecak8s opens a new broker session for `hostS42` with a fresh access token, registers its live instances into it again, parents first, with their existing incarnations ([step (4)](#4-agent-instance-jwt-svid)), and reports the open delegations again ([delegation report](#delegation-report)). A closed session leaves a record, its ID, owner, and closing time, so check 6 can refuse to reopen it. The converted `jti` is kept until the source token's `exp` plus the clock skew. **Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with a `mecatl_session` entry naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs `authorization_details` at AS-1's refresh grant, a session-bound token per session in the TUI and Studio, and a two-step session open. It changes nothing downstream of `RT-A42`. @@ -317,11 +319,12 @@ Checks 5 and 6 exist because the broker supplies `A42`, and only mecak8s checks 1. The workload is authenticated ([step (2)](#2-workload-credential)). 2. Session `A42` is open and bound to that workload. 3. The definition reference names a definition the loader recorded, or, for a definition the broker did not load, is accepted as a mecak8s report and marked as such; see [definition provenance](agent-definitions.md#definition-provenance). -4. With no parent, including after a fork or clear, the definition is root-eligible. With a parent, the parent is registered in the same session tree. +4. With no parent, including after a fork or clear, the definition is root-eligible. With a parent, the parent is registered to `A42`. 5. The definition category and name match `[A-Za-z0-9._-]+` and are neither `.` nor `..` (SPIFFE-ID §2.2). A name that would need encoding is rejected, not rewritten. -6. The name and the nesting depth stay within the caps. Proposed: names up to 63 bytes and up to 8 child levels below the root, which keeps the longest subject near 1,400 bytes. The agent-instance issuer also checks the whole URI against the 2,048 bytes SPIFFE-ID §2.3 says implementations should not generate beyond. +6. The name and the nesting depth stay within the caps. Proposed: the definition category and name each up to 63 bytes, a `legacy_` incarnation no longer than an `inc_` one, and up to 8 child levels below the root, which keeps the longest subject near 1,600 bytes. The agent-instance issuer also checks the whole URI against the 2,048 bytes SPIFFE-ID §2.3 says implementations should not generate beyond. +7. If the instance was registered before, to an earlier broker session of `hostS42`, that session's recorded owner equals `A42`'s. -Registration records identity, not permission to spawn, run, or call tools. The agent-instance issuer constructs the canonical subject; see the [registration checks](agent-definitions.md#registering-instances). In the same transaction the broker creates the instance's **`ag_` identifier**: a random, opaque value such as `ag_7hq2x9`, stored under the SPIFFE ID with a uniqueness constraint. AS-1 later writes it into `act`. +Registration records identity, not permission to spawn, run, or call tools. The agent-instance issuer constructs the canonical subject; see the [registration checks](agent-definitions.md#registering-instances). In the same transaction the agent-instance issuer gives the instance its **`ag_` identifier**: a random, opaque value such as `ag_7hq2x9`, created only if the SPIFFE ID has none yet and reused otherwise. Both directions are unique, one `ag_` identifier per SPIFFE ID and one SPIFFE ID per `ag_` identifier, and an identifier is never reassigned. Registering the same instance into a new session therefore keeps its `ag_` identifier. AS-1 later writes it into `act`. **Output.** The instance reference, its canonical subject, and, on `requestSVID`, a JWT-SVID signed by the agent-instance issuer. The reviewer's, deliberately using only the claims JWT-SVID requires: @@ -342,12 +345,22 @@ mecak8s checks that the returned subject is the registered instance. The harness 2. The verifier selects that trust domain's bundle and, in it, the keys whose `use` is `jwt-svid`. The header must carry `kid`, which selects one of them; JWT-SVID §2.2 makes `kid` optional, so requiring it is Mecatl policy. An unknown `kid` triggers one rate-limited bundle refresh, then rejection. A platform SVID with the same audience fails here, because it belongs to another trust domain and bundle. 3. `alg` is on the allowlist JWT-SVID §2.1 permits: RS, ES, or PS with SHA-256 or longer, never `none` or HMAC. 4. The signature verifies. -5. `aud` is exactly the broker's audience, as its only value. +5. `aud` is exactly the broker's audience, as its only value. JWT-SVID §3.2 only requires the verifier's own value to be present; the single value is Mecatl policy. 6. `exp` has not passed, allowing the clock skew, and is no further in the future than the configured maximum SVID lifetime. 7. `sub` matches the full canonical subject of a registered instance byte for byte, and that instance is not deregistered. 8. The instance is registered to the session in use: `A42` from the request at the broker, the session of `RT-A42`'s record at AS-1. -**Failures.** Registration check 1 returns `UNAUTHENTICATED`, checks 2–4 `PERMISSION_DENIED`, and checks 5 and 6 `INVALID_ARGUMENT`; none is retryable. An expired SVID returns `UNAUTHENTICATED` with reason `svid_expired` at the broker and 400 `invalid_request` with that reason at AS-1. mecak8s then renews the SVID, one renewal per instance at a time, and retries the call once. Every other SVID failure returns `PERMISSION_DENIED` at the broker and `invalid_request` at AS-1 (RFC 8693 §2.2.2), and is not retryable. +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| Registration 1 | Step (2)'s codes | Only an expired workload credential. | +| Registration 2 | `FAILED_PRECONDITION` with the session's closing reason, or `PERMISSION_DENIED` for a session bound to another workload | After a new session opens. | +| Registration 3, 4, 7 | `PERMISSION_DENIED` | No. | +| Registration 5, 6 | `INVALID_ARGUMENT` | No. | +| Registration repeated | The same instance reference for the same session, workload, definition, parent, and incarnation; `ALREADY_EXISTS` for the same instance with different content | A lost response is retried safely. | +| SVID 6, expired | `UNAUTHENTICATED` with `svid_expired` at the broker; 400 `invalid_request` with `svid_expired` at AS-1 | mecak8s renews the SVID, one renewal per instance at a time, and retries once. | +| Any other SVID check | `PERMISSION_DENIED` at the broker; 400 `invalid_request` with `denied` at AS-1 (RFC 8693 §2.2.2) | No. | **Why.** The SVID gives agent identity its own SPIFFE trust domain and signing key, so verifiers can tell it apart from the OAuth issuers, and leaves a path to attestation and to policy shaped by RFC 9396 `authorization_details`. @@ -420,10 +433,10 @@ Local permissions and hooks run before each call. **Checks, at the broker,** before any exchange or dispatch: 1. The workload is authenticated ([step (2)](#2-workload-credential)). -2. Session `A42` is open and bound to that workload and `hostS42`. +2. Session `A42` is open and bound to that workload, and `RT-A42`'s hard expiry, stored at open, has not passed. 3. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid). -4. The run belongs to `A42`. A run naming no delegation is a root run of `A42`, recorded on first use. A run naming a delegation needs that delegation already [reported](#delegation-report) in `A42` with this run as its receiving run. -5. The reported run owner equals the grant's recorded owner, the company identity `(https://login.example.com, alice-204)` the broker stored with `RT-A42`, not AS-1's subject `u-alice-91`. The run owner never selects the grant. +4. The run belongs to `A42` and is bound to the SVID's instance. The broker keeps a run record of session, run, instance, owner, and delegation, created the first time the run appears and compared on every later call; two replicas racing to create it keep one and compare against it. A new run naming no delegation is a root run and needs a root instance, one with no parent. A run naming a delegation needs that delegation already [reported](#delegation-report) in `A42`, with this run and the SVID's instance as its receiving run and instance. +5. The reported run owner equals the run record's owner and the grant's recorded owner, the company identity `(https://login.example.com, alice-204)` the broker stored with `RT-A42`, not AS-1's subject `u-alice-91`. The run owner never selects the grant. 6. A route to the gateway exists for the tool. 7. Once the authority work is in place, the instance's stored effective ceiling allows this server and tool, with no Cedar evaluation errors. @@ -431,7 +444,15 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi **Output.** An allowed operation, dispatched in [step (9)](#9-vmcp-decision-and-upstream-credential). -**Failures.** Check 1 returns `UNAUTHENTICATED`. An expired SVID at check 3 returns `UNAUTHENTICATED` with reason `svid_expired`, and mecak8s renews and retries once. Every other failure of checks 2–7 denies the call with `PERMISSION_DENIED`, records the denial, and dispatches nothing; none is retried automatically, and retrying requires authorization again. +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| 1 | Step (2)'s codes | Only an expired workload credential. | +| 2, session closed | `FAILED_PRECONDITION` with the reason AS-1 recorded, the same status and reason AS-1 would return | mecak8s acts on the reason; see [error reasons](#error-reasons). | +| 2, other workload | `PERMISSION_DENIED` | No. | +| 3, expired | `UNAUTHENTICATED` with `svid_expired` | mecak8s renews and retries once. | +| 3 otherwise, 4–7 | `PERMISSION_DENIED`, recorded; nothing is dispatched | No; retrying requires authorization again. | **Why.** User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). The broker does not check provider connections. It authorizes the session, workload, actor, run, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. @@ -445,15 +466,24 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi **Checks, at the broker:** -1. The workload is authenticated, and `A42` is open and bound to it. -2. `R41` belongs to `A42`. If `R41` was itself delegated, its delegation's receiving run is `R41`. -3. `mainRef` and `reviewerRef` are both registered to `A42`. -4. The reported owner of `R42` equals the owner of `R41`. -5. `D42` has not been reported before, or was reported with exactly the same content. +1. The workload is authenticated ([step (2)](#2-workload-credential)). +2. `A42` is open and bound to that workload. +3. `R41` belongs to `A42` and is bound to `mainRef`. If `R41` has no run record yet and names no delegation, the broker records it as a root run of `mainRef` with the reported owner, as [step (6)](#6-per-operation-authorization) check 4 would. If `R41` was itself delegated, its delegation was reported with `R41` as the receiving run. +4. `reviewerRef` is registered to `A42`. +5. The reported owner of `R42` equals the owner of `R41`. +6. `D42` has not been reported before, or was reported with exactly the same content. -**Output.** A recorded delegation, which lets `R42` pass [check 4](#6-per-operation-authorization) of every later call. +**Output.** A recorded delegation and a run record for `R42`, bound to `reviewerRef` and its owner, which lets `R42` pass [check 4](#6-per-operation-authorization) of every later call. -**Failures.** Check 1 returns `UNAUTHENTICATED` or `PERMISSION_DENIED`; checks 2–4 return `PERMISSION_DENIED`; a repeat of `D42` with different content returns `ALREADY_EXISTS`. A repeat with the same content succeeds, so mecak8s can retry a lost response. Until a consistent report arrives, `R42` cannot call tools. +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| 1 | Step (2)'s codes | Only an expired workload credential. | +| 2 | `FAILED_PRECONDITION` with the session's closing reason, or `PERMISSION_DENIED` | After a new session opens, mecak8s reports the delegation again. | +| 3–5 | `PERMISSION_DENIED` | No. Until a consistent report arrives, `R42` cannot call tools. | +| 6, different content | `ALREADY_EXISTS` | No. | +| 6, same content | Success | A lost response is retried safely. | **Why.** The broker sees delegations only through these reports. Checking each one when it arrives keeps a later call from naming a run the broker never heard of, or a run whose owner changed on the way down. @@ -465,7 +495,7 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)). -**Input.** An RFC 8693 token exchange with `RT-A42` as subject token, `reviewerSVID` as actor token, AS-2 as audience, and the vMCP as `resource`. The operation and attempt IDs travel as request parameters for AS-1's record, as reported trace context: +**Input.** An RFC 8693 token exchange with `RT-A42` as subject token, `reviewerSVID` as actor token, AS-2 as audience, and the vMCP as `resource`. The ID of the operation whose cache miss caused the exchange travels as a request parameter for AS-1's record, as reported trace context. Concurrent misses share one exchange, so it names the first of them: ```text POST https://broker.example.com/as/oauth/token @@ -478,18 +508,19 @@ requested_token_type=urn:ietf:params:oauth:token-type:id-jag audience=https://gateway.example.com resource=https://gateway.example.com/mcp mecatl_operation=op-pr42-001 -mecatl_attempt=1 client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer client_assertion= ``` +ID-JAG makes a refresh token as subject token optional (§4.3: an IdP "MAY additionally accept" them); AS-1 must support it. + **Checks, at AS-1:** 1. Client authentication as `mecatl`. 2. The request is well formed: the three token types above, an `audience`, and a `resource`. 3. `audience` is one of the AS-2 issuers configured for `mecatl`, and `resource` is a vMCP configured for that AS-2. -4. `RT-A42` resolves to a `mecatl_session` grant bound to `mecatl` and not revoked; session `A42` is open; the hard expiry has not passed. -5. The upstream grant is current: its recorded expiry has not passed, and, if the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first ([upstream re-check](#upstream-re-check)). +4. `RT-A42` resolves to a `mecatl_session` grant bound to `mecatl` and not revoked; session `A42` is open; the hard expiry has not passed. A schedule grant is refused here. +5. Alice's upstream refresh token exists and its recorded expiry has not passed. If the re-check interval has passed, AS-1 refreshes it first ([upstream re-check](#upstream-re-check)). 6. The SVID passes the [SVID checks](#4-agent-instance-jwt-svid). 7. AS-1 authorizes the combination of client, subject, actor, and target. @@ -534,13 +565,13 @@ client_assertion= | 1 | 401 `invalid_client` | Call fails; not retryable. | | 2 | 400 `invalid_request` | Call fails; a broker bug. | | 3 | 400 `invalid_target` | Call fails; a configuration fault. | -| 4 | 400 `invalid_request`, reason `session_closed` | `A42` is closed; mecak8s opens a new session on Alice's next request. | -| 5, upstream revoked | 400 `invalid_request`, reason `upstream_revoked` | AS-1 has revoked all of Alice's grants; see [upstream re-check](#upstream-re-check). | -| 5, owner action needed | 400 `invalid_request`, reason `owner_action_required` | Alice must sign in again. | -| 5, IdP unreachable or 5xx | 503 with `Retry-After`, nothing revoked | Call fails closed; a later call retries. A 503 is plain HTTP, not an OAuth error, and never means revocation. | -| 6, expired | 400 `invalid_request`, reason `svid_expired` | mecak8s renews the SVID and retries once. | -| 6, otherwise | 400 `invalid_request`, reason `denied` | Call denied. | -| 7 | 400 `invalid_request`, reason `denied` | Call denied. | +| 4 | 400 `invalid_request` with the grant's revocation reason: `session_closed`, `signed_out`, `upstream_revoked`, or `owner_action_required` | mecak8s acts on the reason; see [error reasons](#error-reasons). | +| 5, IdP refuses the refresh | 400 `invalid_request` with `upstream_revoked` or `owner_action_required` | AS-1 has revoked all of Alice's grants; see [upstream re-check](#upstream-re-check). | +| 5, recorded expiry reached, or no upstream refresh token | 400 `invalid_request` with `owner_action_required` | AS-1 has revoked all of Alice's grants; she must sign in again. | +| 5, IdP unreachable, 5xx, or refresh lease timed out | 503 with `Retry-After`, nothing revoked | Call fails closed as a retryable infrastructure error. A 503 is plain HTTP, not an OAuth error, and never means revocation. | +| 6, expired | 400 `invalid_request` with `svid_expired` | mecak8s renews the SVID and retries once. | +| 6, otherwise | 400 `invalid_request` with `denied` | Call denied. | +| 7 | 400 `invalid_request` with `denied` | Call denied. | | Lost response | None received | Request a new ID-JAG with a new client assertion. An unused ID-JAG expires harmlessly. | **Why.** ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); SVID check 8 is that missing binding. ID-JAG is a profile of [identity chaining across domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. @@ -549,11 +580,11 @@ AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. Th **Does not prove.** That the reviewer, rather than other code in the harness process, asked for the call: the SVID is asserted, not attested. -**Evidence.** AS-1 records the ID-JAG issuance: user, session `A42`, the validated instance's SPIFFE ID and its `ag_` identifier, target, `jti`, and the reported operation and attempt. +**Evidence.** AS-1 records the ID-JAG issuance: user, session `A42`, the validated instance's SPIFFE ID and its `ag_` identifier, target, `jti`, and the reported operation. ### (8) AS-2 access token -**Input.** The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), naming the vMCP with an RFC 8707 `resource`: +**Input.** The broker redeems the ID-JAG at AS-2 with the [RFC 7523 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1), naming the vMCP with an RFC 8707 `resource`. The parameter comes from RFC 8707, not the ID-JAG profile; without it, AS-2 uses the ID-JAG's `resource` claim: ```text POST https://gateway.example.com/oauth/token @@ -606,12 +637,12 @@ client_assertion= | Check | Response | Broker outcome and retry | | --- | --- | --- | | 1 | 401 `invalid_client` | Call fails; not retryable. | -| 2–5 | 400 `invalid_grant` (RFC 7523 §3.1) | Not retryable with the same ID-JAG. | +| 2–5 | 400 `invalid_grant` (RFC 7523 §3.1) with `invalid_assertion` | Not retryable with the same ID-JAG. | | 6 | 400 `invalid_target` | Call fails; a configuration fault. | -| 7 | 400 `invalid_grant` | The existing `mcp_authorization_pending` outcome. How an external gateway signals "consent required" in a structured way is [open](implementation.md#as-1-grant-and-as-2-user-resolution). | +| 7 | 400 `invalid_grant` with `consent_required` | The existing `mcp_authorization_pending` outcome. An external gateway that sends no reason gets the same treatment for every `invalid_grant`: a pending prompt is recoverable, and a bad ID-JAG is a broker bug that also shows in the logs. In embedded mode a missing user mapping is `invalid_assertion`, a plain failure. | | Lost response | None received | Request a new ID-JAG from step (7): the first one's `jti` may already be spent. | -**Why.** The broker never re-submits an ID-JAG. AS-2 enforcing single use is required in embedded mode and requested of external gateways, because ID-JAG §4.4.3 lets clients re-submit and a third-party gateway may allow it. Single use keeps one redemption per issuance in the audit trail. The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). +**Why.** AS-2 sends reason codes in `error_description`, as AS-1 does, so the broker can tell a missing consent link from a bad assertion. The broker never re-submits an ID-JAG. AS-2 enforcing single use is required in embedded mode and requested of external gateways, because ID-JAG §4.4.3 lets clients re-submit and a third-party gateway may allow it. Single use keeps one redemption per issuance in the audit trail. The consent link stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Its creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). **Does not prove.** Anything about the agent's ceiling: `V` carries no scope for it yet, so a gateway allow says nothing about the ceiling. @@ -645,6 +676,8 @@ client_assertion= | 3 | Tool error | Returned as a denial; not retried. | | 4 | "Needs authorization" | The existing `mcp_authorization_pending` outcome; after Alice connects GitHub, a later call passes every check again. | +**Why.** The vMCP is the only component that holds the upstream credential, so it makes the last check before the provider call and looks the credential up from the validated user alone; nothing in the request can point it at another user's credential. + **Does not prove.** Provider-side effects: neither a successful MCP response nor an authorization record proves them. **Evidence.** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, outcome, and the reported operation and attempt. @@ -653,7 +686,7 @@ client_assertion= ### Failure matrix -Where each failure is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. Configuration faults, such as `invalid_client`, `unauthorized_client`, and `invalid_target`, fail every affected call until an operator fixes them, and are not listed again. +The failures each step can return: where each is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. Configuration faults, such as `invalid_client`, `unauthorized_client`, and `invalid_target`, fail every affected call until an operator fixes them, and are not listed again. | Failure | Detected at | Response | User-visible outcome | Revokes | | --- | --- | --- | --- | --- | @@ -661,26 +694,30 @@ Where each failure is detected, what the user sees, and what it revokes. "Fails | Not the owner of `hostS42` | mecak8s | 403 `not_session_owner` | Request refused | Nothing | | Workload credential expired | Broker, agent-instance issuer | `UNAUTHENTICATED` | None: mecak8s rereads the rotated token | Nothing | | Workload not allowed | Broker, agent-instance issuer | `PERMISSION_DENIED` | Every request fails; an operator fault | Nothing | -| Owner mismatch at open | AS-1 | `invalid_request`, recorded | Session open fails | Nothing | -| Session owned by another user, or closed | AS-1 | `invalid_authorization_details`, recorded | Session open fails | Nothing | -| Access token already opened a session | AS-1 | `invalid_request` | Client refreshes and retries once | Nothing | -| Echoed entry differs | Broker | Open fails | Session open fails | The new `RT-A42` | -| Registration into a closed or foreign session | Agent-instance issuer | `PERMISSION_DENIED` | Agent cannot start | Nothing | +| Owner mismatch at open | AS-1 | `invalid_request` with `owner_mismatch`, recorded | Session open fails | Nothing | +| Session owned by another user, or closed | AS-1 | `invalid_authorization_details` with `session_owned`, recorded | Session open fails | Nothing | +| Access token already opened a session | AS-1 | `invalid_request` with `token_used` | Client refreshes and retries once | Nothing | +| Access token issued before a user-wide revocation | AS-1 | `invalid_request` with that revocation's reason | Alice signs in again | Nothing more | +| Echoed entry differs | Broker | Open fails | Session open fails | The new `RT-A42`, closing `A42` | +| Registration into a closed or foreign session | Agent-instance issuer | `FAILED_PRECONDITION`, `PERMISSION_DENIED` | Agent cannot start until a new session opens | Nothing | +| Re-registration under a different owner | Agent-instance issuer | `PERMISSION_DENIED` | Agent cannot start | Nothing | | Root registration of a non-root-eligible definition | Agent-instance issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | | Definition name or depth beyond the caps | Agent-instance issuer | `INVALID_ARGUMENT` | Child agent cannot start | Nothing | | SVID expired | Broker, AS-1 | `UNAUTHENTICATED`, `invalid_request` with `svid_expired` | None: renewed and retried once | Nothing | | SVID invalid or instance not registered to the session | Broker, AS-1 | `PERMISSION_DENIED`, `invalid_request` with `denied` | Call denied | Nothing | -| Run unknown, or delegation not reported | Broker | `PERMISSION_DENIED` | Call denied | Nothing | +| Run bound to another instance, a root run from a child instance, or delegation not reported | Broker | `PERMISSION_DENIED` | Call denied | Nothing | | Run owner differs from the grant's recorded owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | | No route, or ceiling denies the tool | Broker | `PERMISSION_DENIED` | Call denied | Nothing | | Inconsistent delegation report | Broker | `PERMISSION_DENIED`, `ALREADY_EXISTS` | Delegated task cannot call tools | Nothing | -| `RT-A42` revoked, expired, or session closed | AS-1 | `invalid_request` with `session_closed` | A new session opens on Alice's next request | Already revoked | -| Company IdP refuses the refresh | AS-1 | `invalid_request` with `upstream_revoked` | Every agent and schedule for Alice stops | All of Alice's grants | -| Company IdP grant needs Alice's action | AS-1 | `invalid_request` with `owner_action_required` | "Needs owner action": Alice signs in again | All of Alice's grants | -| Company IdP unreachable or 5xx | AS-1 | 503 | This call fails; later calls retry | Nothing | +| Session closed or past its hard expiry | Broker, AS-1 | `FAILED_PRECONDITION`, `invalid_request` with `session_closed` | A new session opens on Alice's next request | Already revoked | +| Grant revoked by sign out everywhere | Broker, AS-1 | `FAILED_PRECONDITION`, `invalid_request` with `signed_out` | Run stops; Alice is signed out | Already revoked | +| Schedule grant presented to an ID-JAG exchange, or a session refresh token used to open a scheduled run | AS-1 | `invalid_request` with `denied` | Call or run fails; a broker bug | Nothing | +| Company IdP refuses the refresh | AS-1 | `invalid_request` with `upstream_revoked` | Every agent and schedule for Alice stops; she is signed out | All of Alice's grants and client refresh tokens | +| Upstream refresh token past its recorded expiry, missing, or refused after an ambiguous refresh | AS-1 | `invalid_request` with `owner_action_required` | "Needs owner action": Alice signs in again | All of Alice's grants and client refresh tokens | +| Company IdP unreachable or 5xx, or the refresh lease timed out | AS-1 | 503 | This call fails as retryable; later calls retry | Nothing | | AS-1 refuses the combination | AS-1 | `invalid_request` with `denied` | Call denied | Nothing | -| No consent link (external) | AS-2 | `invalid_grant` | `mcp_authorization_pending` through "Allow Mecatl" | Nothing | -| ID-JAG replayed | AS-2 | `invalid_grant` | None if the broker re-mints; otherwise call fails | Nothing | +| No consent link (external) | AS-2 | `invalid_grant` with `consent_required` | `mcp_authorization_pending` through "Allow Mecatl" | Nothing | +| ID-JAG replayed | AS-2 | `invalid_grant` with `invalid_assertion` | Call fails; a broker bug or a stolen ID-JAG | Nothing | | `V` rejected by vMCP | vMCP | 401 `invalid_token` | None: one retry with a new `V` | Cached `V` dropped | | vMCP session rejected | vMCP | 404 | None: one retry with a new session | Stored session ID dropped | | vMCP policy denies the tool | vMCP | Tool error | Call denied | Nothing | @@ -689,15 +726,24 @@ Where each failure is detected, what the user sees, and what it revokes. "Fails ### Error reasons -RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 puts a reason code first in `error_description`, for example `error_description=session_closed`; the broker carries it in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome: +RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 and AS-2 therefore start `error_description` with a reason code, either alone or followed by a colon and text: `error_description=session_closed`, or `error_description=session_closed: session ended`. RFC 6749 §5.2 limits `error_description` to printable ASCII, and reason codes stay within it. The broker carries the reason in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome. -| Reason | AS-1 response | Broker gRPC status | mecak8s outcome | +| Reason | Returned by | Broker gRPC status | mecak8s outcome | | --- | --- | --- | --- | -| `session_closed` | 400 `invalid_request` | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. | -| `upstream_revoked` | 400 `invalid_request` | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. | -| `owner_action_required` | 400 `invalid_request` | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. | -| `svid_expired` | 400 `invalid_request` | `UNAUTHENTICATED` | Renew the SVID and retry once; not shown to the user. | -| `denied` | 400 `invalid_request` | `PERMISSION_DENIED` | Deny the call. | +| `subject_invalid` | AS-1, session open | `UNAUTHENTICATED` | Ask the client to refresh its access token and retry once. | +| `token_used` | AS-1, session open | `UNAUTHENTICATED` | The same. | +| `owner_mismatch` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug. | +| `session_owned` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug or a closed session ID reused. | +| `session_closed` | AS-1, broker | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. A scheduled run ends. | +| `signed_out` | AS-1, broker | `FAILED_PRECONDITION` | Stop the run; the client shows the user as signed out. | +| `upstream_revoked` | AS-1, broker | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. | +| `owner_action_required` | AS-1, broker | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. A scheduled run ends with "needs owner action" and the owner is notified. | +| `svid_expired` | AS-1, broker | `UNAUTHENTICATED` | Renew the SVID and retry once; not shown to the user. | +| `denied` | AS-1, broker | `PERMISSION_DENIED` | Deny the call. | +| `consent_required` | AS-2 | `FAILED_PRECONDITION` | The existing `mcp_authorization_pending` outcome. | +| `invalid_assertion` | AS-2 | `INTERNAL` | Fail the call; a broker bug. | +| HTTP 503 | AS-1, AS-2 | `UNAVAILABLE` | Fail the call as a retryable infrastructure error, not a denial. | +| `invalid_client`, `unauthorized_client`, `invalid_target`, or no reason | AS-1, AS-2 | `INTERNAL` | Fail the call; a configuration fault or broker bug. An external AS-2 that sends no reason on `invalid_grant` is treated as `consent_required`. | ### Renewal triggers @@ -718,10 +764,12 @@ RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that None of these values is selected. Each bounds a risk the operator should know: -- **Lifetimes:** `RT-A42`'s hard expiry, a schedule's authority horizon, the ID-JAG lifetime, and the maximum SVID, workload-credential, and client-assertion lifetimes. +- **Lifetimes:** `RT-A42`'s hard expiry, a schedule grant's horizon, the ID-JAG lifetime, and the maximum SVID, workload-credential, and client-assertion lifetimes. - **Clock skew**, and the margins derived from it: the `V` skew margin and how long used `jti` values are kept past `exp`. - **Upstream re-check:** the re-check interval, the maximum upstream refresh-token lifetime per IdP when the IdP sends no `refresh_expires_in`, and the lease timeout for a refresh in flight. - **Key refresh:** the rate limit on refreshing a key set after an unknown `kid`. +- **Closed sessions:** how long closed-session records are kept. Session IDs must be unguessable and never reused, so a record kept for less than forever relies on the ID space alone. +- **IdPs without refresh tokens:** a per-IdP setting that skips the upstream re-check. It is off by default; with it on, a disabled account keeps acting until each refresh token's hard expiry. - **IdP outage:** with no tolerance, every exchange fails closed once the re-check interval has passed while the company IdP is down. A grace period would keep agents working through a short outage, at the cost of a disabled account acting for that much longer. ## User identity @@ -742,10 +790,11 @@ The following custody boundaries do not imply a selected persistence implementat | --- | --- | --- | | AS-1 access token | Client presents it to mecak8s; mecak8s passes it to the broker once at session open. | Not stored by the server side. | | Client refresh token | TUI keyring or Studio sealed cookie. | Never leaves the client. | -| Workload credential | Platform-managed; mecak8s presents it to the issuer and broker. | Projected credential or configured SPIRE identity. Authenticates the workload, not a user or agent. | +| Workload credential | Platform-managed; mecak8s presents it to the agent-instance issuer and broker. | Projected credential or configured SPIRE identity. Authenticates the workload, not a user or agent. | | Agent-instance JWT-SVID | Harness holds `mainSVID` / `reviewerSVID` outside model context; broker forwards it to AS-1 as `actor_token`. | Agent-instance issuer keeps its signing key. Renewal requires the same registered identity and does not widen authority. | | Broker session `A42` (state) | Broker owns the workload and host-session binding. | Recovered under workload and continuity checks. Possession of the reference grants nothing. | -| Broker refresh token `RT-A42` | Broker, in shared session storage with the grant's recorded owner; presented only to AS-1. | Bound to `mecatl`, `A42`, and Alice; does not rotate; hard expiry; revoked when the session closes. | +| Broker refresh token `RT-A42` | Broker, in shared session storage with the grant's recorded owner and expiry; presented only to AS-1. | Bound to `mecatl`, `A42`, and Alice; does not rotate; hard expiry; revoked when the session closes. | +| Schedule grant | Broker, in shared storage under the schedule; presented only to AS-1, to open each run's session. | Bound to `mecatl`, the schedule, and Alice; horizon; revoked when the schedule is deleted. | | Broker private key | Broker; signs client assertions for AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | | ID-JAG | Broker, from issuance to redemption. | Single-use, short-lived. | | AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, gateway, and user; per-call checks still apply. No refresh token. | @@ -761,18 +810,18 @@ An agent instance keeps its identity across runs. These lifetimes are independen | Item | Expiry or loss of authority | Replacement | | --- | --- | --- | | AS-1 access token | AS-1 policy; short. Expiry ends API authentication, not the broker session. | The client refreshes with its own refresh token. | -| Client refresh token | AS-1 policy and upstream sign-in status. | Sign in again. | +| Client refresh token | AS-1 policy, sign out everywhere, and an upstream revocation or owner-action outcome. | Sign in again. | | Workload credential | Expired credentials are rejected. | Platform rotation for the same workload. | | `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the `ag_` identifier and the ceiling do not change. | | `RT-A42` | Hard expiry, session close, sign out everywhere, or the upstream re-check. It does not rotate. Losing it closes `A42`. | A new broker session for `hostS42`, opened with a fresh AS-1 access token. | | ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | | `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | | Broker private key | Operator rotation. | New key published through `jwks_uri`. | -| Schedule authority | Its horizon, schedule deletion, sign out everywhere, or the upstream re-check. | The owner renews it. | +| Schedule grant | Its horizon, schedule deletion, sign out everywhere, or the upstream re-check. | The owner renews it: before the horizon, the grant's horizon moves; after a revocation, setup mints a new grant. | | Consent link | Revocation at AS-2. | The user links again. | | Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | -The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The values of `RT-A42`'s hard expiry and of a schedule's authority horizon are not selected. They bound how long Mecatl acts without a fresh sign-in, and the company IdP administrator should know them. +The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The values of `RT-A42`'s hard expiry and of a schedule grant's horizon are not selected. They bound how long Mecatl acts without a fresh sign-in, and the company IdP administrator should know them. Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. AS-1 keeps a record of the closed session so it is never reopened. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. @@ -783,15 +832,22 @@ Signing out of one client and stopping Mecatl's agents are different actions. | User action or event | What ends | What remains | | --- | --- | --- | | **Sign out** in the TUI or Studio | That client's own AS-1 tokens, revoked at AS-1. | Running agents and schedules. Their authority is held by the broker, not by the client. | -| **Sign out everywhere** | Every agent acting for Alice: live runs stop, tool calls parked for authorization are cancelled, broker sessions and their refresh tokens are revoked, and the authority of each of her schedules is revoked. | Transcripts. Each schedule stays configured; its next run ends with "needs owner action" until Alice renews it. | +| **Sign out everywhere** | Every agent acting for Alice: live runs stop, tool calls parked for authorization are cancelled, broker sessions and their refresh tokens are revoked, each of her schedule grants is revoked, and every client's AS-1 tokens are revoked. | Transcripts. Each schedule stays configured; its next run ends with "needs owner action" until Alice renews it. | | **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval: AS-1 triggers the broker's sign-out-everywhere path. | Transcripts. | +| **Alice's upstream refresh token expires or needs her action** | The same, with "needs owner action" as the reason. | Transcripts. | | **Closing a session** | That session's refresh token. | Other sessions and schedules. | -**Sign out everywhere.** The client calls Mecatl before it clears its own tokens. For each of Alice's sessions, mecak8s cancels the live run and any tool call parked for authorization. The broker revokes Alice's grants in those sessions at AS-1, cancels their in-flight tool calls, and drops her cached ID-JAGs, `V`, and vMCP session IDs. Today every session has one owner, so this ends each of her broker sessions. Revocation is by user, so a later [shared session](run-owner.md) would keep running for its other members. The broker also revokes the authority of each of Alice's schedules. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a client-driven revoke per session, a durable revocation marker checked when a session lease is renewed, or shared broker state does this is an implementation choice. +**Sign out everywhere.** The steps, whether the client starts them or AS-1 does: + +1. AS-1 records Alice's revocation time and reason, revokes every grant it holds for her with that reason, and revokes every client's AS-1 refresh tokens, all in one transaction. From then on, session open and schedule setup refuse any access token issued before that time; a new sign-in issues tokens after it. +2. The broker cancels the in-flight tool calls of Alice's sessions and drops her cached ID-JAGs, `V`, and vMCP session IDs. +3. mecak8s cancels each session's live run and any tool call parked for authorization. When the client starts sign-out, it calls Mecatl before clearing its own tokens. When AS-1 starts it, the broker tells mecak8s: in process while the broker is a modulith, through a revocation event once it is a separate service. + +Today every session has one owner, so this ends each of her broker sessions. Revocation is by user, so a later [shared session](run-owner.md) would keep running for its other members. Runs and broker state can live on different replicas, so the revocation must reach every replica that serves one of Alice's sessions. Whether a durable revocation marker checked when a session lease is renewed, or shared broker state, does this is an implementation choice. ### Upstream re-check -AS-1 keeps one company-IdP refresh token per user, from her most recent sign-in, and re-checks it on behalf of every grant it holds for her; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. Every ID-JAG exchange compares the current time with that recorded expiry, which needs no call to the IdP. +AS-1 keeps one upstream refresh token per user, from her most recent sign-in at the company IdP, and re-checks it on behalf of every grant it holds for her; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. Every ID-JAG exchange compares the current time with that recorded expiry, which needs no call to the IdP. Reaching it ends Alice's authority with reason `owner_action_required`, through the sign-out-everywhere path above. If the IdP issued no refresh token at sign-in, every exchange ends the same way, unless the operator has turned off the re-check for that IdP ([configuration](#configuration)). When the broker exchanges a refresh token for an ID-JAG and the re-check interval has passed, AS-1 refreshes the upstream token first: @@ -804,12 +860,12 @@ The IdP's answer decides what happens: | IdP answer | AS-1 action | Outcome | | --- | --- | --- | | Success | Stores the new token, records the time | The exchange continues. | -| `invalid_grant` before the recorded expiry, after a clean previous refresh | Revokes every grant it holds for Alice, with reason `upstream_revoked`, and triggers the broker's sign-out-everywhere path | The account was disabled or its grants revoked: the same as sign out everywhere. | +| `invalid_grant` before the recorded expiry, after a clean previous refresh | Runs the sign-out-everywhere path with reason `upstream_revoked`, and discards the upstream refresh token | The account was disabled or its grants revoked: the same as sign out everywhere. | | `invalid_grant` with no recorded expiry | The same | Treated as revocation, because AS-1 cannot tell the two apart. | -| `invalid_grant` at or after the recorded expiry, or after an ambiguous previous refresh | Revokes every grant it holds for Alice, with reason `owner_action_required` | "Needs owner action": Alice signs in again. Not reported as a disabled account. | +| `invalid_grant` at or after the recorded expiry, or after an ambiguous previous refresh | Runs the sign-out-everywhere path with reason `owner_action_required`, and discards the upstream refresh token | "Needs owner action": Alice signs in again. Not reported as a disabled account. | | Network error or 5xx | Nothing | That exchange fails closed with a 503; the next one retries. | -A revoked grant stays revoked whatever its reason: signing in again starts a new session or renews a schedule, and never revives the old grant. The reason only decides what Alice is told. +A revoked grant stays revoked whatever its reason: signing in again starts a new session or sets up the schedule's grant again, and never revives the old grant. The reason only decides what Alice is told. Until she signs in again, AS-1 holds no upstream refresh token for her, so any exchange fails closed. This works with any IdP that issues refresh tokens and needs no IdP-specific events. It catches account disable and administrator revocation within about one re-check interval; it does not catch a plain sign-out at the company IdP. The interval is not selected; it bounds how long a disabled account can keep acting. @@ -835,19 +891,55 @@ An operation that may have been dispatched but has no recorded completion has an A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own grant from Alice: +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own grant from Alice, with an explicit horizon. A scheduled run's owner is checked against the owner recorded with the schedule grant. Deleting the schedule, signing out everywhere, and the upstream re-check revoke it. + +### Schedule setup + +**Input.** When Alice sets up the schedule, her client refreshes its access token, and the broker exchanges it at AS-1 as at [session open](#3-session-open-and-the-broker-refresh-token), with a `mecatl_schedule` entry instead of a `mecatl_session` one: + +```text +authorization_details=[{"type": "mecatl_schedule", "schedule": "sch-nightly-7", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] +``` + +**Checks, at AS-1:** session-open checks 1 to 5 and 7, with the `mecatl_schedule` entry in place of the `mecatl_session` one. Instead of check 6, the schedule has no live grant yet, or its grant belongs to the same owner and is replaced. + +**Output.** A schedule grant bound to `mecatl`, the schedule, and Alice, with the horizon. The broker stores it under the schedule. Presented at the refresh-token grant, it returns `invalid_grant`, as `RT-A42` does. + +### Scheduled-run session open + +**Input.** When the schedule fires, mecak8s calls `OpenSession` with the schedule ID in place of an access token, and the broker allocates a session ID for the run. The broker exchanges the stored schedule grant: + +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:refresh_token +requested_token_type=urn:ietf:params:oauth:token-type:refresh_token +authorization_details=[{"type": "mecatl_session", "session": "A57", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` + +**Checks, at AS-1:** + +1. Client authentication as `mecatl`. +2. The request is well formed: the token types above and exactly one `mecatl_session` entry. +3. The subject token resolves to a schedule grant bound to `mecatl`, not revoked, and before its horizon. A session refresh token is refused here. +4. Alice's upstream refresh token passes the [upstream re-check](#upstream-re-check), as in step (7) check 5. +5. The entry's owner equals the schedule grant's recorded owner. +6. Session `A57` has never been opened, or this is a retry: the same schedule grant, client, and session. + +**Output.** A session refresh token for `A57`, with the same response shape as session open, whose expiry is no later than the schedule's horizon. AS-1 records which schedule grant it came from, so revoking the schedule grant revokes it. From here the run is an ordinary session: instances register to `A57`, and steps (6) to (9) apply unchanged, including the `V` cache key. When the run ends, mecak8s closes `A57`, which revokes its refresh token. -- **Setup.** Mecatl obtains it when she sets up the schedule, with the same exchange as at [session open](#3-session-open-and-the-broker-refresh-token): her client refreshes its access token first, and the `authorization_details` entry has type `mecatl_schedule` and names the schedule and its owner. The result is a schedule grant with an explicit horizon. -- **Each run.** When the schedule fires, mecak8s opens a broker session for the run. The broker exchanges the schedule grant at AS-1, as the subject token, for a session refresh token: the same exchange as session open, with a `mecatl_session` entry naming the new session and the schedule's owner. The session's refresh token expires no later than the schedule's horizon, and revoking the schedule grant revokes it. From there the run is an ordinary session: instances register to it, and steps (6) to (9) apply unchanged, including the `V` cache key. -- **Separation.** A schedule grant is accepted only to open a run's session; presented to an ID-JAG exchange, it is refused. A session's refresh token is never accepted to open a scheduled run. -- **Ownership.** A scheduled run's owner is checked against the owner recorded with the schedule grant. -- **End.** Deleting the schedule, signing out everywhere, and the upstream re-check revoke the schedule grant. +**Failures.** As for session open; a revoked schedule grant returns its revocation reason, and the run ends with "needs owner action" and a notification to Alice. A lost response is retried with the same parameters and a new client assertion. When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. At run time nothing is interactive. Suppose something has lapsed since setup: a revoked link, a revoked provider credential, or authority past its horizon. Then the call fails closed, the run ends with a "needs owner action" outcome, and Alice is notified. The notification links into Mecatl, which starts a fresh fix flow. It does not carry a connect URL, because those can be single-use and short-lived. A provider token that merely expired but can be refreshed is not a lapse: the gateway refreshes it. -Pre-authorization is renewable without recreating the schedule. Renewing extends the authority horizon and reconnects anything that lapsed. Alice is notified before the horizon is reached. +Pre-authorization is renewable without recreating the schedule. Renewing a live schedule grant moves its horizon; after a revocation, Alice sets up a new grant. Either way, renewal reconnects anything that lapsed. Alice is notified before the horizon is reached. A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. @@ -859,7 +951,7 @@ Workload authentication identifies the Mecatl service contacting the broker. Age | --- | --- | | Kubernetes ServiceAccount bearer over TLS | None: workload-only identification | | Kubernetes ServiceAccount bearer over TLS | Broker-issued instance JWT-SVID | -| SPIRE workload credential-SVID over TLS | Broker-issued instance JWT-SVID | +| SPIRE workload JWT-SVID over TLS | Broker-issued instance JWT-SVID | | SPIRE workload X.509-SVID over mTLS | Broker-issued instance JWT-SVID | These are alternatives, not a required implementation sequence. Broker-issued agent-instance JWT-SVIDs do not require SPIRE. @@ -891,7 +983,7 @@ The model cannot select another user's credentials or redirect them by supplying ## Instance identity and creation ancestry -The instance JWT-SVID identifies the acting agent instance through its SPIFFE ID. The proposed identity path records the instance's definition category and name, its agent-instance incarnation, and the ancestry of instances that created it. +The agent-instance SVID identifies the acting agent instance through its SPIFFE ID. The proposed identity path records the instance's definition category and name, its agent-instance incarnation, and the ancestry of instances that created it. Creation ancestry is distinct from execution delegation. The same instance can participate in multiple runs, so its SPIFFE ID alone does not identify which parent run delegated the current task. Run and call records supply that context. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index f8747358de..a7a51b49ef 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -32,7 +32,8 @@ The flow has four integration points: AS-1 sign-in, the session-open exchange, t - AS-1 issued it, its `aud` is the Mecatl API, it is still active, and its `jti` has not opened another session; - the presenter is the authenticated client `mecatl`; -- the request asks for no wider scope than the original grant; +- the request carries no `scope` parameter (`invalid_scope` otherwise); +- the token was issued after the user's last user-wide revocation, if any; - the company identity in the token's `sub_id` equals the session owner that mecak8s sends with it; - the broker session has no recorded owner yet, or its recorded owner is that same identity. AS-1 records the owner at the first open, and session IDs are never reused. @@ -62,16 +63,19 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Host session and incarnation | `hostS42` | Restart and recovery continuity. | | Source access-token `jti` | | Refuses a second session opened with the same token. | | Issue time and hard expiry | | Authority horizon. | +| Source schedule grant | | For a scheduled run's session only: revoking the schedule grant revokes this refresh token. | +| Revocation reason | | Set when revoked: `session_closed`, `signed_out`, `upstream_revoked`, or `owner_action_required`. | The broker supplies the session reference. The `mecatl_session` type requires AS-1 to echo the entry unchanged (RFC 9396 §7); the broker checks it is identical, revokes the refresh token and fails the open if not, and otherwise stores the refresh token with the echoed owner in its shared session storage. AS-1 refuses an open while the session already has a live refresh token. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record, the owner, and the refresh-token record in one transaction. The broker runs at most one open per session at a time, and a retry carries a new client assertion. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and keeps a closed-session record (ID, owner, closing time) so the session is never reopened; the converted `jti` is kept until the source token's `exp` plus the clock skew. Losing the refresh token for any reason closes the session. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: -1. resolves the refresh token to its record and checks that it is a `mecatl_session` record bound to `mecatl`, that the session is open, and that the hard expiry has not passed; -1. if the re-check interval has passed, refreshes the user's company-IdP grant, as [upstream re-check](credentials-and-lifecycle.md#upstream-re-check) describes; -2. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; -3. authorizes the combination of client, subject, actor, and target AS-2; -4. issues the ID-JAG. +1. checks that `audience` is an allowed AS-2 for `mecatl` and `resource` a vMCP configured for it; +2. resolves the refresh token to its record and checks that it is a `mecatl_session` record bound to `mecatl`, that the session is open, and that the hard expiry has not passed, returning the record's revocation reason otherwise; +3. checks the user's upstream refresh token against its recorded expiry and, if the re-check interval has passed, refreshes it, as [upstream re-check](credentials-and-lifecycle.md#upstream-re-check) describes; +4. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; +5. authorizes the combination of client, subject, actor, and target AS-2; +6. issues the ID-JAG. The request, for the reviewer instance in the main flow: @@ -84,12 +88,14 @@ actor_token=reviewerSVID actor_token_type=urn:ietf:params:oauth:token-type:jwt requested_token_type=urn:ietf:params:oauth:token-type:id-jag audience=https://gateway.example.com +resource=https://gateway.example.com/mcp +mecatl_operation=op-pr42-001 client_id=mecatl + client assertion ``` -The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance identifier for the acting instance, such as `ag_7hq2x9`: a plain string with no colon, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. The broker creates it at registration, as a random value stored under the SPIFFE ID with a uniqueness constraint; AS-1 looks it up from the validated `actor_token`, and the full SVID stops at AS-1. The ID-JAG also carries the `resource` the broker requested. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. +Refusals carry a reason code in `error_description`, listed under [error reasons](credentials-and-lifecycle.md#error-reasons). The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance identifier for the acting instance, such as `ag_7hq2x9`: a plain string with no colon, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. The agent-instance issuer creates it at registration only if the SPIFFE ID has none yet, unique in both directions and never reassigned; AS-1 looks it up from the validated `actor_token`, and the full SVID stops at AS-1. The ID-JAG also carries the `resource` the broker requested. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. -**AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, and that `client_id` matches the authenticated client (§4.4.1), and rejects a reused `jti` (RFC 7523 §3). A lost redemption response is recovered with a new ID-JAG. It then maps the AS-1 subject to a native user: +**AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, and that `client_id` matches the authenticated client (§4.4.1), rejects a reused `jti` (RFC 7523 §3), and checks that the requested `resource` is the ID-JAG's `resource` claim. Refusals carry `consent_required` or `invalid_assertion` in `error_description`. A lost redemption response is recovered with a new ID-JAG. It then maps the AS-1 subject to a native user: | Mode | Mapping | | --- | --- | @@ -102,7 +108,7 @@ AS-2 issues `V`: `typ=at+jwt`, `sub` = native user, the ID-JAG's `act` unchanged **vMCP and the upstream credential.** vMCP validates `V` under RFC 9068 and, if the request uses a `resource` parameter, RFC 8707. It binds its session to the user and `act.sub`. It loads the upstream credential keyed by (gateway, native user, provider). A missing credential answers "needs authorization", which the broker returns as the existing `mcp_authorization_pending` outcome. Consent is checked at redemption, not here. There is one credential record per (user, provider), refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. -The broker must propagate the operation ID, such as `op-pr42-001`, with each MCP request, and vMCP must log it with its decision as reported, unverified trace context. A cached `V` serves several calls, so its `(iss, jti)` cannot identify one operation; with a single-level `act`, the operation ID is also what leads from a gateway record to the delegation in Mecatl's records. The broker sends them in each MCP request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`, and as the request parameters `mecatl_operation` and `mecatl_attempt` at AS-1. The persistence ordering of these correlation records is open. +The broker must propagate the operation ID, such as `op-pr42-001`, with each MCP request, and vMCP must log it with its decision as reported, unverified trace context. A cached `V` serves several calls, so its `(iss, jti)` cannot identify one operation; with a single-level `act`, the operation ID is also what leads from a gateway record to the delegation in Mecatl's records. The broker sends them in each MCP request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`, and the operation ID as the request parameter `mecatl_operation` at AS-1. The persistence ordering of these correlation records is open. **Open.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome. No callback or private mapping-response fields are specified. Candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. @@ -155,12 +161,15 @@ Test the `V` cache: - Startup fails when the skew margin is not shorter than the `V` lifetime. - The `ag_` identifier in `act` is unchanged after the instance's SVID is renewed. -Test schedule authority: +Test schedule grants: - Setting up a schedule converts a fresh access token with a `mecatl_schedule` entry into a schedule grant. - When the schedule fires, the broker exchanges the schedule grant for a session refresh token whose expiry is no later than the horizon; the run's calls then pass steps (6) to (9). Revoking the schedule grant revokes that session's refresh token. - A schedule grant is refused as the subject token of an ID-JAG exchange, and a session's refresh token is refused for opening a scheduled run. -- A scheduled run whose owner differs from the owner recorded with the schedule's authority is refused. +- A scheduled run whose owner differs from the owner recorded with the schedule grant is refused. +- A scheduled-run open retried with the same schedule grant, client, and session succeeds; a schedule grant past its horizon or revoked is refused with its reason. +- A run's session closes, revoking its refresh token, when the run ends. +- A schedule grant presented at the refresh-token grant returns `invalid_grant`. Test vMCP and the upstream credential: @@ -197,7 +206,7 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | SPIFFE client authentication at the authorization server | Yes | None; verify only | Identity work | | AS-1 and AS-2 audit records following the [AuditEvent profile](audit.md#auditevent-profile), including sign-in, access-token issuance, and refresh-token revocation at AS-1 | No; the authorization server records no audit events. The toolhive-core audit logger and its sink already exist | Not sized | Identity work | | Revoke a broker refresh token and its record, by an RFC 7009 endpoint or, while the broker runs inside mecak8s, in process | No; storage revokes by refresh-token family, but no endpoint is wired | Small | Identity work | -| Revoke all of a user's grants, including schedule authority | No; no index from user to refresh-token families | Medium | Identity work | +| Revoke all of a user's grants, including schedule grants, and her client refresh tokens, recording a revocation time and reason | No; no index from user to grants | Medium | Identity work | | Upstream re-check when issuing an ID-JAG, with the upstream token kept independent of client sign-out, its expiry recorded, and refreshes single-flight per user | No; the refresh grant never contacts the upstream IdP (checked at main `9adad0abb`) | Small to medium | Identity work | | AS-1 and the vMCP record the operation and attempt IDs the broker passes with each exchange and MCP request, marked as reported | No | Small | Identity work | | Session-open owner check: compare the session owner the broker passes with the token's `sub_id`, and keep the owner on the refresh-token record | No | Small | Identity work | @@ -327,6 +336,17 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - `ReportDelegation` is refused for an unknown delegating run, an instance not registered to the session, or a delegating run that is not its parent delegation's receiving run. A repeat with the same content succeeds; a repeat with different content returns `ALREADY_EXISTS`. - A call naming a delegation that was not reported is denied. - Registration into a closed session, or a session bound to another workload, is refused. +- A call whose SVID instance differs from the run record's instance is denied; a new run naming no delegation from a child instance is denied; two replicas recording the same new run keep one record. +- `ReportDelegation` for a delegating root run with no record yet records it and succeeds. +- Re-registering an instance into a new session for the same host session keeps its incarnation and `ag_` identifier; re-registering it under a different owner is refused; a repeated registration with the same content returns the same reference, with different content `ALREADY_EXISTS`. + +Test the checks at each hop: + +- mecak8s rejects each failing step (1) check, returning 401 `invalid_token` for checks 1–7 and 403 `not_session_owner` for check 8. +- The broker rejects a legacy ServiceAccount token, a token with the Kubernetes API server's audience, a token whose `exp - iat` exceeds the maximum, and a ServiceAccount not on the allowlist. +- SVID validation rejects a platform SVID with the broker's audience (wrong trust domain and bundle), a header without `kid`, an `aud` with two values, a disallowed `alg`, and an `exp` beyond the maximum lifetime. +- Registration rejects names over 63 bytes, nesting past 8 child levels, `.` and `..`, and a parentless registration of a definition that is not root-eligible. +- An AS-1 503 maps to `UNAVAILABLE` and is reported as a retryable failure, not a denial; `invalid_client` and a bare `invalid_request` map to `INTERNAL`; each reason in [error reasons](credentials-and-lifecycle.md#error-reasons) maps to its gRPC status and mecak8s outcome. - Two users' calls through the same instance never share a cached `V`, ID-JAG, or vMCP session ID. - Signing out everywhere revokes only that user's grants and cache entries. @@ -335,11 +355,13 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation): - Signing out of one client revokes only that client's tokens; running agents and schedules continue. -- Signing out everywhere cancels live runs, parked tool calls, and in-flight tool calls; deletes the broker sessions; revokes their refresh tokens at AS-1; drops cached ID-JAGs and `V`; and revokes every schedule's authority. Transcripts remain. +- Signing out everywhere cancels live runs, parked tool calls, and in-flight tool calls; closes the broker sessions, keeping closed-session records; revokes their refresh tokens and every client's AS-1 refresh tokens at AS-1; drops cached ID-JAGs and `V`; and revokes every schedule grant. Transcripts remain. +- After it, session open and schedule setup refuse an access token issued before the revocation time, including one opened concurrently with the revocation. - It reaches sessions served by another replica. - After it, an ID-JAG exchange with any revoked refresh token fails, and each schedule's next run ends with "needs owner action". -- When the upstream IdP returns `invalid_grant` before the upstream token's recorded expiry, or when no expiry is recorded, AS-1 revokes all the user's grants, including schedule authority, and the exchange fails. -- When it returns `invalid_grant` at or after the recorded expiry, AS-1 ends the user's grants with a "needs owner action" outcome, not a revocation. +- When the upstream IdP returns `invalid_grant` before the upstream token's recorded expiry, or when no expiry is recorded, AS-1 runs the sign-out-everywhere path with reason `upstream_revoked`, revoking all the user's grants and client refresh tokens, and the exchange fails. +- When it returns `invalid_grant` at or after the recorded expiry, or the recorded expiry passes, or the IdP issued no refresh token and the re-check is not turned off for it, AS-1 runs the same path with reason `owner_action_required`. +- A later exchange for a revoked session returns the stored reason, not `session_closed`; a grant is never revived by a new sign-in. - A network error or 5xx fails that exchange and revokes nothing. - Concurrent exchanges for one user, across replicas, cause one upstream refresh. When the refresh lease times out, waiting exchanges fail with 503 and revoke nothing. - After a lost IdP response or a crash during a refresh, a rotating IdP's `invalid_grant` on the next refresh revokes with reason `owner_action_required`, not `upstream_revoked`. diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 279c5b733a..28257ac9b2 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -37,7 +37,7 @@ The broker authenticates at both servers as one OAuth client, `mecatl`, with `pr AS-2 receives neither Alice's AS-1 access token, `RT-A42`, nor the SVID. It receives only the ID-JAG. -AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different trust domains, in the OAuth sense of a security and administrative boundary (§2.2), which this design calls administrative domains to keep "trust domain" for SPIFFE; and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one administrative domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. +AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different trust domains, in the OAuth sense, which this design calls [administrative domains](credentials-and-lifecycle.md#7-id-jag-from-as-1); and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one administrative domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. ## Following one call diff --git a/docs/drafts/agent-identity/threat-model.md b/docs/drafts/agent-identity/threat-model.md index 60bd7533e1..8bd289f92e 100644 --- a/docs/drafts/agent-identity/threat-model.md +++ b/docs/drafts/agent-identity/threat-model.md @@ -22,7 +22,7 @@ Where the broker runs matters against one attacker: someone with code execution | Threat from a compromised mecak8s | Broker inside mecak8s | Separate broker service | | --- | --- | --- | -| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes, when its refresh token's hard expiry passes, when the user signs out everywhere, or when AS-1's upstream re-check revokes the user's grants. mecak8s can also open new sessions with fresh tokens from users who are using it now. | +| Act as any user, by signing an ID-JAG for a user who is not present | Attack works. The process holds AS-1's signing key and the broker's client key. In embedded mode, the attacker can reach every user connected to the gateway. In external mode, the attacker can reach only users who have a consent link. | Attack limited. mecak8s holds no keys. It can act only in broker sessions that are open and bound to its workload. A session ends when it closes, when its refresh token's hard expiry passes, when the user signs out everywhere, or when AS-1's upstream re-check revokes the user's grants. mecak8s can also open new sessions with fresh tokens from users who are using it now, and, through their schedule grants, sessions for any user with a live schedule, until each schedule's horizon. | | Steal refresh tokens or signing keys | Attack works. A KMS stops the attacker from copying a key out. It does not stop the attacker from signing with the key while the attacker is inside the process. | Attack blocked, if the broker has its own database credentials and only the broker's service account can sign with its KMS keys. | | Read users' provider tokens in embedded mode | Attack works, if the gateway store shares storage credentials with mecak8s. | Attack blocked, if the embedded gateway is its own deployment with its own credentials. | | Tamper with broker and AS-1 audit records | Attack partly blocked. Records that are already in an external append-only store survive. From then on, the compromised process can forge or suppress its own records. | Attack blocked for broker and AS-1 records, because mecak8s does not write them. This holds if the records go to an append-only store that mecak8s cannot write. | From 17ae00f0614eca2f4dd666c41d70f694c8f2cf3d Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 13:03:36 +0200 Subject: [PATCH 47/50] docs: resolve the last contradictions in the credential flow Fix what the final review round found: - A closed session returns its recorded reason with that reason's status everywhere; a replaced refresh token returns superseded; a lease waiter re-reads its grant before answering. - Re-registering a SPIFFE ID must match its earlier host session, owner, definition revision, and parent; the undefined "deregistered" check is dropped. - Delegation records are kept per session and re-reported parents first; an unrecorded delegating run becomes a root run only for a root instance. - Schedule setup keeps the schedule's owner after revocation, renewal moves a live grant's horizon, the scheduled-run open refuses a closed session and has its own failure table, deleting a schedule has its own reason, and scheduled runs map every revocation to "needs owner action". - Sign out everywhere also ends AS-1 login sessions, the revocation time compares iat strictly, and issued access tokens are stated to keep working for the API until they expire. - The error_description character set, the README key-sharing and instance sentences, the registration list in agent-definitions, and stale tests in implementation.md. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 6 +- .../agent-identity/agent-definitions.md | 5 +- .../credentials-and-lifecycle.md | 80 +++++++++++-------- docs/drafts/agent-identity/implementation.md | 14 ++-- 4 files changed, 62 insertions(+), 43 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 25ba6ba8dc..3011b624c6 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -27,7 +27,7 @@ The architecture must connect these facts. An auditor should be able to follow A A **definition** describes a kind of agent, such as `code-reviewer`. Its source is part of that identity: a repository's definition and an administrator's definition with the same name are not interchangeable. A definition's **ceiling** is the set of MCP server/tool pairs that an agent built from it may call. Changing an execution setting such as a turn limit does not change a definition's identity, and loading a definition grants no access to a user's accounts. -An **instance** is one use of a definition in a session. Alice and Bob can use the same definition but have distinct instances. Restoring a session preserves its instances; a new session creates new ones. A session's **root** is the definition its main agent uses. +An **instance** is one use of a definition in a session. Alice and Bob can use the same definition but have distinct instances. Restoring a session preserves its instances; a replacement broker session re-registers the same instances; a new host session creates new ones. A session's **root** is the definition its main agent uses. A **run** is one execution by an instance. Reviewing PR 42 and later PR 57 uses the same reviewer instance in two runs. A delegated child has its own instance and run, linked to the instance that created it and to the run that delegated to it. @@ -42,7 +42,7 @@ mecak8s hosts the API and the harness. It validates the AS-1 access token of the The **identity broker** establishes who is acting. It is the existing MCP broker, extended, and one trusted component. Once the authority work is in place, it also bounds what each agent can reach. Its modules are: - The **definition loader** loads agent definitions from configured sources, records each one's source and revision, and gives the harness the execution configuration. A session's selected definitions stay fixed, so a later change at the source cannot silently replace them. The operator says which definitions may be a session's root, and the broker refuses to register a root instance outside that set. See [agent definitions](agent-definitions.md#loading-definitions). -- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in its own SPIFFE trust domain, separate from the platform's, with a signing key separate from AS-1's and AS-2's; while the broker runs inside mecak8s, all three keys share one process. It signs on mecak8s's workload-authenticated registration, which is an assertion, not attestation: no evidence about the agent itself is produced or checked. +- The **agent-instance issuer** signs a SPIFFE JWT-SVID for each instance that mecak8s registers, in its own SPIFFE trust domain, separate from the platform's, with a signing key separate from AS-1's and AS-2's; while the broker runs inside mecak8s, its key and AS-1's share one process, and in embedded mode AS-2's does too. It signs on mecak8s's workload-authenticated registration, which is an assertion, not attestation: no evidence about the agent itself is produced or checked. - **AS-1**, Mecatl's login authorization server, signs users in through the company identity provider (IdP) and issues ID-JAGs (Identity Assertion JWT Authorization Grants). An ID-JAG is a short-lived, single-use signed grant that names the user and the acting agent. It is addressed only to AS-2, the gateway's authorization server, which redeems it for an access token, and only when the broker's client presents it. - The **authorization and audit** logic checks each protected MCP call and records what it decided and observed. @@ -122,7 +122,7 @@ The **identity work** establishes who acts and records it: - instance registration and agent-instance credentials; - the credential flow, from sign-in at AS-1 to per-user provider credentials at the gateway; - the [run owner](run-owner.md) on every call, so that shared sessions can come later without a call using the wrong user's credentials; -- sign-out everywhere, which stops every agent and schedule acting for the user, and AS-1's re-check of the company-IdP grant, which does the same when the account is disabled; +- sign-out everywhere, which stops every agent and schedule acting for the user, and AS-1's re-check of the user's upstream refresh token, which does the same when the account is disabled; - audit of who acted, for whom, through which delegation, and what was decided, dispatched, and observed. The **authority work** decides what each agent may do: diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md index b9cfed559d..3c326e17b4 100644 --- a/docs/drafts/agent-identity/agent-definitions.md +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -118,7 +118,10 @@ The broker's agent-instance issuer validates the registration records that the w - If there is a parent, the parent is registered to the same broker session. - A registration with no parent names a definition that can be a session's root. - For a definition that the broker loaded, the reference matches the broker's record. -- The subject follows the SPIFFE rules. Each definition category and name uses only `[A-Za-z0-9._-]`, is neither `.` nor `..`, and is at most 63 bytes. The nesting is at most 8 child levels below the root. The whole ID stays within the 2,048 bytes SPIFFE-ID §2.3 says not to exceed. A registration that breaks a rule fails closed; see the [SVID step](credentials-and-lifecycle.md#4-agent-instance-jwt-svid) of the credential flow. +- The subject follows the SPIFFE rules. Each definition category and name uses only `[A-Za-z0-9._-]`, is neither `.` nor `..`, and is at most 63 bytes. The nesting is at most 8 child levels below the root. A `legacy_` incarnation is no longer than an `inc_` one. The whole ID stays within the 2,048 bytes SPIFFE-ID §2.3 says not to exceed. +- If the SPIFFE ID was registered before, the earlier registration had the same host session, owner, definition reference and revision, and parent. Only the broker session may differ, when a replacement session re-registers the instance. + +A registration that breaks a rule fails closed. The issuer also gives each new SPIFFE ID its `ag_` identifier, reused on re-registration. The [SVID step](credentials-and-lifecycle.md#4-agent-instance-jwt-svid) of the credential flow is the full, ordered list of checks and their errors. If the checks pass, the issuer associates the supplied incarnation with the registered definition, source, revision, ceiling, and creating instance, if any. It stores the effective ceiling that it computes from them. The session aggregate mints the incarnation. mecak8s persists it and reports it. The broker does not allocate incarnations. A known definition, or a name that the loop supplies, is not enough for issuance. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index fd28866fc0..e9fba917b7 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -140,7 +140,7 @@ The diagram follows the successful path. Other outcomes: - **Denial or failure.** Any denial before dispatch prevents the provider call. Retrying requires authorization again. - **Unknown outcome.** If dispatch may have happened but no completion was observed, the outcome is unknown and is not replayed automatically. -Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists every failure, and [renewal triggers](#renewal-triggers) when each credential is replaced. +Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists the failures each step returns, and [renewal triggers](#renewal-triggers) when each credential is replaced. Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §8; broker calls are gRPC and use gRPC status codes. Where one error code covers several causes, a reason code tells them apart; see [error reasons](#error-reasons). @@ -258,7 +258,7 @@ client_assertion= 6. Session `A42` has never been closed, and has no recorded owner yet or its recorded owner is that same identity. Session IDs are never reused. 7. The token's `jti` has not opened a session before, or this is a retry: the same `jti`, client, and session, within the source token's `exp`, while `A42` is open. -**Output.** In one transaction, AS-1 records the converted `jti`, records the owner of `A42` if this is the first open, writes the refresh-token record, and revokes `A42`'s previous refresh token if it has one. A retry, or a later open by the same owner, therefore replaces the session's refresh token and never adds a second one. RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1), and AS-1 echoes the `mecatl_session` entry (RFC 9396 §7): +**Output.** In one transaction, AS-1 records the converted `jti`, records the owner of `A42` if this is the first open, writes the refresh-token record, and revokes `A42`'s previous refresh token if it has one. A retry, or a later open by the same owner, therefore replaces the session's refresh token and never adds a second one. An exchange still holding the replaced token gets reason `superseded`. RFC 8693 returns the issued token in `access_token` even when it is not an access token (§2.2.1), and AS-1 echoes the `mecatl_session` entry (RFC 9396 §7): ```json { @@ -302,7 +302,7 @@ Checks 5 and 6 exist because the broker supplies `A42`, and only mecak8s checks **Evidence.** AS-1 records the conversion: client `mecatl`, user `u-alice-91`, owner, source token `jti`, session `A42`, and the hard expiry. It records a refused open with its reason, and each refresh token a later open replaced. -**Session end.** `RT-A42` is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Closing the session, signing out everywhere, the hard expiry, and AS-1's upstream re-check revoke it. Losing `RT-A42` for any of these reasons closes `A42`. On Alice's next request, mecak8s opens a new broker session for `hostS42` with a fresh access token, registers its live instances into it again, parents first, with their existing incarnations ([step (4)](#4-agent-instance-jwt-svid)), and reports the open delegations again ([delegation report](#delegation-report)). A closed session leaves a record, its ID, owner, and closing time, so check 6 can refuse to reopen it. The converted `jti` is kept until the source token's `exp` plus the clock skew. +**Session end.** `RT-A42` is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Closing the session, signing out everywhere, the hard expiry, and AS-1's upstream re-check revoke it. Losing `RT-A42` for any of these reasons closes `A42`. On Alice's next request, mecak8s opens a new broker session for `hostS42` with a fresh access token, registers its live instances into it again, parents first, with their existing incarnations ([step (4)](#4-agent-instance-jwt-svid)), and reports the open delegations again, parents first ([delegation report](#delegation-report)). A closed session leaves a record, its ID, owner, and closing time, so check 6 can refuse to reopen it. The converted `jti` is kept until the source token's `exp` plus the clock skew. **Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with a `mecatl_session` entry naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs `authorization_details` at AS-1's refresh grant, a session-bound token per session in the TUI and Studio, and a two-step session open. It changes nothing downstream of `RT-A42`. @@ -322,7 +322,7 @@ Checks 5 and 6 exist because the broker supplies `A42`, and only mecak8s checks 4. With no parent, including after a fork or clear, the definition is root-eligible. With a parent, the parent is registered to `A42`. 5. The definition category and name match `[A-Za-z0-9._-]+` and are neither `.` nor `..` (SPIFFE-ID §2.2). A name that would need encoding is rejected, not rewritten. 6. The name and the nesting depth stay within the caps. Proposed: the definition category and name each up to 63 bytes, a `legacy_` incarnation no longer than an `inc_` one, and up to 8 child levels below the root, which keeps the longest subject near 1,600 bytes. The agent-instance issuer also checks the whole URI against the 2,048 bytes SPIFFE-ID §2.3 says implementations should not generate beyond. -7. If the instance was registered before, to an earlier broker session of `hostS42`, that session's recorded owner equals `A42`'s. +7. If the SPIFFE ID was registered before, in any session, that registration had the same host session, owner, definition reference and revision, and parent; only the broker session may differ. Registration records identity, not permission to spawn, run, or call tools. The agent-instance issuer constructs the canonical subject; see the [registration checks](agent-definitions.md#registering-instances). In the same transaction the agent-instance issuer gives the instance its **`ag_` identifier**: a random, opaque value such as `ag_7hq2x9`, created only if the SPIFFE ID has none yet and reused otherwise. Both directions are unique, one `ag_` identifier per SPIFFE ID and one SPIFFE ID per `ag_` identifier, and an identifier is never reassigned. Registering the same instance into a new session therefore keeps its `ag_` identifier. AS-1 later writes it into `act`. @@ -347,7 +347,7 @@ mecak8s checks that the returned subject is the registered instance. The harness 4. The signature verifies. 5. `aud` is exactly the broker's audience, as its only value. JWT-SVID §3.2 only requires the verifier's own value to be present; the single value is Mecatl policy. 6. `exp` has not passed, allowing the clock skew, and is no further in the future than the configured maximum SVID lifetime. -7. `sub` matches the full canonical subject of a registered instance byte for byte, and that instance is not deregistered. +7. `sub` matches the full canonical subject of a registered instance byte for byte. 8. The instance is registered to the session in use: `A42` from the request at the broker, the session of `RT-A42`'s record at AS-1. **Failures.** @@ -355,10 +355,10 @@ mecak8s checks that the returned subject is the registered instance. The harness | Check | Response | Retry | | --- | --- | --- | | Registration 1 | Step (2)'s codes | Only an expired workload credential. | -| Registration 2 | `FAILED_PRECONDITION` with the session's closing reason, or `PERMISSION_DENIED` for a session bound to another workload | After a new session opens. | +| Registration 2 | The session's closing reason with its status from [error reasons](#error-reasons), or `PERMISSION_DENIED` for a session bound to another workload | After a new session opens. | | Registration 3, 4, 7 | `PERMISSION_DENIED` | No. | | Registration 5, 6 | `INVALID_ARGUMENT` | No. | -| Registration repeated | The same instance reference for the same session, workload, definition, parent, and incarnation; `ALREADY_EXISTS` for the same instance with different content | A lost response is retried safely. | +| Registration repeated | The same instance reference for the same session and content; `ALREADY_EXISTS` for the same SPIFFE ID with different content | A lost response is retried safely. | | SVID 6, expired | `UNAUTHENTICATED` with `svid_expired` at the broker; 400 `invalid_request` with `svid_expired` at AS-1 | mecak8s renews the SVID, one renewal per instance at a time, and retries once. | | Any other SVID check | `PERMISSION_DENIED` at the broker; 400 `invalid_request` with `denied` at AS-1 (RFC 8693 §2.2.2) | No. | @@ -449,7 +449,7 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi | Check | Response | Retry | | --- | --- | --- | | 1 | Step (2)'s codes | Only an expired workload credential. | -| 2, session closed | `FAILED_PRECONDITION` with the reason AS-1 recorded, the same status and reason AS-1 would return | mecak8s acts on the reason; see [error reasons](#error-reasons). | +| 2, session closed | The reason AS-1 recorded, with that reason's status from [error reasons](#error-reasons) | mecak8s acts on the reason; see [error reasons](#error-reasons). | | 2, other workload | `PERMISSION_DENIED` | No. | | 3, expired | `UNAUTHENTICATED` with `svid_expired` | mecak8s renews and retries once. | | 3 otherwise, 4–7 | `PERMISSION_DENIED`, recorded; nothing is dispatched | No; retrying requires authorization again. | @@ -468,10 +468,10 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi 1. The workload is authenticated ([step (2)](#2-workload-credential)). 2. `A42` is open and bound to that workload. -3. `R41` belongs to `A42` and is bound to `mainRef`. If `R41` has no run record yet and names no delegation, the broker records it as a root run of `mainRef` with the reported owner, as [step (6)](#6-per-operation-authorization) check 4 would. If `R41` was itself delegated, its delegation was reported with `R41` as the receiving run. +3. `R41` belongs to `A42` and is bound to `mainRef`. If `R41` has no run record yet, the broker records it as a root run of `mainRef` with the reported owner, which needs `mainRef` to be a root instance, as [step (6)](#6-per-operation-authorization) check 4 would. If `R41` was itself delegated, its delegation was reported in `A42` with `R41` as the receiving run. 4. `reviewerRef` is registered to `A42`. 5. The reported owner of `R42` equals the owner of `R41`. -6. `D42` has not been reported before, or was reported with exactly the same content. +6. `D42` has not been reported in `A42`, or was reported in it with exactly the same content. Delegation records are kept per session. **Output.** A recorded delegation and a run record for `R42`, bound to `reviewerRef` and its owner, which lets `R42` pass [check 4](#6-per-operation-authorization) of every later call. @@ -480,7 +480,7 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi | Check | Response | Retry | | --- | --- | --- | | 1 | Step (2)'s codes | Only an expired workload credential. | -| 2 | `FAILED_PRECONDITION` with the session's closing reason, or `PERMISSION_DENIED` | After a new session opens, mecak8s reports the delegation again. | +| 2 | The session's closing reason with its status from [error reasons](#error-reasons), or `PERMISSION_DENIED` | After a new session opens, mecak8s reports the delegation again. | | 3–5 | `PERMISSION_DENIED` | No. Until a consistent report arrives, `R42` cannot call tools. | | 6, different content | `ALREADY_EXISTS` | No. | | 6, same content | Success | A lost response is retried safely. | @@ -565,7 +565,8 @@ ID-JAG makes a refresh token as subject token optional (§4.3: an IdP "MAY addit | 1 | 401 `invalid_client` | Call fails; not retryable. | | 2 | 400 `invalid_request` | Call fails; a broker bug. | | 3 | 400 `invalid_target` | Call fails; a configuration fault. | -| 4 | 400 `invalid_request` with the grant's revocation reason: `session_closed`, `signed_out`, `upstream_revoked`, or `owner_action_required` | mecak8s acts on the reason; see [error reasons](#error-reasons). | +| 4, revoked | 400 `invalid_request` with the grant's revocation reason | mecak8s acts on the reason; see [error reasons](#error-reasons). | +| 4, a schedule grant | 400 `invalid_request` with `denied` | Call fails; a broker bug. | | 5, IdP refuses the refresh | 400 `invalid_request` with `upstream_revoked` or `owner_action_required` | AS-1 has revoked all of Alice's grants; see [upstream re-check](#upstream-re-check). | | 5, recorded expiry reached, or no upstream refresh token | 400 `invalid_request` with `owner_action_required` | AS-1 has revoked all of Alice's grants; she must sign in again. | | 5, IdP unreachable, 5xx, or refresh lease timed out | 503 with `Retry-After`, nothing revoked | Call fails closed as a retryable infrastructure error. A 503 is plain HTTP, not an OAuth error, and never means revocation. | @@ -699,7 +700,7 @@ The failures each step can return: where each is detected, what the user sees, a | Access token already opened a session | AS-1 | `invalid_request` with `token_used` | Client refreshes and retries once | Nothing | | Access token issued before a user-wide revocation | AS-1 | `invalid_request` with that revocation's reason | Alice signs in again | Nothing more | | Echoed entry differs | Broker | Open fails | Session open fails | The new `RT-A42`, closing `A42` | -| Registration into a closed or foreign session | Agent-instance issuer | `FAILED_PRECONDITION`, `PERMISSION_DENIED` | Agent cannot start until a new session opens | Nothing | +| Registration into a closed or foreign session | Agent-instance issuer | The closing reason's status, or `PERMISSION_DENIED` | Agent cannot start until a new session opens | Nothing | | Re-registration under a different owner | Agent-instance issuer | `PERMISSION_DENIED` | Agent cannot start | Nothing | | Root registration of a non-root-eligible definition | Agent-instance issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | | Definition name or depth beyond the caps | Agent-instance issuer | `INVALID_ARGUMENT` | Child agent cannot start | Nothing | @@ -709,8 +710,8 @@ The failures each step can return: where each is detected, what the user sees, a | Run owner differs from the grant's recorded owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | | No route, or ceiling denies the tool | Broker | `PERMISSION_DENIED` | Call denied | Nothing | | Inconsistent delegation report | Broker | `PERMISSION_DENIED`, `ALREADY_EXISTS` | Delegated task cannot call tools | Nothing | -| Session closed or past its hard expiry | Broker, AS-1 | `FAILED_PRECONDITION`, `invalid_request` with `session_closed` | A new session opens on Alice's next request | Already revoked | -| Grant revoked by sign out everywhere | Broker, AS-1 | `FAILED_PRECONDITION`, `invalid_request` with `signed_out` | Run stops; Alice is signed out | Already revoked | +| Session closed or past its hard expiry | Broker, AS-1 | `invalid_request` with `session_closed`; the broker returns its status from error reasons | A new session opens on Alice's next request | Already revoked | +| Grant revoked by sign out everywhere | Broker, AS-1 | `invalid_request` with `signed_out`; the broker returns its status from error reasons | Run stops; Alice is signed out | Already revoked | | Schedule grant presented to an ID-JAG exchange, or a session refresh token used to open a scheduled run | AS-1 | `invalid_request` with `denied` | Call or run fails; a broker bug | Nothing | | Company IdP refuses the refresh | AS-1 | `invalid_request` with `upstream_revoked` | Every agent and schedule for Alice stops; she is signed out | All of Alice's grants and client refresh tokens | | Upstream refresh token past its recorded expiry, missing, or refused after an ambiguous refresh | AS-1 | `invalid_request` with `owner_action_required` | "Needs owner action": Alice signs in again | All of Alice's grants and client refresh tokens | @@ -726,7 +727,9 @@ The failures each step can return: where each is detected, what the user sees, a ### Error reasons -RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 and AS-2 therefore start `error_description` with a reason code, either alone or followed by a colon and text: `error_description=session_closed`, or `error_description=session_closed: session ended`. RFC 6749 §5.2 limits `error_description` to printable ASCII, and reason codes stay within it. The broker carries the reason in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome. +A scheduled run has no client to show anything to: for every revocation reason except `schedule_ended`, it ends with "needs owner action" and Alice is notified. + +RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 and AS-2 therefore start `error_description` with a reason code, either alone or followed by a colon and text: `error_description=session_closed`, or `error_description=session_closed: session ended`. RFC 6749 §5.2 limits `error_description` to the characters `%x20-21 / %x23-5B / %x5D-7E`, so the text never contains `"` or `\`; reason codes are lowercase letters and underscores. The broker carries the reason in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome. | Reason | Returned by | Broker gRPC status | mecak8s outcome | | --- | --- | --- | --- | @@ -734,10 +737,12 @@ RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that | `token_used` | AS-1, session open | `UNAUTHENTICATED` | The same. | | `owner_mismatch` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug. | | `session_owned` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug or a closed session ID reused. | -| `session_closed` | AS-1, broker | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. A scheduled run ends. | -| `signed_out` | AS-1, broker | `FAILED_PRECONDITION` | Stop the run; the client shows the user as signed out. | -| `upstream_revoked` | AS-1, broker | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. | -| `owner_action_required` | AS-1, broker | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. A scheduled run ends with "needs owner action" and the owner is notified. | +| `session_closed` | AS-1, broker | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. | +| `signed_out` | AS-1, broker | `FAILED_PRECONDITION` | Stop the run; the client shows the user as signed out. At session open: 401, and the client signs in again. | +| `upstream_revoked` | AS-1, broker | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. At session open: 401, and the client signs in again. | +| `schedule_ended` | AS-1, broker | `FAILED_PRECONDITION` | The schedule was deleted; its run does not start. | +| `owner_action_required` | AS-1, broker | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. | +| `superseded` | AS-1 | `ABORTED` | A later open replaced the session's refresh token; the broker rereads the stored one and retries once. | | `svid_expired` | AS-1, broker | `UNAUTHENTICATED` | Renew the SVID and retry once; not shown to the user. | | `denied` | AS-1, broker | `PERMISSION_DENIED` | Deny the call. | | `consent_required` | AS-2 | `FAILED_PRECONDITION` | The existing `mcp_authorization_pending` outcome. | @@ -754,11 +759,11 @@ RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that | Agent-instance SVID | When less than half its lifetime remains, on the next use; the old SVID stays valid until its `exp` | Agent-instance issuer, for the same registered instance; one renewal per instance at a time; the `ag_` identifier is unchanged | Calls from that instance fail | | Client assertion | Every token-endpoint request | Broker | Exchange fails with `invalid_client` | | `RT-A42` | Never | — | A new session open with a fresh access token | -| Company-IdP refresh token | At most once per re-check interval, when an ID-JAG is requested | AS-1, one refresh per user at a time | See [upstream re-check](#upstream-re-check) | +| Upstream refresh token | At most once per re-check interval, at an ID-JAG exchange or a scheduled-run session open | AS-1, one refresh per user at a time | See [upstream re-check](#upstream-re-check) | | ID-JAG | Never; one per `V` | — | — | | `V` | A skew margin before `exp`, at the next call; or within the same call after a 401 from the vMCP | Broker, through steps (7) and (8), one exchange per cache key at a time | The call fails with step (7) or (8)'s outcome | | Key sets: AS-1's, AS-2's, the agent trust bundle, the cluster JWKS, and the broker's `jwks_uri` at AS-1 and AS-2 | On an unknown `kid`, rate-limited, and periodically | Each verifier | Tokens signed with a new key are rejected until the refresh succeeds | -| Schedule authority | Before its horizon, after a notification | Alice | Runs end with "needs owner action" | +| Schedule grant | Before its horizon, after a notification; renewal moves the horizon of the live grant | Alice | Runs end with "needs owner action" | ### Configuration @@ -832,14 +837,14 @@ Signing out of one client and stopping Mecatl's agents are different actions. | User action or event | What ends | What remains | | --- | --- | --- | | **Sign out** in the TUI or Studio | That client's own AS-1 tokens, revoked at AS-1. | Running agents and schedules. Their authority is held by the broker, not by the client. | -| **Sign out everywhere** | Every agent acting for Alice: live runs stop, tool calls parked for authorization are cancelled, broker sessions and their refresh tokens are revoked, each of her schedule grants is revoked, and every client's AS-1 tokens are revoked. | Transcripts. Each schedule stays configured; its next run ends with "needs owner action" until Alice renews it. | +| **Sign out everywhere** | Every agent acting for Alice: live runs stop, tool calls parked for authorization are cancelled, broker sessions and their refresh tokens are revoked, each of her schedule grants is revoked, and every client's AS-1 refresh tokens and her AS-1 login sessions are revoked. | Transcripts. Each schedule stays configured; its next run ends with "needs owner action" until Alice renews it. | | **The company IdP disables Alice's account or revokes her grants** | The same as sign out everywhere, within one re-check interval: AS-1 triggers the broker's sign-out-everywhere path. | Transcripts. | | **Alice's upstream refresh token expires or needs her action** | The same, with "needs owner action" as the reason. | Transcripts. | | **Closing a session** | That session's refresh token. | Other sessions and schedules. | **Sign out everywhere.** The steps, whether the client starts them or AS-1 does: -1. AS-1 records Alice's revocation time and reason, revokes every grant it holds for her with that reason, and revokes every client's AS-1 refresh tokens, all in one transaction. From then on, session open and schedule setup refuse any access token issued before that time; a new sign-in issues tokens after it. +1. AS-1 records Alice's revocation time and reason, revokes every grant it holds for her with that reason, revokes every client's AS-1 refresh tokens, and ends her AS-1 login sessions, all in one transaction. From then on, session open and schedule setup refuse any access token whose `iat` is not later than that time. Issued access tokens still authenticate API requests until they expire, but can no longer open sessions or set up schedules. For `upstream_revoked` and `owner_action_required`, the next sign-in must go through the company IdP and bring a new upstream refresh token. 2. The broker cancels the in-flight tool calls of Alice's sessions and drops her cached ID-JAGs, `V`, and vMCP session IDs. 3. mecak8s cancels each session's live run and any tool call parked for authorization. When the client starts sign-out, it calls Mecatl before clearing its own tokens. When AS-1 starts it, the broker tells mecak8s: in process while the broker is a modulith, through a revocation event once it is a separate service. @@ -847,11 +852,11 @@ Today every session has one owner, so this ends each of her broker sessions. Rev ### Upstream re-check -AS-1 keeps one upstream refresh token per user, from her most recent sign-in at the company IdP, and re-checks it on behalf of every grant it holds for her; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. Every ID-JAG exchange compares the current time with that recorded expiry, which needs no call to the IdP. Reaching it ends Alice's authority with reason `owner_action_required`, through the sign-out-everywhere path above. If the IdP issued no refresh token at sign-in, every exchange ends the same way, unless the operator has turned off the re-check for that IdP ([configuration](#configuration)). +AS-1 keeps one upstream refresh token per user, from her most recent sign-in at the company IdP, and re-checks it on behalf of every grant it holds for her; a client signing out does not remove it. AS-1 obtains it at sign-in ([step (1)](#1-as-1-access-token)) and records when it expires: the IdP's `refresh_expires_in` where the IdP sends one, otherwise the configured maximum for that IdP. Every ID-JAG exchange and scheduled-run session open compares the current time with that recorded expiry, which needs no call to the IdP. Reaching it ends Alice's authority with reason `owner_action_required`, through the sign-out-everywhere path above. If the IdP issued no refresh token at sign-in, every exchange ends the same way, unless the operator has turned off the re-check for that IdP ([configuration](#configuration)). -When the broker exchanges a refresh token for an ID-JAG and the re-check interval has passed, AS-1 refreshes the upstream token first: +At an ID-JAG exchange or a scheduled-run session open, when the re-check interval has passed, AS-1 refreshes the upstream token first: -1. AS-1 takes a lease on Alice's upstream token and records that a refresh is in flight before calling the IdP. Other exchanges for Alice wait for the lease; if it times out, they fail closed with a 503 and retry later. +1. AS-1 takes a lease on Alice's upstream token and records that a refresh is in flight before calling the IdP. Other exchanges for Alice wait for the lease; if it times out, they fail closed with a 503 and retry later. A waiter that resumes re-reads its grant first, so it returns the grant's revocation reason if the lease holder revoked it. 2. AS-1 calls the IdP, then stores the new token, rotated or not, and clears the in-flight record. 3. If AS-1 crashed or lost the response while a refresh was in flight, it cannot know whether the IdP rotated the token. The next refresh's `invalid_grant` is then treated as "needs owner action", never as revocation. @@ -861,8 +866,7 @@ The IdP's answer decides what happens: | --- | --- | --- | | Success | Stores the new token, records the time | The exchange continues. | | `invalid_grant` before the recorded expiry, after a clean previous refresh | Runs the sign-out-everywhere path with reason `upstream_revoked`, and discards the upstream refresh token | The account was disabled or its grants revoked: the same as sign out everywhere. | -| `invalid_grant` with no recorded expiry | The same | Treated as revocation, because AS-1 cannot tell the two apart. | -| `invalid_grant` at or after the recorded expiry, or after an ambiguous previous refresh | Runs the sign-out-everywhere path with reason `owner_action_required`, and discards the upstream refresh token | "Needs owner action": Alice signs in again. Not reported as a disabled account. | +| `invalid_grant` at or after the recorded expiry, or after an ambiguous previous refresh (this row wins when both apply) | Runs the sign-out-everywhere path with reason `owner_action_required`, and discards the upstream refresh token | "Needs owner action": Alice signs in again. Not reported as a disabled account. | | Network error or 5xx | Nothing | That exchange fails closed with a 503; the next one retries. | A revoked grant stays revoked whatever its reason: signing in again starts a new session or sets up the schedule's grant again, and never revives the old grant. The reason only decides what Alice is told. Until she signs in again, AS-1 holds no upstream refresh token for her, so any exchange fails closed. @@ -902,7 +906,7 @@ authorization_details=[{"type": "mecatl_schedule", "schedule": "sch-nightly-7", "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] ``` -**Checks, at AS-1:** session-open checks 1 to 5 and 7, with the `mecatl_schedule` entry in place of the `mecatl_session` one. Instead of check 6, the schedule has no live grant yet, or its grant belongs to the same owner and is replaced. +**Checks, at AS-1:** session-open checks 1 to 5 and 7, with the `mecatl_schedule` entry in place of the `mecatl_session` one. Instead of check 6, AS-1 records the schedule's owner at first setup and keeps it after any revocation; a later setup must name the same owner, or is refused with `invalid_authorization_details` and `session_owned`. A setup for a schedule whose grant is live moves that grant's horizon; after a revocation, it creates a new grant. **Output.** A schedule grant bound to `mecatl`, the schedule, and Alice, with the horizon. The broker stores it under the schedule. Presented at the refresh-token grant, it returns `invalid_grant`, as `RT-A42` does. @@ -929,11 +933,23 @@ client_assertion= 3. The subject token resolves to a schedule grant bound to `mecatl`, not revoked, and before its horizon. A session refresh token is refused here. 4. Alice's upstream refresh token passes the [upstream re-check](#upstream-re-check), as in step (7) check 5. 5. The entry's owner equals the schedule grant's recorded owner. -6. Session `A57` has never been opened, or this is a retry: the same schedule grant, client, and session. +6. Session `A57` has never been opened, or this is a retry: the same schedule grant, client, and session, while `A57` is open. A retry revokes the previous refresh token in the same transaction. **Output.** A session refresh token for `A57`, with the same response shape as session open, whose expiry is no later than the schedule's horizon. AS-1 records which schedule grant it came from, so revoking the schedule grant revokes it. From here the run is an ordinary session: instances register to `A57`, and steps (6) to (9) apply unchanged, including the `V` cache key. When the run ends, mecak8s closes `A57`, which revokes its refresh token. -**Failures.** As for session open; a revoked schedule grant returns its revocation reason, and the run ends with "needs owner action" and a notification to Alice. A lost response is retried with the same parameters and a new client assertion. +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| 1 | 401 `invalid_client` | No. | +| 2 | 400 `invalid_request` or `invalid_authorization_details` | No; a broker bug. | +| 3, revoked | 400 `invalid_request` with the grant's revocation reason; `schedule_ended` if the schedule was deleted | No; the run does not start. | +| 3, past the horizon | 400 `invalid_request` with `owner_action_required` | No; Alice is notified to renew. | +| 3, a session refresh token | 400 `invalid_request` with `denied` | No; a broker bug. | +| 4 | As step (7) check 5 | As step (7) check 5. | +| 5 | 400 `invalid_request` with `owner_mismatch` | No. | +| 6 | 400 `invalid_authorization_details` with `session_owned` | No. | +| Lost response | None received | Yes: the same parameters with a new client assertion. | When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index a7a51b49ef..bdfbf654d7 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -18,7 +18,7 @@ Verify that scheduled invocations preserve owner attribution without treating sa Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `.` and `..`); reject invalid names rather than rewriting them. Agent-instance incarnations use persisted `inc_` values with 26 lowercase base32 characters, or deterministic `legacy_` values for older snapshots. -Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs. The proposed caps are names up to 63 bytes and up to 8 child levels below the root, which keeps the longest subject near 1,400 bytes; registration past either cap is refused. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. +Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs. The proposed caps are the definition category and name each up to 63 bytes, and up to 8 child levels below the root, which keeps the longest subject near 1,600 bytes; registration past either cap is refused. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. ## AS-1 grant and AS-2 user resolution @@ -64,9 +64,9 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Source access-token `jti` | | Refuses a second session opened with the same token. | | Issue time and hard expiry | | Authority horizon. | | Source schedule grant | | For a scheduled run's session only: revoking the schedule grant revokes this refresh token. | -| Revocation reason | | Set when revoked: `session_closed`, `signed_out`, `upstream_revoked`, or `owner_action_required`. | +| Revocation reason | | Set when revoked: `session_closed`, `signed_out`, `upstream_revoked`, `owner_action_required`, or, for a schedule grant, `schedule_ended`. | -The broker supplies the session reference. The `mecatl_session` type requires AS-1 to echo the entry unchanged (RFC 9396 §7); the broker checks it is identical, revokes the refresh token and fails the open if not, and otherwise stores the refresh token with the echoed owner in its shared session storage. AS-1 refuses an open while the session already has a live refresh token. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record, the owner, and the refresh-token record in one transaction. The broker runs at most one open per session at a time, and a retry carries a new client assertion. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and keeps a closed-session record (ID, owner, closing time) so the session is never reopened; the converted `jti` is kept until the source token's `exp` plus the clock skew. Losing the refresh token for any reason closes the session. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. +The broker supplies the session reference. The `mecatl_session` type requires AS-1 to echo the entry unchanged (RFC 9396 §7); the broker checks it is identical, revokes the refresh token and fails the open if not, and otherwise stores the refresh token with the echoed owner in its shared session storage. A later open by the same owner with a fresh token replaces the live refresh token in the same transaction. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record, the owner, and the refresh-token record in one transaction. The broker runs at most one open per session at a time, and a retry carries a new client assertion. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and keeps a closed-session record (ID, owner, closing time) so the session is never reopened; the converted `jti` is kept until the source token's `exp` plus the clock skew. Losing the refresh token for any reason closes the session. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. **Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: @@ -130,7 +130,7 @@ Test the session-open exchange: - A crash between writing the converted-`jti` record and the refresh-token record leaves neither. - The response echoes the `mecatl_session` entry unchanged; an owner that differs from the token's `sub_id` is refused with `invalid_request`, and a session owned by another user or closed is refused with `invalid_authorization_details`. - An echoed entry that differs from the request makes the broker revoke the refresh token and fail the open. -- A second open of a session that has a live refresh token, with a different `jti`, is refused. Two concurrent opens with one `jti` for two sessions yield one session. +- A later open of a session that has a live refresh token, by the same owner with a fresh `jti`, replaces it and revokes the old one; an exchange still holding the old one gets `superseded`. An open by a different owner is refused. Two concurrent opens with one `jti` for two sessions yield one session. - A broker crash after AS-1 issued the refresh token, before the broker stored it, is recovered by a retry that revokes the orphan. - A retry after a lost response, with a new client assertion, succeeds; mecak8s's client refreshes and retries at most once. - Losing the refresh token closes the session, and a new open of the same session ID is refused. @@ -333,12 +333,12 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - The broker refuses a call whose reported run owner differs from the owner recorded with the grant in use, the company identity, and records the denial. - A child run's owner equals its parent run's owner; the broker refuses a delegation that changes it. -- `ReportDelegation` is refused for an unknown delegating run, an instance not registered to the session, or a delegating run that is not its parent delegation's receiving run. A repeat with the same content succeeds; a repeat with different content returns `ALREADY_EXISTS`. +- `ReportDelegation` is refused for an unknown delegating run whose instance is not a root instance, an instance not registered to the session, or a delegating run that is not its parent delegation's receiving run. A repeat with the same content succeeds; a repeat with different content returns `ALREADY_EXISTS`. - A call naming a delegation that was not reported is denied. - Registration into a closed session, or a session bound to another workload, is refused. - A call whose SVID instance differs from the run record's instance is denied; a new run naming no delegation from a child instance is denied; two replicas recording the same new run keep one record. - `ReportDelegation` for a delegating root run with no record yet records it and succeeds. -- Re-registering an instance into a new session for the same host session keeps its incarnation and `ag_` identifier; re-registering it under a different owner is refused; a repeated registration with the same content returns the same reference, with different content `ALREADY_EXISTS`. +- Re-registering an instance into a new session for the same host session keeps its incarnation and `ag_` identifier; re-registering a SPIFFE ID with a different host session, owner, definition revision, or parent is refused; a repeated registration with the same content returns the same reference, with different content `ALREADY_EXISTS`. Test the checks at each hop: @@ -359,7 +359,7 @@ Verify the behavior in [sign-out and revocation](credentials-and-lifecycle.md#si - After it, session open and schedule setup refuse an access token issued before the revocation time, including one opened concurrently with the revocation. - It reaches sessions served by another replica. - After it, an ID-JAG exchange with any revoked refresh token fails, and each schedule's next run ends with "needs owner action". -- When the upstream IdP returns `invalid_grant` before the upstream token's recorded expiry, or when no expiry is recorded, AS-1 runs the sign-out-everywhere path with reason `upstream_revoked`, revoking all the user's grants and client refresh tokens, and the exchange fails. +- When the upstream IdP returns `invalid_grant` before the upstream token's recorded expiry, after a clean previous refresh, AS-1 runs the sign-out-everywhere path with reason `upstream_revoked`, revoking all the user's grants and client refresh tokens, and the exchange fails. - When it returns `invalid_grant` at or after the recorded expiry, or the recorded expiry passes, or the IdP issued no refresh token and the re-check is not turned off for it, AS-1 runs the same path with reason `owner_action_required`. - A later exchange for a revoked session returns the stored reason, not `session_closed`; a grant is never revived by a new sign-in. - A network error or 5xx fails that exchange and revokes nothing. From 09429643115882048378845acea5065584db76a5 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 13:09:14 +0200 Subject: [PATCH 48/50] docs: restructure the credentials chapter around one flow Make credentials-and-lifecycle the single home of the end-to-end token flow and reorder it for implementers: - One credentials table with custody, then the walkthrough with a new step (0) for broker startup, the failure matrix, and error reasons. - Scheduled runs follow the interactive flow; lifetimes and renewal become one table; vMCP sessions move here from the gateway chapter. - A Later work section collects the unselected options; the workload and instance-identity material moves under Reference. - The User identity, custody, actor-history, provider-selection, and sender-binding sections are folded into the steps they belong to. mcp-gateway-modes keeps only what differs between embedded and external gateways and links to the flow. implementation.md keeps the grant record, open items, tests, and ledger, and links to the chapter instead of restating it. Step numbers are unchanged so existing links still resolve. Co-Authored-By: mecatl --- .../credentials-and-lifecycle.md | 334 +++++++++--------- docs/drafts/agent-identity/implementation.md | 80 +---- .../agent-identity/mcp-authorization.md | 4 +- .../agent-identity/mcp-gateway-modes.md | 156 +------- 4 files changed, 172 insertions(+), 402 deletions(-) diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index e9fba917b7..03c481fd6d 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -15,19 +15,21 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that ## Credential roles -| Credential | What it establishes | Issuer → holder → verifier | -| --- | --- | --- | -| **AS-1 access token** | Alice's identity for requests to the Mecatl API. | AS-1 → Alice's client (TUI or Studio) → mecak8s. Passed to the broker once, at session open. | -| **Client refresh token** | The client's ability to renew its own AS-1 access token. | AS-1 → client only. Never leaves the client. | -| **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and agent-instance issuer. It identifies neither Alice nor an agent instance. | -| **Agent-instance JWT-SVID** | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | -| **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl`, the broker session, and the user. Accepted only as the subject of an ID-JAG exchange. | -| **Schedule grant** | Alice's delegated authority for one schedule, until its horizon. | AS-1 → broker → AS-1. Bound to client `mecatl`, the schedule, and the user. Accepted only to open a scheduled run's session. | -| **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | -| **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | -| **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. Cached per session, instance, gateway, and user. | -| **Upstream provider credential** | Access to Alice's GitHub account. | GitHub → gateway credential store → GitHub. Never leaves the gateway. | -| **Signing keys** | Authority to issue credentials. | AS-1, AS-2, and the agent-instance issuer each keep their own key. Separate keys are not process isolation when components share a process. | +| Credential | What it establishes | Issuer → holder → verifier | Custody | +| --- | --- | --- | --- | +| **AS-1 access token** | Alice's identity for requests to the Mecatl API. | AS-1 → Alice's client (TUI or Studio) → mecak8s. Passed to the broker once, at session open. | Not stored by the server side. | +| **Client refresh token** | The client's ability to renew its own AS-1 access token. | AS-1 → client only. | TUI keyring or Studio sealed cookie; never leaves the client. | +| **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and agent-instance issuer. It identifies neither Alice nor an agent instance. | Projected ServiceAccount token or a configured SPIRE identity. | +| **Agent-instance SVID** | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | Harness, outside model context. Renewal keeps the registered identity and widens nothing. | +| **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl`, the broker session, and the user. Accepted only as the subject of an ID-JAG exchange. | Broker, in shared session storage with the grant's recorded owner and expiry. Does not rotate. | +| **Schedule grant** | Alice's delegated authority for one schedule, until its horizon. | AS-1 → broker → AS-1. Bound to client `mecatl`, the schedule, and the user. Accepted only to open a scheduled run's session. | Broker, in shared storage under the schedule. | +| **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | +| **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | Broker, from issuance to redemption. | +| **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. | Broker cache, per session, instance, gateway, and user; per-call checks still apply. | +| **Upstream provider credential** | Access to Alice's GitHub account. | GitHub → gateway credential store → GitHub. | Gateway credential store, keyed by (gateway, user, provider); never leaves the gateway. | +| **Signing keys** | Authority to issue credentials. | AS-1, AS-2, and the agent-instance issuer each keep their own key. | Separate keys are not process isolation when components share a process. | + +Two pieces of state matter as much as the credentials: broker session `A42`, whose reference grants nothing on its own, and, in external mode, the consent link at AS-2. Keep credential values out of model context, tool arguments, results, transcripts, logs, environment variables, audit records, diagnostics, and errors. Hiding a credential from the model does not protect it from a compromised process that holds it. This protected MCP path does not relocate other harness credentials, such as those used to call an LLM provider. ### Example nomenclature @@ -140,10 +142,24 @@ The diagram follows the successful path. Other outcomes: - **Denial or failure.** Any denial before dispatch prevents the provider call. Retrying requires authorization again. - **Unknown outcome.** If dispatch may have happened but no completion was observed, the outcome is unknown and is not replayed automatically. -Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists the failures each step returns, and [renewal triggers](#renewal-triggers) when each credential is replaced. +Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists the failures each step returns, and [lifetimes and renewal](#lifetimes-and-renewal) when each credential is replaced. Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §8; broker calls are gRPC and use gRPC status codes. Where one error code covers several causes, a reason code tells them apart; see [error reasons](#error-reasons). +### (0) Broker startup + +**Input.** The operator's configuration names the definition sources for global definitions. + +**Checks, at the definition loader:** each source is configured and reachable, and each definition parses and passes the [definition checks](agent-definitions.md#definition-provenance). + +**Output.** A record per definition, with its source, revision, and whether it may be a session's root; with the authority work, also its ceiling. Repository definitions are not loaded; mecak8s names them by reference at registration. + +**Failures.** A definition that fails is not loaded and cannot be registered. The broker serves the definitions that loaded. + +**Why.** An agent name is only a claim. Loading definitions inside the broker means a definition name in an audit record refers to a known source and revision, and mecak8s cannot supply fake content for a global definition. + +**Evidence.** The loader records each load with source and revision ([audit](audit.md#auditevent-profile)). + ### (1) AS-1 access token AS-1 is Mecatl's login authorization server. mecak8s's protected-resource metadata names AS-1, and the TUI and Studio already discover their issuer there, so neither client changes. AS-1 does not hold passwords: it redirects Alice to the company IdP as an ordinary OIDC client. The company IdP administrator registers one web application, AS-1, with AS-1's callback as redirect URI. The company IdP therefore sees only AS-1 as the application, and per-application IdP policies can no longer distinguish the TUI from Studio. @@ -304,12 +320,6 @@ Checks 5 and 6 exist because the broker supplies `A42`, and only mecak8s checks **Session end.** `RT-A42` is not tied to Alice's AS-1 login session, so signing out of one client does not end it. Closing the session, signing out everywhere, the hard expiry, and AS-1's upstream re-check revoke it. Losing `RT-A42` for any of these reasons closes `A42`. On Alice's next request, mecak8s opens a new broker session for `hostS42` with a fresh access token, registers its live instances into it again, parents first, with their existing incarnations ([step (4)](#4-agent-instance-jwt-svid)), and reports the open delegations again, parents first ([delegation report](#delegation-report)). A closed session leaves a record, its ID, owner, and closing time, so check 6 can refuse to reopen it. The converted `jti` is kept until the source token's `exp` plus the clock skew. -**Later: a token bound to the session.** When a session is created, the user's client could silently refresh its AS-1 token with a `mecatl_session` entry naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs `authorization_details` at AS-1's refresh grant, a session-bound token per session in the TUI and Studio, and a two-step session open. It changes nothing downstream of `RT-A42`. - -**Later: run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still turn any fresh token the user sends into a run of its choosing, one run per token; only a key held by the client and signing each run's start would prevent that. - -**Alternative:** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, and is an extension to the ID-JAG profile. - ### (4) Agent-instance JWT-SVID **Input.** For the diagram's main registration, mecak8s supplies session `A42`, the persisted main agent-instance incarnation, and the selected definition reference, with no parent. For the reviewer, it supplies `A42`, the child's persisted incarnation, the definition reference `global/code-reviewer`, and the registered main-instance reference, not the main's token. Both calls carry the workload credential. @@ -368,16 +378,11 @@ The SVID carries a single audience, the broker's, as JWT-SVID §7.2 recommends. JWT-SVID requires `sub`, `aud`, and `exp`, but not `iss` or `jti`. The agent-instance issuer and its trust domain are distinct from OAuth AS-1 and AS-2. -**Does not prove.** This is an assertion, not attestation: the agent-instance issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. While the broker runs inside mecak8s, the agent-instance issuer shares a process, and therefore its security controls, with AS-1 and the broker. AS-1's registration check catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings are possible but not selected: - -- **Session claim.** The SVID names the session it was issued for, so AS-1 can check that the SVID and the subject token belong to the same session without relying on the broker's pairing. It is still issued on mecak8s's word. -- **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession, so a stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. - -Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. +**Does not prove.** This is an assertion, not attestation: the agent-instance issuer signs on mecak8s's workload-authenticated registration, and no evidence about the agent itself is produced or appraised. While the broker runs inside mecak8s, the agent-instance issuer shares a process, and therefore its security controls, with AS-1 and the broker. AS-1's registration check catches pairing bugs in the broker core, not a compromised broker or mecak8s, which write the registry. Two strengthenings and per-agent attestation are [later work](#later-work). **Evidence.** The agent-instance issuer records each registration as a [lineage event](audit.md#lineage), marked as reported by mecak8s, with the instance's `ag_` identifier, and each SVID issuance with the instance and expiry. -See [renewal triggers](#renewal-triggers) for renewal across runs. +See [lifetimes and renewal](#lifetimes-and-renewal) for renewal across runs. ### (5) Broker client authentication @@ -413,6 +418,8 @@ The private key never leaves the broker and can live in a KMS or HSM. In externa One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. +This makes `RT-A42`, the schedule grant, and the ID-JAG usable only with the broker's key. The agent-instance SVID is a bearer token; at AS-1 it is usable only alongside `mecatl`'s client authentication. `V` stays a bearer token at the vMCP: its signature protects its claims but does not prove who holds it. + **Does not prove.** Which agent asked: `mecatl` is the broker's registration, not an actor. Nor does it stop a thief with a stolen AS-1 access token from presenting it to the Mecatl API, where mecak8s converts it at session open, once per token. **Evidence.** AS-1 and AS-2 record the authenticated client and the assertion's `kid` with each token-endpoint decision. @@ -554,7 +561,7 @@ ID-JAG makes a refresh token as subject token optional (§4.3: an IdP "MAY addit - `sub` comes from `RT-A42`'s grant, never from the request. - `act.sub` is the instance's `ag_` identifier, created at registration: no SPIFFE path, no definition name, and no colon, so it reads as a plain string rather than a URI. It stays the same across SVID renewals, so the vMCP can bind a session to the instance and audit can correlate its calls. `act.iss` names AS-1, which minted `act`, so the identifier stays attributable to AS-1 inside `V`, whose own `iss` is AS-2. The full SVID stops at AS-1, so internal structure does not cross into the gateway's domain. -- `act` has one level. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. +- `act` has one level and names the current actor only (RFC 8693 §4.1). RFC 8693 treats prior actors in a nested `act` as informational, so leaving them out removes nothing the gateway enforces. That main delegated `D42` to the reviewer is recorded in Mecatl's harness and broker records, not in the token. A nested `act` naming main comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. - `resource` is signed by AS-1, so AS-2 checks the redemption's `resource` against a claim, not only against a broker parameter. - `aud_sub` and `email`, recommended by the draft, are deliberately omitted: AS-1 does not know AS-2's subject, and an email address is not an identity link. @@ -577,9 +584,11 @@ ID-JAG makes a refresh token as subject token optional (§4.3: an IdP "MAY addit **Why.** ID-JAG leaves actor-token processing undefined and warns that a client could pair a valid subject token with an unrelated actor (§9.7); SVID check 8 is that missing binding. ID-JAG is a profile of [identity chaining across domains](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html). It adds the `typ`, required `client_id` and `jti`, client-binding redemption checks, and security rules that a private chaining profile would otherwise have to define. Both are Internet-Drafts; identity chaining is in the RFC Editor queue, and ID-JAG is a working-group draft. +AS-1 never issues an access token for AS-2's audience, and the ID-JAG's issuer, audience, and `typ` keep the vMCP from accepting it as `V`. + AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode. The draft's §9.3 scopes the profile to parties in different trust domains, in the OAuth sense of a security and administrative boundary (§2.2). This design calls those administrative domains, to keep "trust domain" for SPIFFE. External mode fits §9.3, and embedded mode reuses the format for one protocol path. See [embedded mode](mcp-gateway-modes.md#embedded-mode). -**Does not prove.** That the reviewer, rather than other code in the harness process, asked for the call: the SVID is asserted, not attested. +**Does not prove.** That the reviewer, rather than other code in the harness process, asked for the call: the SVID is asserted, not attested. Nor delegation or run context: those are mecak8s reports the broker checked in step (6). **Evidence.** AS-1 records the ID-JAG issuance: user, session `A42`, the validated instance's SPIFFE ID and its `ag_` identifier, target, `jti`, and the reported operation. @@ -604,7 +613,7 @@ client_assertion= 4. `client_id` matches the authenticated client (ID-JAG §4.4.1). 5. `jti` has not been redeemed (RFC 7523 §3). AS-2 keeps used identifiers until `exp` plus the clock skew. 6. The requested `resource` is the ID-JAG's `resource` claim, and this client and actor may reach it. -7. AS-2 resolves the native user as the table below shows, never through `sub_id` (§9.5), email, or display name. +7. AS-2 resolves the native user as the table below shows, never through `sub_id`, email, or display name. A `sub_id` in the ID-JAG would be AS-1's claim about the company identity, not the IdP's (§9.5). The native identifier need not equal either upstream subject. | Mode | How AS-2 resolves and authorizes the user | | --- | --- | @@ -633,6 +642,8 @@ client_assertion= } ``` +`V`'s lifetime does not exceed the ID-JAG's. How each mode resolves the user is the main difference between the [gateway modes](mcp-gateway-modes.md#how-as-2-resolves-the-user). + **Failures.** | Check | Response | Broker outcome and retry | @@ -677,9 +688,9 @@ client_assertion= | 3 | Tool error | Returned as a denial; not retried. | | 4 | "Needs authorization" | The existing `mcp_authorization_pending` outcome; after Alice connects GitHub, a later call passes every check again. | -**Why.** The vMCP is the only component that holds the upstream credential, so it makes the last check before the provider call and looks the credential up from the validated user alone; nothing in the request can point it at another user's credential. +**Why.** The vMCP is the only component that holds the upstream credential, so it makes the last check before the provider call and looks the credential up from the validated user alone. The model cannot select another user's credential by supplying a URL, header, audience, or credential selector. If identity, link, connection, or authorization evidence is missing, stale, or revoked, the call fails closed; it never falls back to a service credential, another user, or a more privileged agent. -**Does not prove.** Provider-side effects: neither a successful MCP response nor an authorization record proves them. +**Does not prove.** Provider-side effects: neither a successful MCP response nor an authorization record proves them. GitHub sees only Alice's GitHub token, not the ID-JAG, the SVID, or `V`, so the design claims no audit trail from the gateway to the provider. **Evidence.** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, outcome, and the reported operation and attempt. @@ -750,85 +761,97 @@ RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that | HTTP 503 | AS-1, AS-2 | `UNAVAILABLE` | Fail the call as a retryable infrastructure error, not a denial. | | `invalid_client`, `unauthorized_client`, `invalid_target`, or no reason | AS-1, AS-2 | `INTERNAL` | Fail the call; a configuration fault or broker bug. An external AS-2 that sends no reason on `invalid_grant` is treated as `consent_required`. | -### Renewal triggers +## Scheduled runs -| Credential | Renewed when | By | If renewal fails | -| --- | --- | --- | --- | -| AS-1 access token | Before expiry, and before each session open | Client, with its refresh token | Sign in again | -| Workload credential | Before it expires | Kubelet; mecak8s rereads the file | Calls fail with `UNAUTHENTICATED` | -| Agent-instance SVID | When less than half its lifetime remains, on the next use; the old SVID stays valid until its `exp` | Agent-instance issuer, for the same registered instance; one renewal per instance at a time; the `ag_` identifier is unchanged | Calls from that instance fail | -| Client assertion | Every token-endpoint request | Broker | Exchange fails with `invalid_client` | -| `RT-A42` | Never | — | A new session open with a fresh access token | -| Upstream refresh token | At most once per re-check interval, at an ID-JAG exchange or a scheduled-run session open | AS-1, one refresh per user at a time | See [upstream re-check](#upstream-re-check) | -| ID-JAG | Never; one per `V` | — | — | -| `V` | A skew margin before `exp`, at the next call; or within the same call after a 401 from the vMCP | Broker, through steps (7) and (8), one exchange per cache key at a time | The call fails with step (7) or (8)'s outcome | -| Key sets: AS-1's, AS-2's, the agent trust bundle, the cluster JWKS, and the broker's `jwks_uri` at AS-1 and AS-2 | On an unknown `kid`, rate-limited, and periodically | Each verifier | Tokens signed with a new key are rejected until the refresh succeeds | -| Schedule grant | Before its horizon, after a notification; renewal moves the horizon of the live grant | Alice | Runs end with "needs owner action" | - -### Configuration +A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. -None of these values is selected. Each bounds a risk the operator should know: +A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own grant from Alice, with an explicit horizon. A scheduled run's owner is checked against the owner recorded with the schedule grant. Deleting the schedule, signing out everywhere, and the upstream re-check revoke it. -- **Lifetimes:** `RT-A42`'s hard expiry, a schedule grant's horizon, the ID-JAG lifetime, and the maximum SVID, workload-credential, and client-assertion lifetimes. -- **Clock skew**, and the margins derived from it: the `V` skew margin and how long used `jti` values are kept past `exp`. -- **Upstream re-check:** the re-check interval, the maximum upstream refresh-token lifetime per IdP when the IdP sends no `refresh_expires_in`, and the lease timeout for a refresh in flight. -- **Key refresh:** the rate limit on refreshing a key set after an unknown `kid`. -- **Closed sessions:** how long closed-session records are kept. Session IDs must be unguessable and never reused, so a record kept for less than forever relies on the ID space alone. -- **IdPs without refresh tokens:** a per-IdP setting that skips the upstream re-check. It is off by default; with it on, a disabled account keeps acting until each refresh token's hard expiry. -- **IdP outage:** with no tolerance, every exchange fails closed once the re-check interval has passed while the company IdP is down. A grace period would keep agents working through a short outage, at the cost of a disabled account acting for that much longer. +### Schedule setup -## User identity +**Input.** When Alice sets up the schedule, her client refreshes its access token, and the broker exchanges it at AS-1 as at [session open](#3-session-open-and-the-broker-refresh-token), with a `mecatl_schedule` entry instead of a `mecatl_session` one: -AS-1 runs Alice's sign-in through the company IdP and represents her as `u-alice-91`, carrying the company identity in `sub_id`. mecak8s authenticates the API request with the AS-1 token and takes the session owner from its `sub_id`, the company identity. The broker receives Alice's AS-1 token once and converts it into `RT-A42`; every ID-JAG names the subject from that refresh token's record, not from caller input. +```text +authorization_details=[{"type": "mecatl_schedule", "schedule": "sch-nightly-7", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] +``` -No mecak8s code path can name a user who did not sign in at AS-1. An attacker with code execution in mecak8s is another matter. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is a separate service, the attacker can act only in open broker sessions bound to its workload and open new ones with fresh tokens from users currently using it. See [deployment and threats](README.md#deployment-and-threats). A compromised broker or AS-1 can still act for any user whose authority it holds; in external mode the consent link limits that to users who linked Mecatl. +**Checks, at AS-1:** session-open checks 1 to 5 and 7, with the `mecatl_schedule` entry in place of the `mecatl_session` one. Instead of check 6, AS-1 records the schedule's owner at first setup and keeps it after any revocation; a later setup must name the same owner, or is refused with `invalid_authorization_details` and `session_owned`. A setup for a schedule whose grant is live moves that grant's horizon; after a revocation, it creates a new grant. -AS-2 resolves `u-alice-91` to native Alice `usr-alice-7c1d` through shared login (embedded) or the consent link (external). The native identifier need not equal either upstream subject. Never infer the mapping from matching names or email addresses. +**Output.** A schedule grant bound to `mecatl`, the schedule, and Alice, with the horizon. The broker stores it under the schedule. Presented at the refresh-token grant, it returns `invalid_grant`, as `RT-A42` does. -The agent remains the actor: AS-1 validates its SVID and the registered presenter combination and sets `act`. AS-2 receives neither the AS-1 access token nor the SVID. Run, delegation, and parent facts remain lifecycle and context evidence. Each call also carries the [run owner](run-owner.md); see [step (6)](#6-per-operation-authorization). +### Scheduled-run session open -## Credential custody +**Input.** When the schedule fires, mecak8s calls `OpenSession` with the schedule ID in place of an access token, and the broker allocates a session ID for the run. The broker exchanges the stored schedule grant: -The following custody boundaries do not imply a selected persistence implementation or process-isolation boundary. +```text +POST https://broker.example.com/as/oauth/token +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +subject_token= +subject_token_type=urn:ietf:params:oauth:token-type:refresh_token +requested_token_type=urn:ietf:params:oauth:token-type:refresh_token +authorization_details=[{"type": "mecatl_session", "session": "A57", + "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` -| Credential | Holder and use | Custody and renewal | -| --- | --- | --- | -| AS-1 access token | Client presents it to mecak8s; mecak8s passes it to the broker once at session open. | Not stored by the server side. | -| Client refresh token | TUI keyring or Studio sealed cookie. | Never leaves the client. | -| Workload credential | Platform-managed; mecak8s presents it to the agent-instance issuer and broker. | Projected credential or configured SPIRE identity. Authenticates the workload, not a user or agent. | -| Agent-instance JWT-SVID | Harness holds `mainSVID` / `reviewerSVID` outside model context; broker forwards it to AS-1 as `actor_token`. | Agent-instance issuer keeps its signing key. Renewal requires the same registered identity and does not widen authority. | -| Broker session `A42` (state) | Broker owns the workload and host-session binding. | Recovered under workload and continuity checks. Possession of the reference grants nothing. | -| Broker refresh token `RT-A42` | Broker, in shared session storage with the grant's recorded owner and expiry; presented only to AS-1. | Bound to `mecatl`, `A42`, and Alice; does not rotate; hard expiry; revoked when the session closes. | -| Schedule grant | Broker, in shared storage under the schedule; presented only to AS-1, to open each run's session. | Bound to `mecatl`, the schedule, and Alice; horizon; revoked when the schedule is deleted. | -| Broker private key | Broker; signs client assertions for AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | -| ID-JAG | Broker, from issuance to redemption. | Single-use, short-lived. | -| AS-2 access token `V` | Broker presents it to vMCP. | Cached per session, instance, gateway, and user; per-call checks still apply. No refresh token. | -| Consent link (state) | AS-2, external mode. | Until revoked in the gateway UI or by Mecatl. | -| Upstream provider credential | Gateway credential store. | Keyed by (gateway, user, provider); provider-specific renewal stays at the gateway. | - -Keep credential values out of model context, tool arguments, results, transcripts, logs, environment variables, audit records, diagnostics, and errors. Hiding a credential from the model does not protect it from a compromised process that holds it. This protected MCP path does not relocate other harness credentials, such as those used to call an LLM provider. - -## Credential lifetimes and renewal - -An agent instance keeps its identity across runs. These lifetimes are independent. - -| Item | Expiry or loss of authority | Replacement | +**Checks, at AS-1:** + +1. Client authentication as `mecatl`. +2. The request is well formed: the token types above and exactly one `mecatl_session` entry. +3. The subject token resolves to a schedule grant bound to `mecatl`, not revoked, and before its horizon. A session refresh token is refused here. +4. Alice's upstream refresh token passes the [upstream re-check](#upstream-re-check), as in step (7) check 5. +5. The entry's owner equals the schedule grant's recorded owner. +6. Session `A57` has never been opened, or this is a retry: the same schedule grant, client, and session, while `A57` is open. A retry revokes the previous refresh token in the same transaction. + +**Output.** A session refresh token for `A57`, with the same response shape as session open, whose expiry is no later than the schedule's horizon. AS-1 records which schedule grant it came from, so revoking the schedule grant revokes it. From here the run is an ordinary session: instances register to `A57`, and steps (6) to (9) apply unchanged, including the `V` cache key. When the run ends, mecak8s closes `A57`, which revokes its refresh token. + +**Failures.** + +| Check | Response | Retry | | --- | --- | --- | -| AS-1 access token | AS-1 policy; short. Expiry ends API authentication, not the broker session. | The client refreshes with its own refresh token. | -| Client refresh token | AS-1 policy, sign out everywhere, and an upstream revocation or owner-action outcome. | Sign in again. | -| Workload credential | Expired credentials are rejected. | Platform rotation for the same workload. | -| `mainSVID` / `reviewerSVID` | Expiry prevents use as current actor evidence. | The agent-instance issuer renews for the same registered instance; the `ag_` identifier and the ceiling do not change. | -| `RT-A42` | Hard expiry, session close, sign out everywhere, or the upstream re-check. It does not rotate. Losing it closes `A42`. | A new broker session for `hostS42`, opened with a fresh AS-1 access token. | -| ID-JAG | Short-lived and single-use. | A new exchange with `RT-A42` and a current SVID. | -| `V` | Short-lived; every cached use still passes broker checks. | A new ID-JAG. | -| Broker private key | Operator rotation. | New key published through `jwks_uri`. | -| Schedule grant | Its horizon, schedule deletion, sign out everywhere, or the upstream re-check. | The owner renews it: before the horizon, the grant's horizon moves; after a revocation, setup mints a new grant. | -| Consent link | Revocation at AS-2. | The user links again. | -| Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | - -The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. The values of `RT-A42`'s hard expiry and of a schedule grant's horizon are not selected. They bound how long Mecatl acts without a fresh sign-in, and the company IdP administrator should know them. - -Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. AS-1 keeps a record of the closed session so it is never reopened. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. +| 1 | 401 `invalid_client` | No. | +| 2 | 400 `invalid_request` or `invalid_authorization_details` | No; a broker bug. | +| 3, revoked | 400 `invalid_request` with the grant's revocation reason; `schedule_ended` if the schedule was deleted | No; the run does not start. | +| 3, past the horizon | 400 `invalid_request` with `owner_action_required` | No; Alice is notified to renew. | +| 3, a session refresh token | 400 `invalid_request` with `denied` | No; a broker bug. | +| 4 | As step (7) check 5 | As step (7) check 5. | +| 5 | 400 `invalid_request` with `owner_mismatch` | No. | +| 6 | 400 `invalid_authorization_details` with `session_owned` | No. | +| Lost response | None received | Yes: the same parameters with a new client assertion. | + +When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. + +At run time nothing is interactive. Suppose something has lapsed since setup: a revoked link, a revoked provider credential, or authority past its horizon. Then the call fails closed, the run ends with a "needs owner action" outcome, and Alice is notified. The notification links into Mecatl, which starts a fresh fix flow. It does not carry a connect URL, because those can be single-use and short-lived. A provider token that merely expired but can be refreshed is not a lapse: the gateway refreshes it. + +Pre-authorization is renewable without recreating the schedule. Renewing a live schedule grant moves its horizon; after a revocation, Alice sets up a new grant. Either way, renewal reconnects anything that lapsed. Alice is notified before the horizon is reached. + +A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. + +## Lifetimes and renewal + +An agent instance keeps its identity across runs. Each credential's lifetime is independent: + +| Credential | Ends when | Renewed or replaced | By | +| --- | --- | --- | --- | +| AS-1 access token | AS-1 policy; short. Expiry ends API authentication, not the broker session. | Before expiry and before each session open, with the client refresh token. | Client | +| Client refresh token | AS-1 policy, sign out everywhere, or an upstream revocation or owner-action outcome. | Sign in again. | Alice | +| Workload credential | Expiry. | Before it expires. | Kubelet; mecak8s rereads the file | +| Agent-instance SVID | Expiry. | When less than half its lifetime remains, on the next use; the old one stays valid until its `exp`. One renewal per instance at a time; the `ag_` identifier and the ceiling do not change. | Agent-instance issuer | +| Client assertion | After one use. | Every token-endpoint request. | Broker | +| `RT-A42` | Hard expiry, session close, sign out everywhere, or the upstream re-check. Losing it closes `A42`. | Never renewed; a new broker session for `hostS42`, opened with a fresh access token. | mecak8s and broker | +| Schedule grant | Its horizon, schedule deletion, sign out everywhere, or the upstream re-check. | Before the horizon, renewal moves it; after a revocation, setup creates a new grant. Alice is notified before the horizon. | Alice | +| Upstream refresh token | IdP expiry or revocation. | At most once per re-check interval, at an ID-JAG exchange or scheduled-run open; one refresh per user at a time. | AS-1 | +| ID-JAG | Short lifetime and single use. | Never renewed; one per `V`. | Broker | +| `V` | Short lifetime. | A skew margin before `exp` at the next call, or within the same call after a 401; one exchange per cache key at a time. | Broker, through steps (7) and (8) | +| Key sets: AS-1's, AS-2's, the agent trust bundle, the cluster JWKS, and the broker's `jwks_uri` | Operator rotation. | On an unknown `kid`, rate-limited, and periodically. | Each verifier | +| Consent link | Revocation at AS-2. | The user links again. | Alice | +| Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | Gateway, or Alice | + +The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. `RT-A42`'s hard expiry and a schedule grant's horizon bound how long Mecatl acts without a fresh sign-in; the company IdP administrator should know them. + +Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. ## Sign-out and revocation @@ -875,7 +898,15 @@ This works with any IdP that issues refresh tokens and needs no IdP-specific eve **What remains after revocation.** Issued ID-JAGs and AS-2 access tokens cannot be revoked. The broker stops using them at once, but a `V` already issued stays valid at the gateway until it expires, and the vMCP does not check for revocation. Keep both lifetimes short. -**Later.** Two steps would make revocation faster or broader. AS-1 could receive logout and account events from the company IdP: OIDC Back-Channel Logout from Keycloak, or event hooks from Okta. Entra and Google Workspace send neither to third-party applications. And each broker refresh token could be bound to Alice's AS-1 login session, so that signing out at AS-1 itself ends her agents. Shared Signals (SSF, CAEP, RISC) carry the same events but have little IdP support today. +## Sessions at the vMCP + +Each agent instance has its own vMCP session (`Mcp-Session-Id`) for each user it acts for. The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. + +- Sessions are never shared between instances. +- The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and `act.sub`. A token naming another instance in its `act` cannot use the session. +- The run is not part of the session or token. +- The broker stores each session ID under the instance, gateway, and user, so a [shared session](run-owner.md) never hands one user's backend state to another. After a restart it reattaches. When the vMCP reports the session gone, the broker creates a new one. A wedged session can be reset explicitly. +- An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. ## Pauses and restart @@ -891,75 +922,50 @@ Under the [identity and selection continuity rules](agent-definitions.md#identit An operation that may have been dispatched but has no recorded completion has an unknown outcome. Restoring credentials does not make replay safe. Reconciliation requires evidence about the original operation, such as provider status or an idempotency mechanism. -## Scheduled runs - -A scheduled invocation runs without Alice present. Its recorded owner identifies on whose behalf the work was configured; audit records must distinguish scheduled execution from a fresh human request. +## Configuration -A schedule usually outlives the session that created it, and `RT-A42` is revoked when that session closes. A schedule therefore has its own grant from Alice, with an explicit horizon. A scheduled run's owner is checked against the owner recorded with the schedule grant. Deleting the schedule, signing out everywhere, and the upstream re-check revoke it. - -### Schedule setup +None of these values is selected. Each bounds a risk the operator should know: -**Input.** When Alice sets up the schedule, her client refreshes its access token, and the broker exchanges it at AS-1 as at [session open](#3-session-open-and-the-broker-refresh-token), with a `mecatl_schedule` entry instead of a `mecatl_session` one: +- **Lifetimes:** `RT-A42`'s hard expiry, a schedule grant's horizon, the ID-JAG lifetime, and the maximum SVID, workload-credential, and client-assertion lifetimes. +- **Clock skew**, and the margins derived from it: the `V` skew margin and how long used `jti` values are kept past `exp`. +- **Upstream re-check:** the re-check interval, the maximum upstream refresh-token lifetime per IdP when the IdP sends no `refresh_expires_in`, and the lease timeout for a refresh in flight. +- **Key refresh:** the rate limit on refreshing a key set after an unknown `kid`. +- **Closed sessions:** how long closed-session records are kept. Session IDs must be unguessable and never reused, so a record kept for less than forever relies on the ID space alone. +- **IdPs without refresh tokens:** a per-IdP setting that skips the upstream re-check. It is off by default; with it on, a disabled account keeps acting until each refresh token's hard expiry. +- **IdP outage:** with no tolerance, every exchange fails closed once the re-check interval has passed while the company IdP is down. A grace period would keep agents working through a short outage, at the cost of a disabled account acting for that much longer. -```text -authorization_details=[{"type": "mecatl_schedule", "schedule": "sch-nightly-7", - "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] -``` +## Later work -**Checks, at AS-1:** session-open checks 1 to 5 and 7, with the `mecatl_schedule` entry in place of the `mecatl_session` one. Instead of check 6, AS-1 records the schedule's owner at first setup and keeps it after any revocation; a later setup must name the same owner, or is refused with `invalid_authorization_details` and `session_owned`. A setup for a schedule whose grant is live moves that grant's horizon; after a revocation, it creates a new grant. +None of these is selected. Each closes a gap the sections above name. -**Output.** A schedule grant bound to `mecatl`, the schedule, and Alice, with the horizon. The broker stores it under the schedule. Presented at the refresh-token grant, it returns `invalid_grant`, as `RT-A42` does. +**Session-bound access token.** When a session is created, the user's client could silently refresh its AS-1 token with a `mecatl_session` entry naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs `authorization_details` at AS-1's refresh grant, a session-bound token per session in the TUI and Studio, and a two-step session open. It changes nothing downstream of `RT-A42`. -### Scheduled-run session open +**Run grants.** The [authority work](README.md#order-of-work) replaces the session's standing refresh token with a grant for each run, minted when the run starts from a fresh access token that arrives with the user's request. Sessions of users who are not present then carry no authority. Schedules keep their own authority. A compromised mecak8s could still turn any fresh token the user sends into a run of its choosing, one run per token; only a key held by the client and signing each run's start would prevent that. -**Input.** When the schedule fires, mecak8s calls `OpenSession` with the schedule ID in place of an access token, and the broker allocates a session ID for the run. The broker exchanges the stored schedule grant: +**Signed session assertion.** AS-1 could return a signed session assertion JWT instead of a refresh token. It needs less authorization-server work but still requires the AS-1 record for revocation and one-conversion checks, and is an extension to the ID-JAG profile. -```text -POST https://broker.example.com/as/oauth/token -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token= -subject_token_type=urn:ietf:params:oauth:token-type:refresh_token -requested_token_type=urn:ietf:params:oauth:token-type:refresh_token -authorization_details=[{"type": "mecatl_session", "session": "A57", - "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] -client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -client_assertion= -``` +**Stronger agent-instance SVIDs.** Two strengthenings: -**Checks, at AS-1:** +- **Session claim.** The SVID names the session it was issued for, so AS-1 can check that the SVID and the subject token belong to the same session without relying on the broker's pairing. It is still issued on mecak8s's word. +- **Key binding.** The SVID carries a `cnf` key, and the presenter proves possession, so a stolen SVID is useless without the private key. Inside one process, the key proves the process, not the agent. -1. Client authentication as `mecatl`. -2. The request is well formed: the token types above and exactly one `mecatl_session` entry. -3. The subject token resolves to a schedule grant bound to `mecatl`, not revoked, and before its horizon. A session refresh token is refused here. -4. Alice's upstream refresh token passes the [upstream re-check](#upstream-re-check), as in step (7) check 5. -5. The entry's owner equals the schedule grant's recorded owner. -6. Session `A57` has never been opened, or this is a retry: the same schedule grant, client, and session, while `A57` is open. A retry revokes the previous refresh token in the same transaction. +**Per-agent attestation.** Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. -**Output.** A session refresh token for `A57`, with the same response shape as session open, whose expiry is no later than the schedule's horizon. AS-1 records which schedule grant it came from, so revoking the schedule grant revokes it. From here the run is an ordinary session: instances register to `A57`, and steps (6) to (9) apply unchanged, including the `V` cache key. When the run ends, mecak8s closes `A57`, which revokes its refresh token. +**DPoP for `V`.** Binding `V` to a broker key would make a stolen `V` useless at the vMCP. Binding a credential to a worker's key would still not establish which agent inside that worker made a call, or bind the MCP arguments. -**Failures.** +**Faster revocation.** Two steps would make revocation faster or broader. AS-1 could receive logout and account events from the company IdP: OIDC Back-Channel Logout from Keycloak, or event hooks from Okta. Entra and Google Workspace send neither to third-party applications. And each broker refresh token could be bound to Alice's AS-1 login session, so that signing out at AS-1 itself ends her agents. Shared Signals (SSF, CAEP, RISC) carry the same events but have little IdP support today. -| Check | Response | Retry | -| --- | --- | --- | -| 1 | 401 `invalid_client` | No. | -| 2 | 400 `invalid_request` or `invalid_authorization_details` | No; a broker bug. | -| 3, revoked | 400 `invalid_request` with the grant's revocation reason; `schedule_ended` if the schedule was deleted | No; the run does not start. | -| 3, past the horizon | 400 `invalid_request` with `owner_action_required` | No; Alice is notified to renew. | -| 3, a session refresh token | 400 `invalid_request` with `denied` | No; a broker bug. | -| 4 | As step (7) check 5 | As step (7) check 5. | -| 5 | 400 `invalid_request` with `owner_mismatch` | No. | -| 6 | 400 `invalid_authorization_details` with `session_owned` | No. | -| Lost response | None received | Yes: the same parameters with a new client assertion. | +**SPIFFE client authentication.** The broker could authenticate as OAuth client `mecatl` with its SPIFFE workload identity instead of a separate private key. -When Alice sets up the schedule, every connector the schedule may use must be connected: in external mode, the gateway link; in every mode, the provider connection. Anything missing is fixed right then through the existing authorization-pending flow, while she is present. +**Delegation in the token.** A [chained exchange](mcp-authorization.md#chained-exchange) at delegation time would let AS-1 nest the delegating instance as a prior actor in `act`. -At run time nothing is interactive. Suppose something has lapsed since setup: a revoked link, a revoked provider credential, or authority past its horizon. Then the call fails closed, the run ends with a "needs owner action" outcome, and Alice is notified. The notification links into Mecatl, which starts a fresh fix flow. It does not carry a connect URL, because those can be single-use and short-lived. A provider token that merely expired but can be refreshed is not a lapse: the gateway refreshes it. +**The agent ceiling at the gateway.** Until AS-1 carries the instance's effective ceiling in the ID-JAG as scope or RFC 9396 `authorization_details`, and AS-2 carries it into `V`, the broker alone enforces the ceiling; the gateway enforces user and connection. -Pre-authorization is renewable without recreating the schedule. Renewing a live schedule grant moves its horizon; after a revocation, Alice sets up a new grant. Either way, renewal reconnects anything that lapsed. Alice is notified before the horizon is reached. +## Reference -A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. +The following sections are reference material for the steps above. -## Workload authentication +### Workload authentication Workload authentication identifies the Mecatl service contacting the broker. Agent-instance identity distinguishes the agents hosted by that service. The proposed deployment options separate these two roles: @@ -981,23 +987,7 @@ For SPIRE authentication, audit evidence identifies the configured trust domain/ A workload credential alone cannot distinguish `code-reviewer` from `deployer` when both run in the same Mecatl service. A self-declared agent name does not establish that distinction for broker policy. -A future option is to authenticate the broker as OAuth client `mecatl` with its SPIFFE workload identity instead of a separate private key. - -## AS-2 access-token use and actor history - -The broker redeems the ID-JAG as described in [step (8)](#8-as-2-access-token). AS-2 issues an access token naming its native user in `sub`, the acting agent in `act`, the OAuth client `mecatl`, and the vMCP resource in `aud`. The broker presents this token to vMCP; the SVID is not an AS-2 input. - -The ID-JAG `jti`, AS-2 token `(iss, jti)`, and operation ID are separate references. A token can be reused for multiple authorized calls, so its `jti` cannot replace a per-operation reference. Each recipient records only what it verifies or observes. - -`act` identifies the current actor only ([RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1)) and has one level. Actor history stays inside Mecatl: delegation in the harness and broker records, creation ancestry in the SPIFFE path. RFC 8693 treats prior actors in a nested `act` as informational only, so leaving them out removes nothing the gateway enforces. A later [chained exchange](mcp-authorization.md#chained-exchange) would let AS-1 nest the delegating instance as a prior actor. To reach the delegation from a gateway record, follow the propagated operation ID (see [step (9)](#9-vmcp-decision-and-upstream-credential)). Until the agent ceiling is also carried as scope or `authorization_details`, the gateway enforces user and connection, and the broker alone enforces the agent ceiling. - -## Provider-connection selection - -The vMCP gateway resolves the provider credential from the validated user and the route's provider, keyed by (gateway, native user, provider). - -The model cannot select another user's credentials or redirect them by supplying a URL, header, audience, or credential selector. If required identity, link, connection, or authorization evidence is missing, invalid, stale, or revoked, the call fails closed. It does not fall back to a service credential, another user, or a more privileged agent. A denied tool call dispatches no backend operation. Missing credentials or consent surface through the existing `mcp_authorization_pending` flow; how an external gateway reports them to the broker as a structured outcome remains open. - -## Instance identity and creation ancestry +### Instance identity and creation ancestry The agent-instance SVID identifies the acting agent instance through its SPIFFE ID. The proposed identity path records the instance's definition category and name, its agent-instance incarnation, and the ancestry of instances that created it. @@ -1005,7 +995,7 @@ Creation ancestry is distinct from execution delegation. The same instance can p A definition category and name are not enough to establish provenance. Two repositories can both define `code-reviewer`. Before selecting definition-specific policy, the broker needs a verified association with the source that supplied the definition. -### Example instance identities +#### Example instance identities Alice's main instance: @@ -1028,9 +1018,3 @@ spiffe://agents.example.com/mecatl/agent/system/main/inst/inc_45bvtrrrpkekb6336h The reviewer's ID extends its creating instance's path with a child segment. A fork or clear creates a new root instance rather than extending that path. Restoring an existing instance preserves its identity. These IDs describe instance identity and creation ancestry. The initiating user and the current run's delegation context are recorded separately. - -## Sender binding - -`RT-A42`, the ID-JAG, and `V` are all presented only by the broker. `RT-A42` and the ID-JAG are usable only together with the broker's client authentication, so they are sender-constrained through `mecatl`'s private key. The agent SVID is a bearer token too; its holder is limited only by the broker's workload-authentication and presenter checks, and at AS-1 by `mecatl`'s client authentication. `V` remains a bearer token presented to vMCP; its signature protects the claims but does not prove the holder. DPoP-binding `V` to a broker key would close that gap; it is later work. - -Binding a credential to a worker's key would not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. Agent-instance identity and tool-call authorization remain separate checks. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index bdfbf654d7..b1c987accf 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -22,37 +22,9 @@ Apply any issuance limit to the **whole URI**, including child hops, without tru ## AS-1 grant and AS-2 user resolution -The flow has four integration points: AS-1 sign-in, the session-open exchange, the per-call ID-JAG, and AS-2 redemption. The original company token never leaves AS-1: AS-1 is an ordinary OIDC client of the company IdP, and nothing downstream receives or exchanges the company token. +The flow has four integration points: AS-1 sign-in, the session-open exchange, the per-call ID-JAG, and AS-2 redemption. [Credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) gives each one's request, ordered checks, response, and errors; this section adds only what an implementer needs beyond that. The original company token never leaves AS-1: AS-1 is an ordinary OIDC client of the company IdP, keeps only the upstream refresh token for its re-check, and nothing downstream receives or exchanges a company token. -**Client authentication.** The broker is one OAuth client, `mecatl`, registered at both AS-1 and AS-2. On every token-endpoint request it authenticates with `private_key_jwt`: a short-lived assertion with `iss` = `sub` = `mecatl`, `aud` = that server's issuer identifier, and a single-use `jti`. ID-JAG §4.4.1 requires the ID-JAG's `client_id` to match the client redeeming it at AS-2, so both servers must use the same client ID. A `client_secret` is not accepted. In external mode the gateway administrator registers the broker's `jwks_uri` at AS-2 once. - -**Sign-in.** mecak8s's protected-resource metadata names AS-1. The TUI and Studio sign in there with authorization code and PKCE and receive an AS-1 access token (`typ=at+jwt`, `aud` = the Mecatl API) and their own refresh token. mecak8s validates the access token under RFC 9068 and checks session ownership. An `id_token` is not accepted. - -**Session open.** At `OpenSession`, mecak8s passes the user's current AS-1 access token to the broker once, alongside workload authentication. It replaces the harness-reported `human {issuer, subject}`. The broker exchanges it at AS-1 with RFC 8693: `subject_token_type=urn:ietf:params:oauth:token-type:access_token`, `requested_token_type=urn:ietf:params:oauth:token-type:refresh_token`. AS-1 accepts the token only if: - -- AS-1 issued it, its `aud` is the Mecatl API, it is still active, and its `jti` has not opened another session; -- the presenter is the authenticated client `mecatl`; -- the request carries no `scope` parameter (`invalid_scope` otherwise); -- the token was issued after the user's last user-wide revocation, if any; -- the company identity in the token's `sub_id` equals the session owner that mecak8s sends with it; -- the broker session has no recorded owner yet, or its recorded owner is that same identity. AS-1 records the owner at the first open, and session IDs are never reused. - -Client `mecatl` may convert tokens issued to the TUI and Studio only. A session-open request, using the [main flow](credentials-and-lifecycle.md) values: - -```text -POST https://broker.example.com/as/oauth/token -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token= -subject_token_type=urn:ietf:params:oauth:token-type:access_token -requested_token_type=urn:ietf:params:oauth:token-type:refresh_token -authorization_details=[{"type": "mecatl_session", "session": "A42", - "owner": {"iss": "https://login.example.com", "sub": "alice-204"}}] -client_id=mecatl -client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -client_assertion= -``` - -Accepting a token whose `aud` is the Mecatl API from a different client is an explicit, narrow AS-1 policy for client `mecatl`, not a general audience bypass. AS-1 issues a broker refresh token, such as `RT-A42`, which is opaque to the broker. Its meaning lives in an AS-1 record: +**Grant record.** AS-1 keeps one record per broker refresh token or schedule grant. The broker never sees it; the refresh token is opaque to the broker. | Field | Example | Purpose | | --- | --- | --- | @@ -66,51 +38,7 @@ Accepting a token whose `aud` is the Mecatl API from a different client is an ex | Source schedule grant | | For a scheduled run's session only: revoking the schedule grant revokes this refresh token. | | Revocation reason | | Set when revoked: `session_closed`, `signed_out`, `upstream_revoked`, `owner_action_required`, or, for a schedule grant, `schedule_ended`. | -The broker supplies the session reference. The `mecatl_session` type requires AS-1 to echo the entry unchanged (RFC 9396 §7); the broker checks it is identical, revokes the refresh token and fails the open if not, and otherwise stores the refresh token with the echoed owner in its shared session storage. A later open by the same owner with a fresh token replaces the live refresh token in the same transaction. A retry of the same open, with the same `jti`, client, and session, within the source token's `exp`, while the session is open and its refresh token is not revoked, revokes the first refresh token and issues a new one; AS-1 writes the converted-`jti` record, the owner, and the refresh-token record in one transaction. The broker runs at most one open per session at a time, and a retry carries a new client assertion. The refresh token does not rotate; client authentication already binds it to the broker. Session close revokes the token and keeps a closed-session record (ID, owner, closing time) so the session is never reopened; the converted `jti` is kept until the source token's `exp` plus the clock skew. Losing the refresh token for any reason closes the session. The refresh token is not tied to the user's AS-1 login session; [sign-out and revocation](credentials-and-lifecycle.md#sign-out-and-revocation) describes what ends it. - -**Per-call ID-JAG.** When no cached `V` exists for (session, instance, gateway, user), the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) with RFC 8693: the broker refresh token as `subject_token` (`subject_token_type=urn:ietf:params:oauth:token-type:refresh_token`), the agent-instance JWT-SVID as `actor_token` (`actor_token_type=urn:ietf:params:oauth:token-type:jwt`), `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`, and AS-2's issuer as `audience`. AS-1: - -1. checks that `audience` is an allowed AS-2 for `mecatl` and `resource` a vMCP configured for it; -2. resolves the refresh token to its record and checks that it is a `mecatl_session` record bound to `mecatl`, that the session is open, and that the hard expiry has not passed, returning the record's revocation reason otherwise; -3. checks the user's upstream refresh token against its recorded expiry and, if the re-check interval has passed, refreshes it, as [upstream re-check](credentials-and-lifecycle.md#upstream-re-check) describes; -4. validates the SVID against its agent trust bundle, registered subject, and a configured audience accepted by both AS-1 and the broker invocation validator, and checks that the instance is registered to this session; -5. authorizes the combination of client, subject, actor, and target AS-2; -6. issues the ID-JAG. - -The request, for the reviewer instance in the main flow: - -```text -POST https://broker.example.com/as/oauth/token -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token=RT-A42 -subject_token_type=urn:ietf:params:oauth:token-type:refresh_token -actor_token=reviewerSVID -actor_token_type=urn:ietf:params:oauth:token-type:jwt -requested_token_type=urn:ietf:params:oauth:token-type:id-jag -audience=https://gateway.example.com -resource=https://gateway.example.com/mcp -mecatl_operation=op-pr42-001 -client_id=mecatl + client assertion -``` - -Refusals carry a reason code in `error_description`, listed under [error reasons](credentials-and-lifecycle.md#error-reasons). The ID-JAG has header `typ=oauth-id-jag+jwt`, AS-1 `iss`, the AS-1 `sub` from the refresh token record (never from caller input), AS-2 `aud`, `client_id=mecatl`, `jti`, `iat`, a short `exp`, and a single-level `act`. `act.sub` is an opaque, stable, per-instance identifier for the acting instance, such as `ag_7hq2x9`: a plain string with no colon, never a SPIFFE path or definition name, and `act.iss` is AS-1's issuer, so the identifier stays attributable to AS-1 when AS-2 copies `act` into `V`. The agent-instance issuer creates it at registration only if the SPIFFE ID has none yet, unique in both directions and never reassigned; AS-1 looks it up from the validated `actor_token`, and the full SVID stops at AS-1. The ID-JAG also carries the `resource` the broker requested. There is no nested `act`: the delegation that led to the call stays in Mecatl's harness and broker records. Nesting comes later with the [chained exchange](mcp-authorization.md#chained-exchange). `aud_sub` and `email` are deliberately omitted. AS-1 and AS-2 are separate issuers with separate keys, also in embedded mode; embedded mode is outside the cross-domain scope of §9.3, as [MCP gateway modes](mcp-gateway-modes.md#embedded-mode) explains. - -**AS-2 redemption.** The broker redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant, authenticating again as `mecatl`. AS-2 checks the trusted AS-1 issuer and signature, `typ`, that `aud` is its own issuer, lifetime, and that `client_id` matches the authenticated client (§4.4.1), rejects a reused `jti` (RFC 7523 §3), and checks that the requested `resource` is the ID-JAG's `resource` claim. Refusals carry `consent_required` or `invalid_assertion` in `error_description`. A lost redemption response is recovered with a new ID-JAG. It then maps the AS-1 subject to a native user: - -| Mode | Mapping | -| --- | --- | -| Embedded | Users log in to AS-2 through AS-1, so the AS-1 `(iss, sub)` maps directly to the native user. Consent is implicit. | -| External | AS-2 redeems only for an AS-1 `(iss, sub, client_id)` with a consent link to a native user, created once through AS-2's own login. No just-in-time provisioning. | - -AS-2 never resolves a user through `sub_id` (§9.5), email, or display name. Unknown subjects fail closed. A native-user match does not authorize use of a provider connection. - -AS-2 issues `V`: `typ=at+jwt`, `sub` = native user, the ID-JAG's `act` unchanged, `client_id=mecatl`, `aud` = the vMCP resource, and `jti`. It issues no refresh token (§4.4.3); after expiry the broker obtains a new ID-JAG from the broker refresh token. If the refresh token has expired or been revoked, fail closed until a new session open supplies a fresh AS-1 access token. - -**vMCP and the upstream credential.** vMCP validates `V` under RFC 9068 and, if the request uses a `resource` parameter, RFC 8707. It binds its session to the user and `act.sub`. It loads the upstream credential keyed by (gateway, native user, provider). A missing credential answers "needs authorization", which the broker returns as the existing `mcp_authorization_pending` outcome. Consent is checked at redemption, not here. There is one credential record per (user, provider), refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. - -The broker must propagate the operation ID, such as `op-pr42-001`, with each MCP request, and vMCP must log it with its decision as reported, unverified trace context. A cached `V` serves several calls, so its `(iss, jti)` cannot identify one operation; with a single-level `act`, the operation ID is also what leads from a gateway record to the delegation in Mecatl's records. The broker sends them in each MCP request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`, and the operation ID as the request parameter `mecatl_operation` at AS-1. The persistence ordering of these correlation records is open. - -**Open.** How an external gateway reports "needs authorization" or "consent required" to the broker as a structured outcome. No callback or private mapping-response fields are specified. Candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. +**Open.** Whether external gateways adopt the AS-2 reason codes in `error_description`; until one does, the broker treats its `invalid_grant` as `consent_required`. No callback or private mapping-response fields are specified. Whether the "Allow Mecatl" step and the provider's consent screen can be one redirect chain is also open; candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. ## Credential verification @@ -226,7 +154,7 @@ This table is the ledger of ToolHive changes the design needs. None is filed as | Scope or `authorization_details` (RFC 9396) narrowing enforced per tool at the gateway | Scope intersection only | Large | Later | | Cedar at the gateway reading `act` | No | Part of the narrowing work | Later | -The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is under [external consent link](#external-consent-link); its requirement is in [MCP gateway modes](mcp-gateway-modes.md#3-jwt-bearer-grant-at-as-2). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. +The consent link needs the link record, the AS-2 authorization request carrying an AS-1-signed subject hint, the consent screen, revocation, and link-swap defenses. Its creation flow is under [external consent link](#external-consent-link); its requirement is in [MCP gateway modes](mcp-gateway-modes.md#how-as-2-resolves-the-user). Whether "Allow Mecatl" and the provider's consent screen can be one redirect chain is open. Until the narrowing work lands, the broker alone enforces the agent ceiling; the gateway enforces user and connection. diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index e1430b5961..89d90417ee 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -30,7 +30,7 @@ For example, a read-only reviewer spawns a child from a `deployer` definition wh The intersection narrows a child's definition but trusts the registered parent link: a compromised mecak8s that registers a child under a more privileged parent still inflates its effective ceiling (see [registering instances](agent-definitions.md#registering-instances)). Root eligibility bounds that inflation: every parent chain ends at a root the operator allows, so no child can exceed that root's ceiling, which the [definition loader](agent-definitions.md#loading-definitions) set. Task-specific limits, such as keeping the reviewer to PR 42 under `D42`, are dynamic delegation policy and remain deferred. An instance JWT-SVID identifies the agent; it does not prove those limits. -The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `ag_` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1) and [actor history](credentials-and-lifecycle.md#as-2-access-token-use-and-actor-history). +The gateway sees delegation only as identity, not as authority. AS-1 writes only the acting instance into the ID-JAG's `act`, as an opaque `ag_` identifier, and AS-2 copies it into `V`. vMCP binds its session to `act.sub`. The instance that delegated the task does not appear in the token; Mecatl's harness and broker records keep the delegation, and a later chained exchange may add it as a nested `act`. `act` does not carry the agent's ceiling, so the broker alone enforces the effective ceiling, and the gateway enforces only user and connection. The long-term plan is for AS-1 to write the instance's effective ceiling into the ID-JAG as scope or RFC 9396 `authorization_details`, for AS-2 to carry it into `V`, and for the gateway to enforce it as a second check. The ID-JAG and `V` formats reserve that slot; until it is filled, a gateway allow says nothing about the agent ceiling. See the [ID-JAG step](credentials-and-lifecycle.md#7-id-jag-from-as-1). ### Chained exchange @@ -82,7 +82,7 @@ In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execut Both requests also require valid credentials and execution-context checks, and must pass local checks and the downstream AS-2 and vMCP checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. -For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. The broker holds no connection evidence of its own. See [provider-connection selection](credentials-and-lifecycle.md#provider-connection-selection). +For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. The broker holds no connection evidence of its own. See [the vMCP step](credentials-and-lifecycle.md#9-vmcp-decision-and-upstream-credential). ## Authorization, credentials, and dispatch diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 28257ac9b2..78d262a6c9 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -28,7 +28,7 @@ Mecatl uses two authorization servers so that it can work with an MCP gateway it **AS-2** is the gateway's authorization server. The vMCP accepts only access tokens AS-2 issued. On each MCP request, the vMCP validates the token and loads the user's provider credential. -Between them, per call and on a cache miss, the broker runs two steps: +Between them, per call and on a cache miss, the broker runs two steps, detailed in [credentials and lifecycle](credentials-and-lifecycle.md#7-id-jag-from-as-1): 1. It exchanges `RT-A42` at AS-1 (RFC 8693), with the agent-instance JWT-SVID as `actor_token`, and receives an **ID-JAG** ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)) addressed to AS-2. 2. It redeems the ID-JAG at AS-2 with the RFC 7523 JWT-bearer grant and receives the AS-2 access token `V`. @@ -39,121 +39,9 @@ AS-2 receives neither Alice's AS-1 access token, `RT-A42`, nor the SVID. It rece AS-1 and AS-2 have separate issuer identities, keys, and storage, also in embedded mode. ID-JAG §9.3 says the profile is intended for parties in different trust domains, in the OAuth sense, which this design calls [administrative domains](credentials-and-lifecycle.md#7-id-jag-from-as-1); and that an IdP must not issue access tokens in response to an ID-JAG it issued itself in the same domain, because that could broaden authorization. External mode fits that scope. Embedded mode does not: AS-1, AS-2, and the credential store share one administrative domain, and separate keys do not create a boundary. See [embedded mode](#embedded-mode). The agent-instance issuer is a third identity role. RFC 8693 standardizes token-exchange input and output in [§§2.1 and 2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2), token-type identifiers in [§3](https://www.rfc-editor.org/rfc/rfc8693.html#section-3), and `act` in [§4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1). ID-JAG is a profile of [identity chaining across domains, revision 17](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html): it reuses that draft's exchange, RFC 7523 redemption, and no-refresh-token rule, and adds an explicit `typ`, required `client_id`, `jti`, `iat`, and `aud`, client-binding redemption checks, and security rules. Both are Internet-Drafts, not RFCs. -## Following one call - -Alice, AS-1 subject `u-alice-91`, owns harness session `hostS42`. Her session is already open: the broker holds `RT-A42` for broker session `A42`. Her reviewer agent instance asks to read PR 42 in `acme/payments`. The broker checks the session, workload, actor, run owner, route, and, with the authority work, the effective ceiling. With no cached `V` for `(A42, reviewer, gateway, Alice)`, it exchanges `RT-A42` and the reviewer SVID at AS-1 for an ID-JAG, redeems the ID-JAG at AS-2 for `V`, and calls the vMCP. The gateway in this example is external, at `https://gateway.example.com`. - -```mermaid -sequenceDiagram - participant B as Broker (client mecatl) - participant A1 as AS-1 - participant A2 as AS-2 gateway - participant V as vMCP - participant P as GitHub - - Note over B: Holds RT-A42 from session open - B->>B: Broker checks
Session, workload, actor, run owner, route
(ceiling with the authority work) - Note over B,P: Any denial before provider dispatch stops the request - opt No cached V for (A42, reviewer, gateway, Alice) - B->>A1: (1) RFC 8693 + private_key_jwt
subject_token = RT-A42, actor_token = reviewer SVID
requested_token_type = id-jag, audience = AS-2 - A1->>A1: Resolve RT-A42 record: session open, not expired
Re-check company-IdP grant if due
Validate SVID, reviewer registered to A42
Build act - A1-->>B: (2) ID-JAG (sub u-alice-91, act, client_id mecatl) - B->>A2: (3) RFC 7523 assertion = ID-JAG + private_key_jwt - A2->>A2: Check typ, aud, client_id, jti
Resolve native user
External mode: require consent link - A2-->>B: (4) V (sub usr-alice-7c1d, act, aud vMCP) - end - B->>V: (5) tools/call pull_request_read + Bearer V - V->>V: Validate V
Load credential (gateway, usr-alice-7c1d, github) - V->>P: Read PR 42 with Alice's GitHub token - P-->>V: Result - V-->>B: Result -``` - -A cache hit skips steps (1) through (4), not the broker checks or the vMCP's own validation and credential lookup. - -The broker checks run before any exchange. They establish that the session is open and bound to the authenticated workload, that the SVID names an instance registered to `A42`, and that the instance's effective ceiling allows this server and tool. They do not establish that AS-2 can resolve Alice or that a provider connection exists. AS-2 redeems only for a mapped user, and in external mode only for a linked one; the vMCP dispatches only with a stored credential for that user. No denial at the broker, AS-1, AS-2, or vMCP permits a provider call. - -### (1) RFC 8693 token exchange at AS-1 - -The broker asks AS-1 for an ID-JAG. The subject token is the broker refresh token from session open, which ID-JAG §4.3 allows as `subject_token`. The actor token is the registered reviewer JWT-SVID. Neither token authenticates the client; the `private_key_jwt` assertion does. - -```text -POST https://broker.example.com/as/oauth/token -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token=RT-A42 -subject_token_type=urn:ietf:params:oauth:token-type:refresh_token -actor_token= -actor_token_type=urn:ietf:params:oauth:token-type:jwt -requested_token_type=urn:ietf:params:oauth:token-type:id-jag -audience=https://gateway.example.com -client_id=mecatl -client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -client_assertion= -``` - -AS-1 checks: - -- **The client.** The assertion verifies against `mecatl`'s registered key. -- **The subject token.** `RT-A42` resolves to its AS-1 record, is bound to `mecatl`, its session `A42` is open, and its hard expiry has not passed. The record supplies the subject `u-alice-91`; the broker cannot name a different user through this request. AS-1 runs in the same process as the broker core, so this guards against broker bugs, not against a compromised broker. -- **The upstream grant.** If the re-check interval has passed, AS-1 refreshes Alice's company-IdP grant first; see [upstream re-check](credentials-and-lifecycle.md#upstream-re-check). -- **The actor.** AS-1 validates the SVID against the configured agent trust bundle, registered subject, `sub`, `aud`, and `exp`, and checks in the registry that the reviewer is registered to `A42`. -- **The target.** `gateway.example.com` is an allowed AS-2 for this client. - -AS-1 authorizes the client, subject, actor, and target as one combination, not any set of valid signatures. Token validation alone does not prove agent registration, delegation, or run context; those depend on trusted lifecycle and request context. - -AS-1 returns the ID-JAG in the standard token-exchange response ([RFC 8693 §2.2.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-2.2.1)): - -```json -{ - "access_token": "", - "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", - "token_type": "N_A", - "expires_in": 120 -} -``` - -Here `access_token` carries the grant for AS-2; it is not an access token the vMCP accepts. The broker holds it only until redemption. AS-1 never issues an access token for AS-2's audience. - -### (2) The grant - -```json -{"typ": "oauth-id-jag+jwt", "alg": "ES256", "kid": "as1-key-1"} -{ - "iss": "https://broker.example.com/as", - "sub": "u-alice-91", - "aud": "https://gateway.example.com", - "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, - "jti": "jag-pr42-001", - "iat": 1800000001, - "exp": 1800000121 -} -``` - -This fictional decoded ID-JAG is not a usable credential. `iss` is AS-1 and `sub` is Alice's AS-1 subject, from the `RT-A42` record. `aud` is AS-2's issuer identifier. `client_id` names `mecatl`, the client that must redeem it. - -`act` follows [RFC 8693 §4.1](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.1) and names only the acting instance, the reviewer. It carries an opaque, stable, per-instance `ag_` identifier: no SPIFFE path and no definition name, so Mecatl's internal structure does not cross into the gateway's domain. `act.iss` is AS-1's issuer: it names who minted `act`, so the identifier stays attributable to AS-1 after AS-2 copies `act` into `V`. The broker creates the identifier when it registers the instance, as a random value stored under the SPIFFE ID; AS-1 looks it up from the validated reviewer SVID, and the full SVID stops at AS-1. `act` has one level: it does not name main, which delegated the task. That delegation is recorded in Mecatl's harness and broker records. A nested `act` comes later, from a [chained exchange](mcp-authorization.md#chained-exchange) at delegation time. - -`aud_sub` and `email`, both RECOMMENDED by ID-JAG §3.1, are deliberately omitted. AS-1 does not know AS-2's subject, and an email address is not an identity link. The ID-JAG carries no `sub_id` for AS-2 to resolve: a `sub_id` would be AS-1's claim about the company identity, not the IdP's (§9.5). - -The ID-JAG's AS-1 issuer, AS-2 audience, and `typ` distinguish it from `V`. The vMCP validates its configured AS-2 issuer and resource audience and rejects the ID-JAG. - -### (3) JWT-bearer grant at AS-2 - -The broker authenticates to AS-2 as `mecatl` with `private_key_jwt` and redeems the ID-JAG with the [RFC 7523 §2.1 JWT-bearer grant](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1): - -```text -grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer -assertion= -resource=https://gateway.example.com/mcp -client_id=mecatl -client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -client_assertion= -``` - -The `resource` value identifies the vMCP using [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html). AS-2 checks, following ID-JAG §4.4.1: `typ` is `oauth-id-jag+jwt`; the signature verifies against the trusted AS-1 issuer's keys; `aud` is AS-2's own issuer; `client_id` matches the authenticated client `mecatl`; and the lifetime is current. It rejects a reused `jti` ([RFC 7523 §3](https://www.rfc-editor.org/rfc/rfc7523.html#section-3)), as Mecatl policy. It also applies its actor and resource policy. [RFC 7523 §3](https://www.rfc-editor.org/rfc/rfc7523.html#section-3) defines the base JWT-bearer validation. - -AS-2 then resolves the native user. This is where the two modes differ: +## How AS-2 resolves the user + +The per-call flow, with every request, token, and check, is in [credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution), steps (7) to (9). AS-1, the ID-JAG, the redemption, and the vMCP's checks are the same in both modes. They differ at one point: how AS-2, after [validating the ID-JAG](credentials-and-lifecycle.md#8-as-2-access-token), finds the native user. | Mode | How AS-2 resolves and authorizes the user | | --- | --- | @@ -162,31 +50,7 @@ AS-2 then resolves the native user. This is where the two modes differ: The external consent link records (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, grant time, and scope or providers). Alice creates it once per gateway, through AS-2's own login, by approving "Allow Mecatl" on a consent screen AS-2 generates. It is keyed on the AS-1 `sub` in the ID-JAG, never on a `sub_id` (§9.5). It stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Admin pre-consent without an independent witness of the user is rejected for the same reason. The link's creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). The gateway UI can revoke the link. -An unresolved or unlinked subject fails closed. AS-2 must not create an unlinked user as a side effect of lookup. Resolving the user does not establish that a provider connection exists; the vMCP's credential lookup remains a separate check. - -### (4) Access token - -```json -{"typ": "at+jwt", "alg": "RS256", "kid": "gateway-example-key-1"} -{ - "iss": "https://gateway.example.com", - "sub": "usr-alice-7c1d", - "aud": "https://gateway.example.com/mcp", - "client_id": "mecatl", - "act": {"iss": "https://broker.example.com/as", "sub": "ag_7hq2x9"}, - "iat": 1800000005, - "exp": 1800000120, - "jti": "access-pr42-001" -} -``` - -This fictional [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) JWT access token omits its signature. AS-2 issues its own `iss`, puts its native user ID in `sub`, and copies `act` unchanged. Its lifetime does not exceed the ID-JAG's. AS-2 issues no refresh token (ID-JAG §4.4.3); when `V` expires, the broker obtains a new ID-JAG from `RT-A42`. The broker caches `V` per session, instance, gateway, and user, and runs its checks before every use of a cached token. - -### (5) Dispatch - -The broker sends the checked `pull_request_read` request with `V` to the vMCP. The vMCP validates `V` against AS-2's keys, `typ`, audience, and expiry, applies its own operation policy, and loads the upstream credential keyed by **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. GitHub sees Alice's GitHub token, not the ID-JAG, the SVID, or `V`. If no credential is stored, the vMCP answers "needs authorization" and the broker returns the existing `mcp_authorization_pending` outcome. The design does not claim that downstream credentials establish a backend-to-SaaS audit trail. - -AS-1 issuance, AS-2 redemption, and vMCP authorization are separate decisions. Their audit references distinguish ID-JAG `jti` `jag-pr42-001` from access-token `jti` `access-pr42-001`, and neither replaces operation `op-pr42-001`. The broker propagates `op-pr42-001` with the MCP request, in its `_meta`, and the vMCP logs it as reported trace context; this is required for correlation. Audit requirements and evidence limits are described in [Audit](audit.md). +An unresolved or unlinked subject fails closed, with reason `consent_required` in external mode. AS-2 must not create an unlinked user as a side effect of lookup. Resolving the user does not establish that a provider connection exists; the vMCP's credential lookup remains a separate check. ## Gateway administrator configuration @@ -202,13 +66,7 @@ Mecatl's operator configures AS-1 with the `mecatl` client, the accepted agent t ## Sessions at the vMCP -Each agent instance has its own vMCP session (`Mcp-Session-Id`) for each user it acts for. The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. - -- Sessions are never shared between instances. -- The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and `act.sub`. A token naming another instance in its `act` cannot use the session. -- The run is not part of the session or token. The run ID travels in trace headers. -- The broker stores each session ID under the instance, gateway, and user, so a [shared session](run-owner.md) never hands one user's backend state to another. After a restart it reattaches. When the vMCP reports the session gone (idle timeout or eviction), the broker creates a new one. A wedged session can be reset explicitly. -- An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. +Each agent instance has its own vMCP session per user, bound to `V`'s `iss`, `sub`, and `act.sub`; see [sessions at the vMCP](credentials-and-lifecycle.md#sessions-at-the-vmcp). ## Embedded mode @@ -226,7 +84,7 @@ Embedded mode is outside the cross-domain scope that ID-JAG §9.3 states, becaus ## Integration requirements and evidence limits -AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state and hard expiry, re-checks the company-IdP grant when due, checks the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `ag_` identifier. +AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state and hard expiry, re-checks the user's upstream refresh token when due, checks the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `ag_` identifier. AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, reject a reused `jti` (RFC 7523 §3), and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. From 25969be6adcf4a93b6faee1442647ca213be6349 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 13:13:38 +0200 Subject: [PATCH 49/50] docs: restore facts the restructure dropped and fix its seams From the pass-3 review: - Restore the credential record's compare-and-swap refresh, the run ID's path to the vMCP, renewal-failure behavior, Mecatl revoking the consent link, the broker-side SVID limit, the custody disclaimer, RFC 8707 at the vMCP, and the open item on correlation-record order. - vMCP sessions belong to the instance and user, so they survive a replacement broker session and end when the instance ends or the user's grants are revoked. - The delegation report now precedes step (6), which depends on it; the failure matrix and error reasons follow scheduled runs, whose rows they include; step (0) is marked as a startup prerequisite. - The embedded-vs-external resolution table lives only in the gateway chapter; "provider credential" and "broker refresh token" replace the ambiguous "upstream credential" and "session refresh token". - The chapter points to the tests and the threat model; links to run grants and session-bound tokens go to Later work; implementation's section is renamed Grant record. - README: the identity-work checks include the run and its owner, and the post-split attacker limit names new sessions and schedule grants. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 4 +- .../credentials-and-lifecycle.md | 278 +++++++++--------- docs/drafts/agent-identity/implementation.md | 10 +- .../agent-identity/mcp-authorization.md | 2 +- .../agent-identity/mcp-gateway-modes.md | 4 +- docs/drafts/agent-identity/run-owner.md | 2 +- 6 files changed, 156 insertions(+), 144 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 3011b624c6..370ae67cc5 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -87,7 +87,7 @@ Alice asks her main agent to list pull requests and review PR 42. The main agent **Each call.** When the broker has no cached access token for this session, agent, gateway, and user, it sends the refresh token and the acting agent's JWT-SVID to AS-1. AS-1 checks that the agent is registered to the session and that the combination of client, user, agent, and gateway is allowed, then returns an ID-JAG naming Alice and the agent in its `act` claim. The JWT-SVID gives the gateway and the audit trail the acting agent; it asserts that agent rather than authenticating it, because agents share one process. The broker redeems the ID-JAG at AS-2 for the vMCP access token `V`. AS-2 maps AS-1's user to its own account for Alice and never receives her AS-1 token or the agent's JWT-SVID. An external gateway also requires a one-time consent link, which Alice creates at AS-2's own login. The link is her own statement, made at the gateway, that Mecatl may act for her, so a compromised AS-1 cannot use the gateway credentials of a user who never chose Mecatl. -**The checks.** During the identity work, the broker checks the session, the workload, that the acting instance is registered to the session, and the route. Once the [authority work](#order-of-work) is in place, it also uses Cedar, a policy engine, to check the reviewer's effective ceiling: the reviewer's definition ceiling, narrowed by the main agent's. Even then, the design does not evaluate PR-specific bounds or comprehensive enterprise user policy. AS-2 redeems only for a mapped user, and in external mode only for a linked one, and the vMCP dispatches only with Alice's stored GitHub credential, which never reaches the harness or the model. If any check denies the call, no request reaches GitHub. Missing, stale, or revoked identity, consent, connection, or authorization evidence fails closed, with no fallback to a service credential, another user, or a more privileged agent. +**The checks.** During the identity work, the broker checks the session, the workload, that the acting instance is registered to the session, the run and its owner, and the route. Once the [authority work](#order-of-work) is in place, it also uses Cedar, a policy engine, to check the reviewer's effective ceiling: the reviewer's definition ceiling, narrowed by the main agent's. Even then, the design does not evaluate PR-specific bounds or comprehensive enterprise user policy. AS-2 redeems only for a mapped user, and in external mode only for a linked one, and the vMCP dispatches only with Alice's stored GitHub credential, which never reaches the harness or the model. If any check denies the call, no request reaches GitHub. Missing, stale, or revoked identity, consent, connection, or authorization evidence fails closed, with no fallback to a service credential, another user, or a more privileged agent. **The record.** GitHub sees access under Alice's account. The audit trail supplies the rest: which reviewer acted, which agent delegated the work, why the call was permitted, and what result was observed. A missing result is recorded as unknown, not as proof that the operation never happened. Each record is written by the component that made the decision: the broker, AS-1, AS-2, and the vMCP all record through the toolhive-core audit logger, to a store mecak8s cannot write. @@ -100,7 +100,7 @@ The broker starts as modules inside the mecak8s process and later moves, as a wh Some protections hold in both deployments. Executor Pods get no broker or database credentials. A prompt-injected agent never sees or chooses credentials, because harness code attaches them and the model supplies only tool names and arguments. And correct mecak8s code cannot name a user who did not sign in at AS-1. -Against an attacker with code execution in mecak8s, the split is what matters. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is separate, the attacker holds no keys, can act only in open sessions bound to its workload, and every false report it makes must pass the broker's checks and is recorded where it cannot erase it. +Against an attacker with code execution in mecak8s, the split is what matters. While the broker runs inside mecak8s, that attacker holds AS-1's keys and can act as any user connected to the gateway. Once the broker is separate, the attacker holds no keys. It can act only in open sessions bound to its workload, in new sessions opened with fresh tokens from users currently using it, and in sessions opened from live schedule grants until their horizon, and every false report it makes must pass the broker's checks and is recorded where it cannot erase it. The [threat model](threat-model.md) has the full table, the facts mecak8s reports and how the broker checks them, and what the split requires to be real. diff --git a/docs/drafts/agent-identity/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md index 03c481fd6d..58404dd1ea 100644 --- a/docs/drafts/agent-identity/credentials-and-lifecycle.md +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -20,16 +20,16 @@ The human enters the flow once, when Alice signs in through AS-1. AS-1 runs that | **AS-1 access token** | Alice's identity for requests to the Mecatl API. | AS-1 → Alice's client (TUI or Studio) → mecak8s. Passed to the broker once, at session open. | Not stored by the server side. | | **Client refresh token** | The client's ability to renew its own AS-1 access token. | AS-1 → client only. | TUI keyring or Studio sealed cookie; never leaves the client. | | **Workload credential** | Which Mecatl service contacts the broker and agent-instance issuer. | Platform → mecak8s → broker and agent-instance issuer. It identifies neither Alice nor an agent instance. | Projected ServiceAccount token or a configured SPIRE identity. | -| **Agent-instance SVID** | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | Harness, outside model context. Renewal keeps the registered identity and widens nothing. | +| **Agent-instance JWT-SVID** (the agent-instance SVID) | Which registered agent instance is acting. It can span multiple runs. | Agent-instance issuer → harness → broker, which forwards it to AS-1 as `actor_token`. | Harness, outside model context. Renewal keeps the registered identity and widens nothing. | | **Broker refresh token** | Alice's delegated authority for one broker session, usable without her present. | AS-1 → broker → AS-1. Bound to client `mecatl`, the broker session, and the user. Accepted only as the subject of an ID-JAG exchange. | Broker, in shared session storage with the grant's recorded owner and expiry. Does not rotate. | | **Schedule grant** | Alice's delegated authority for one schedule, until its horizon. | AS-1 → broker → AS-1. Bound to client `mecatl`, the schedule, and the user. Accepted only to open a scheduled run's session. | Broker, in shared storage under the schedule. | | **Broker client credential** | That the caller at AS-1 and AS-2 is the broker, OAuth client `mecatl`. | Broker's private key; each request carries a short-lived `private_key_jwt` assertion verified by AS-1 and AS-2. | KMS or HSM where available; public key published through `jwks_uri`. | | **ID-JAG** | Alice and the acting agent, for one redemption at AS-2. | AS-1 → broker → AS-2. Short-lived, single-use. | Broker, from issuance to redemption. | | **AS-2 access token (`V`)** | AS-2's native Alice, the acting agent, and client `mecatl`, for the vMCP resource. | AS-2 → broker → vMCP. | Broker cache, per session, instance, gateway, and user; per-call checks still apply. | -| **Upstream provider credential** | Access to Alice's GitHub account. | GitHub → gateway credential store → GitHub. | Gateway credential store, keyed by (gateway, user, provider); never leaves the gateway. | +| **Provider credential** | Access to Alice's GitHub account. | GitHub → gateway credential store → GitHub. | Gateway credential store, keyed by (gateway, user, provider); never leaves the gateway. | | **Signing keys** | Authority to issue credentials. | AS-1, AS-2, and the agent-instance issuer each keep their own key. | Separate keys are not process isolation when components share a process. | -Two pieces of state matter as much as the credentials: broker session `A42`, whose reference grants nothing on its own, and, in external mode, the consent link at AS-2. Keep credential values out of model context, tool arguments, results, transcripts, logs, environment variables, audit records, diagnostics, and errors. Hiding a credential from the model does not protect it from a compromised process that holds it. This protected MCP path does not relocate other harness credentials, such as those used to call an LLM provider. +Two pieces of state matter as much as the credentials: broker session `A42`, whose reference grants nothing on its own, and, in external mode, the consent link at AS-2, which the gateway UI or Mecatl can revoke. The custody column does not select a persistence implementation or a process-isolation boundary. Keep credential values out of model context, tool arguments, results, transcripts, logs, environment variables, audit records, diagnostics, and errors. Hiding a credential from the model does not protect it from a compromised process that holds it. This protected MCP path does not relocate other harness credentials, such as those used to call an LLM provider. ### Example nomenclature @@ -42,12 +42,10 @@ Two pieces of state matter as much as the credentials: broker session `A42`, who | Reference | `op-list-prs-001`, `op-pr42-001` | Correlation IDs for two tool operations. They identify operations, not people, and authorize nothing. | | Credential | `mainSVID`, `reviewerSVID` | Registered agent-instance credentials. | | Credential | `RT-A42` | The broker refresh token for session `A42`. | -| State | Grant | AS-1's record of authority Alice delegated: the record behind a broker refresh token, or a schedule grant. Each grant records its owner, the company identity, and, once revoked, the reason. | +| State | Grant | AS-1's record of authority Alice delegated. The broker holds an opaque token for each: a broker refresh token or a schedule grant. Each grant records its owner, the company identity, and, once revoked, the reason. | | Credential | `J-main`, `J-reviewer` | ID-JAGs naming Alice and each acting agent. | | Credential | `V-main`, `V-reviewer` | AS-2 access tokens naming native Alice and each acting agent. | -No credential value belongs in the model's context. - ## Interactive execution Alice owns harness session `hostS42`. She asks Mecatl to list pull requests in `acme/payments` and review PR 42. Main run `R41` calls `pull_request_list`. After the list returns, main delegates the review through `D42` to reviewer run `R42`, which calls `pull_request_read`. Tool names and arguments are illustrative. The reviewer uses definition `global/code-reviewer`; main both creates it and delegates the task to it, but creation and delegation remain separate facts. In the diagram, `main` and `reviewer` abbreviate the SPIFFE subjects in [example instance identities](#example-instance-identities). @@ -137,20 +135,25 @@ sequenceDiagram The diagram follows the successful path. Other outcomes: -- **No upstream credential.** vMCP finds no stored GitHub credential for Alice and answers "needs authorization". The broker returns the existing `mcp_authorization_pending` outcome; the TUI or Studio opens the authorization URL and rechecks, and the parked run resumes. +- **No provider credential.** vMCP finds no stored GitHub credential for Alice and answers "needs authorization". The broker returns the existing `mcp_authorization_pending` outcome; the TUI or Studio opens the authorization URL and rechecks, and the parked run resumes. - **No consent link (external mode).** AS-2 refuses to redeem the ID-JAG. The broker returns the same pending outcome, and the URL first leads through AS-2's "Allow Mecatl" step. - **Denial or failure.** Any denial before dispatch prevents the provider call. Retrying requires authorization again. - **Unknown outcome.** If dispatch may have happened but no completion was observed, the outcome is unknown and is not replayed automatically. Every later tool call repeats the broker checks, including calls served from the `V` cache. The [failure matrix](#failure-matrix) lists the failures each step returns, and [lifetimes and renewal](#lifetimes-and-renewal) when each credential is replaced. -Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §8; broker calls are gRPC and use gRPC status codes. Where one error code covers several causes, a reason code tells them apart; see [error reasons](#error-reasons). +Each step below follows the same shape: what arrives, the checks in the order they run, what is issued, how each failure is reported and whether a retry can succeed, why the step exists, what it does not prove, and what is recorded. The tests for every step are in the [implementation companion](implementation.md#credential-verification), and what a compromised component can still do is in the [threat model](threat-model.md). Checks run in the listed order and stop at the first failure. Error responses never echo a token, an assertion, or a claim value from one. Token-endpoint errors follow RFC 6749 §5.2, RFC 8693 §2.2.2, RFC 7523 §3.1, and RFC 9396 §8; broker calls are gRPC and use gRPC status codes. Where one error code covers several causes, a reason code tells them apart; see [error reasons](#error-reasons). ### (0) Broker startup +This step runs once when the broker starts, before any request; it is a prerequisite, not part of a call. + **Input.** The operator's configuration names the definition sources for global definitions. -**Checks, at the definition loader:** each source is configured and reachable, and each definition parses and passes the [definition checks](agent-definitions.md#definition-provenance). +**Checks, at the definition loader:** + +1. Each source is configured and reachable. +2. Each definition parses and passes the [definition checks](agent-definitions.md#definition-provenance). **Output.** A record per definition, with its source, revision, and whether it may be a session's root; with the authority work, also its ceiling. Repository definitions are not loaded; mecak8s names them by reference at registration. @@ -158,6 +161,8 @@ Each step below follows the same shape: what arrives, the checks in the order th **Why.** An agent name is only a claim. Loading definitions inside the broker means a definition name in an audit record refers to a known source and revision, and mecak8s cannot supply fake content for a global definition. +**Does not prove.** Anything about repository definitions, which the broker accepts only as mecak8s reports. + **Evidence.** The loader records each load with source and revision ([audit](audit.md#auditevent-profile)). ### (1) AS-1 access token @@ -418,12 +423,43 @@ The private key never leaves the broker and can live in a KMS or HSM. In externa One client ID, `mecatl`, is used at both servers, because the ID-JAG's `client_id` must match the client that redeems it at AS-2. -This makes `RT-A42`, the schedule grant, and the ID-JAG usable only with the broker's key. The agent-instance SVID is a bearer token; at AS-1 it is usable only alongside `mecatl`'s client authentication. `V` stays a bearer token at the vMCP: its signature protects its claims but does not prove who holds it. +This makes `RT-A42`, the schedule grant, and the ID-JAG usable only with the broker's key. The agent-instance SVID is a bearer token; at AS-1 it is usable only alongside `mecatl`'s client authentication. At the broker, the SVID's holder is limited only by workload authentication and the presenter checks. `V` stays a bearer token at the vMCP: its signature protects its claims but does not prove who holds it. **Does not prove.** Which agent asked: `mecatl` is the broker's registration, not an actor. Nor does it stop a thief with a stolen AS-1 access token from presenting it to the Mecatl API, where mecak8s converts it at session open, once per token. **Evidence.** AS-1 and AS-2 record the authenticated client and the assertion's `kid` with each token-endpoint decision. +### Delegation report + +**Input.** When main delegates the review, mecak8s calls `ReportDelegation` with the workload credential, session `A42`, delegation `D42`, the delegating run `R41` and instance `mainRef`, the receiving run `R42` and instance `reviewerRef`, and the receiving run's owner. + +**Checks, at the broker:** + +1. The workload is authenticated ([step (2)](#2-workload-credential)). +2. `A42` is open and bound to that workload. +3. `R41` belongs to `A42` and is bound to `mainRef`. If `R41` has no run record yet, the broker records it as a root run of `mainRef` with the reported owner, which needs `mainRef` to be a root instance, as [step (6)](#6-per-operation-authorization) check 4 would. If `R41` was itself delegated, its delegation was reported in `A42` with `R41` as the receiving run. +4. `reviewerRef` is registered to `A42`. +5. The reported owner of `R42` equals the owner of `R41`. +6. `D42` has not been reported in `A42`, or was reported in it with exactly the same content. Delegation records are kept per session. + +**Output.** A recorded delegation and a run record for `R42`, bound to `reviewerRef` and its owner, which lets `R42` pass [check 4](#6-per-operation-authorization) of every later call. + +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| 1 | Step (2)'s codes | Only an expired workload credential. | +| 2 | The session's closing reason with its status from [error reasons](#error-reasons), or `PERMISSION_DENIED` | After a new session opens, mecak8s reports the delegation again. | +| 3–5 | `PERMISSION_DENIED` | No. Until a consistent report arrives, `R42` cannot call tools. | +| 6, different content | `ALREADY_EXISTS` | No. | +| 6, same content | Success | A lost response is retried safely. | + +**Why.** The broker sees delegations only through these reports. Checking each one when it arrives keeps a later call from naming a run the broker never heard of, or a run whose owner changed on the way down. + +**Does not prove.** That main actually delegated to the reviewer: it is a mecak8s report, checked for consistency. + +**Evidence.** The broker records each accepted or refused report as a [delegation lineage event](audit.md#lineage), marked as reported by mecak8s. + ### (6) Per-operation authorization Local permissions and hooks run before each call. @@ -449,7 +485,7 @@ Local permissions and hooks run before each call. The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hit skips steps (7) and (8), never these checks. Concurrent misses for one key share a single exchange. -**Output.** An allowed operation, dispatched in [step (9)](#9-vmcp-decision-and-upstream-credential). +**Output.** An allowed operation, dispatched in [step (9)](#9-vmcp-decision-and-provider-credential). **Failures.** @@ -463,41 +499,10 @@ The broker then looks up `V` under `(A42, reviewer, gateway, Alice)`. A cache hi **Why.** User, run, and delegation are not inputs to the ceiling check; the reviewer ceiling gives the same result for PR 43. See [two requests, two authorization decisions](mcp-authorization.md#two-requests-two-authorization-decisions). The broker does not check provider connections. It authorizes the session, workload, actor, run, run owner, route, and, with the authority work, the ceiling. AS-2 authorizes the user: the mapping, and in external mode the consent link. vMCP authorizes the connection and dispatches. The provider enforces its own permissions on the request. -**Does not prove.** That mecak8s reported the right run or run owner: both are mecak8s reports, checked for consistency with the grant and the reported delegations. Neither dispatch nor success. +**Does not prove.** That mecak8s reported the right run or run owner: both are mecak8s reports, checked for consistency with the grant and the reported delegations. That AS-2 can resolve Alice or that a provider connection exists: later steps check those. Neither dispatch nor success. **Evidence.** `op-pr42-001` passed the session, workload, actor, run, and run-owner checks, and, once the authority work is in place, the effective-ceiling check. The [audit chapter](audit.md#auditevent-profile) owns the full evidence shape. -### Delegation report - -**Input.** When main delegates the review, mecak8s calls `ReportDelegation` with the workload credential, session `A42`, delegation `D42`, the delegating run `R41` and instance `mainRef`, the receiving run `R42` and instance `reviewerRef`, and the receiving run's owner. - -**Checks, at the broker:** - -1. The workload is authenticated ([step (2)](#2-workload-credential)). -2. `A42` is open and bound to that workload. -3. `R41` belongs to `A42` and is bound to `mainRef`. If `R41` has no run record yet, the broker records it as a root run of `mainRef` with the reported owner, which needs `mainRef` to be a root instance, as [step (6)](#6-per-operation-authorization) check 4 would. If `R41` was itself delegated, its delegation was reported in `A42` with `R41` as the receiving run. -4. `reviewerRef` is registered to `A42`. -5. The reported owner of `R42` equals the owner of `R41`. -6. `D42` has not been reported in `A42`, or was reported in it with exactly the same content. Delegation records are kept per session. - -**Output.** A recorded delegation and a run record for `R42`, bound to `reviewerRef` and its owner, which lets `R42` pass [check 4](#6-per-operation-authorization) of every later call. - -**Failures.** - -| Check | Response | Retry | -| --- | --- | --- | -| 1 | Step (2)'s codes | Only an expired workload credential. | -| 2 | The session's closing reason with its status from [error reasons](#error-reasons), or `PERMISSION_DENIED` | After a new session opens, mecak8s reports the delegation again. | -| 3–5 | `PERMISSION_DENIED` | No. Until a consistent report arrives, `R42` cannot call tools. | -| 6, different content | `ALREADY_EXISTS` | No. | -| 6, same content | Success | A lost response is retried safely. | - -**Why.** The broker sees delegations only through these reports. Checking each one when it arrives keeps a later call from naming a run the broker never heard of, or a run whose owner changed on the way down. - -**Does not prove.** That main actually delegated to the reviewer: it is a mecak8s report, checked for consistency. - -**Evidence.** The broker records each accepted or refused report as a [delegation lineage event](audit.md#lineage), marked as reported by mecak8s. - ### (7) ID-JAG from AS-1 On a cache miss, the broker asks AS-1 for an ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)). @@ -613,7 +618,7 @@ client_assertion= 4. `client_id` matches the authenticated client (ID-JAG §4.4.1). 5. `jti` has not been redeemed (RFC 7523 §3). AS-2 keeps used identifiers until `exp` plus the clock skew. 6. The requested `resource` is the ID-JAG's `resource` claim, and this client and actor may reach it. -7. AS-2 resolves the native user as the table below shows, never through `sub_id`, email, or display name. A `sub_id` in the ID-JAG would be AS-1's claim about the company identity, not the IdP's (§9.5). The native identifier need not equal either upstream subject. +7. AS-2 resolves the native user: directly in embedded mode, and only through a consent link in external mode, as [gateway modes](mcp-gateway-modes.md#how-as-2-resolves-the-user) describes. Never through `sub_id`, email, or display name: a `sub_id` in the ID-JAG would be AS-1's claim about the company identity, not the IdP's (§9.5). The native identifier need not equal either upstream subject. | Mode | How AS-2 resolves and authorizes the user | | --- | --- | @@ -642,7 +647,7 @@ client_assertion= } ``` -`V`'s lifetime does not exceed the ID-JAG's. How each mode resolves the user is the main difference between the [gateway modes](mcp-gateway-modes.md#how-as-2-resolves-the-user). +`V`'s lifetime does not exceed the ID-JAG's. **Failures.** @@ -662,22 +667,21 @@ client_assertion= - On a 401 `invalid_token` from the vMCP, the broker drops the cached `V`, but only if it is still the one that failed, and repeats steps (7) and (8) once within the same call. - A cache hit still needs a currently valid SVID for the same instance. -- vMCP binds its session (`Mcp-Session-Id`) to the user and `act.sub`, and the broker stores each session ID under the instance, gateway, and user, so each agent instance has its own vMCP session per user. **Evidence.** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. -### (9) vMCP decision and upstream credential +### (9) vMCP decision and provider credential -**Input.** `tools/call` with `Authorization: Bearer `, the vMCP session ID, and the operation and attempt IDs in the request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`. The attempt number increases with each dispatch attempt of the same operation. +**Input.** `tools/call` with `Authorization: Bearer `, the vMCP session ID, and the operation and attempt IDs in the request's `_meta`, under the proposed keys `dev.mecatl/operation` and `dev.mecatl/attempt`. The attempt number increases with each dispatch attempt of the same operation. The run ID travels in trace headers. **Checks, at the vMCP:** -1. `V`'s `typ` is `at+jwt` or `application/at+jwt`, `iss` is AS-2, the signature verifies, `aud` is this vMCP, and `exp` has not passed (RFC 9068 §4). +1. `V`'s `typ` is `at+jwt` or `application/at+jwt`, `iss` is AS-2, the signature verifies, `aud` is this vMCP, and `exp` has not passed (RFC 9068 §4); a request that names a `resource` is checked against it (RFC 8707). 2. The session ID, if present, is bound to the same `iss`, `sub`, and `act.sub`. 3. Its own operation policy allows the tool. -4. An upstream credential exists under **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. +4. A provider credential exists under **(gateway, native user, provider)**: here `(gateway.example.com, usr-alice-7c1d, github)`. -**Output.** The GitHub call made with Alice's GitHub token, and its result. One credential record exists per user and provider. A delegated `V` comes from no interactive gateway login, so every gateway, including a standalone one, must find the credential from the user alone. +**Output.** The GitHub call made with Alice's GitHub token, and its result. One credential record exists per user and provider, refreshed as a single row with compare-and-swap; the enterprise connector gateway already stores credentials this way. A delegated `V` comes from no interactive gateway login, so every gateway, including a standalone one, must find the credential from the user alone. **Failures.** @@ -688,78 +692,13 @@ client_assertion= | 3 | Tool error | Returned as a denial; not retried. | | 4 | "Needs authorization" | The existing `mcp_authorization_pending` outcome; after Alice connects GitHub, a later call passes every check again. | -**Why.** The vMCP is the only component that holds the upstream credential, so it makes the last check before the provider call and looks the credential up from the validated user alone. The model cannot select another user's credential by supplying a URL, header, audience, or credential selector. If identity, link, connection, or authorization evidence is missing, stale, or revoked, the call fails closed; it never falls back to a service credential, another user, or a more privileged agent. +**Why.** The vMCP is the only component that holds the provider credential, so it makes the last check before the provider call and looks the credential up from the validated user alone. The model cannot select another user's credential by supplying a URL, header, audience, or credential selector. If identity, link, connection, or authorization evidence is missing, stale, or revoked, the call fails closed; it never falls back to a service credential, another user, or a more privileged agent. **Does not prove.** Provider-side effects: neither a successful MCP response nor an authorization record proves them. GitHub sees only Alice's GitHub token, not the ID-JAG, the SVID, or `V`, so the design claims no audit trail from the gateway to the provider. **Evidence.** vMCP records its decision with the access-token reference `(iss, jti)`, user, actor, tool, outcome, and the reported operation and attempt. -**Correlation.** A cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. The operation ID in `_meta`, logged by vMCP as reported, unverified trace context, ties a gateway record to a single call. Because `act` names only the reviewer, it is also what lets an investigator reach delegation `D42` from a gateway log. - -### Failure matrix - -The failures each step can return: where each is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. Configuration faults, such as `invalid_client`, `unauthorized_client`, and `invalid_target`, fail every affected call until an operator fixes them, and are not listed again. - -| Failure | Detected at | Response | User-visible outcome | Revokes | -| --- | --- | --- | --- | --- | -| Expired or invalid AS-1 access token | mecak8s | 401 `invalid_token` | Client refreshes silently once; signs in again if that fails | Nothing | -| Not the owner of `hostS42` | mecak8s | 403 `not_session_owner` | Request refused | Nothing | -| Workload credential expired | Broker, agent-instance issuer | `UNAUTHENTICATED` | None: mecak8s rereads the rotated token | Nothing | -| Workload not allowed | Broker, agent-instance issuer | `PERMISSION_DENIED` | Every request fails; an operator fault | Nothing | -| Owner mismatch at open | AS-1 | `invalid_request` with `owner_mismatch`, recorded | Session open fails | Nothing | -| Session owned by another user, or closed | AS-1 | `invalid_authorization_details` with `session_owned`, recorded | Session open fails | Nothing | -| Access token already opened a session | AS-1 | `invalid_request` with `token_used` | Client refreshes and retries once | Nothing | -| Access token issued before a user-wide revocation | AS-1 | `invalid_request` with that revocation's reason | Alice signs in again | Nothing more | -| Echoed entry differs | Broker | Open fails | Session open fails | The new `RT-A42`, closing `A42` | -| Registration into a closed or foreign session | Agent-instance issuer | The closing reason's status, or `PERMISSION_DENIED` | Agent cannot start until a new session opens | Nothing | -| Re-registration under a different owner | Agent-instance issuer | `PERMISSION_DENIED` | Agent cannot start | Nothing | -| Root registration of a non-root-eligible definition | Agent-instance issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | -| Definition name or depth beyond the caps | Agent-instance issuer | `INVALID_ARGUMENT` | Child agent cannot start | Nothing | -| SVID expired | Broker, AS-1 | `UNAUTHENTICATED`, `invalid_request` with `svid_expired` | None: renewed and retried once | Nothing | -| SVID invalid or instance not registered to the session | Broker, AS-1 | `PERMISSION_DENIED`, `invalid_request` with `denied` | Call denied | Nothing | -| Run bound to another instance, a root run from a child instance, or delegation not reported | Broker | `PERMISSION_DENIED` | Call denied | Nothing | -| Run owner differs from the grant's recorded owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | -| No route, or ceiling denies the tool | Broker | `PERMISSION_DENIED` | Call denied | Nothing | -| Inconsistent delegation report | Broker | `PERMISSION_DENIED`, `ALREADY_EXISTS` | Delegated task cannot call tools | Nothing | -| Session closed or past its hard expiry | Broker, AS-1 | `invalid_request` with `session_closed`; the broker returns its status from error reasons | A new session opens on Alice's next request | Already revoked | -| Grant revoked by sign out everywhere | Broker, AS-1 | `invalid_request` with `signed_out`; the broker returns its status from error reasons | Run stops; Alice is signed out | Already revoked | -| Schedule grant presented to an ID-JAG exchange, or a session refresh token used to open a scheduled run | AS-1 | `invalid_request` with `denied` | Call or run fails; a broker bug | Nothing | -| Company IdP refuses the refresh | AS-1 | `invalid_request` with `upstream_revoked` | Every agent and schedule for Alice stops; she is signed out | All of Alice's grants and client refresh tokens | -| Upstream refresh token past its recorded expiry, missing, or refused after an ambiguous refresh | AS-1 | `invalid_request` with `owner_action_required` | "Needs owner action": Alice signs in again | All of Alice's grants and client refresh tokens | -| Company IdP unreachable or 5xx, or the refresh lease timed out | AS-1 | 503 | This call fails as retryable; later calls retry | Nothing | -| AS-1 refuses the combination | AS-1 | `invalid_request` with `denied` | Call denied | Nothing | -| No consent link (external) | AS-2 | `invalid_grant` with `consent_required` | `mcp_authorization_pending` through "Allow Mecatl" | Nothing | -| ID-JAG replayed | AS-2 | `invalid_grant` with `invalid_assertion` | Call fails; a broker bug or a stolen ID-JAG | Nothing | -| `V` rejected by vMCP | vMCP | 401 `invalid_token` | None: one retry with a new `V` | Cached `V` dropped | -| vMCP session rejected | vMCP | 404 | None: one retry with a new session | Stored session ID dropped | -| vMCP policy denies the tool | vMCP | Tool error | Call denied | Nothing | -| No upstream credential | vMCP | "Needs authorization" | `mcp_authorization_pending`; Alice connects GitHub | Nothing | -| Dispatch happened, no completion observed | Broker | Unknown outcome | Reported as unknown; not replayed | Nothing | - -### Error reasons - -A scheduled run has no client to show anything to: for every revocation reason except `schedule_ended`, it ends with "needs owner action" and Alice is notified. - -RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 and AS-2 therefore start `error_description` with a reason code, either alone or followed by a colon and text: `error_description=session_closed`, or `error_description=session_closed: session ended`. RFC 6749 §5.2 limits `error_description` to the characters `%x20-21 / %x23-5B / %x5D-7E`, so the text never contains `"` or `\`; reason codes are lowercase letters and underscores. The broker carries the reason in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome. - -| Reason | Returned by | Broker gRPC status | mecak8s outcome | -| --- | --- | --- | --- | -| `subject_invalid` | AS-1, session open | `UNAUTHENTICATED` | Ask the client to refresh its access token and retry once. | -| `token_used` | AS-1, session open | `UNAUTHENTICATED` | The same. | -| `owner_mismatch` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug. | -| `session_owned` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug or a closed session ID reused. | -| `session_closed` | AS-1, broker | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. | -| `signed_out` | AS-1, broker | `FAILED_PRECONDITION` | Stop the run; the client shows the user as signed out. At session open: 401, and the client signs in again. | -| `upstream_revoked` | AS-1, broker | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. At session open: 401, and the client signs in again. | -| `schedule_ended` | AS-1, broker | `FAILED_PRECONDITION` | The schedule was deleted; its run does not start. | -| `owner_action_required` | AS-1, broker | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. | -| `superseded` | AS-1 | `ABORTED` | A later open replaced the session's refresh token; the broker rereads the stored one and retries once. | -| `svid_expired` | AS-1, broker | `UNAUTHENTICATED` | Renew the SVID and retry once; not shown to the user. | -| `denied` | AS-1, broker | `PERMISSION_DENIED` | Deny the call. | -| `consent_required` | AS-2 | `FAILED_PRECONDITION` | The existing `mcp_authorization_pending` outcome. | -| `invalid_assertion` | AS-2 | `INTERNAL` | Fail the call; a broker bug. | -| HTTP 503 | AS-1, AS-2 | `UNAVAILABLE` | Fail the call as a retryable infrastructure error, not a denial. | -| `invalid_client`, `unauthorized_client`, `invalid_target`, or no reason | AS-1, AS-2 | `INTERNAL` | Fail the call; a configuration fault or broker bug. An external AS-2 that sends no reason on `invalid_grant` is treated as `consent_required`. | +**Correlation.** A cached `V-reviewer` serves several calls, so its `(iss, jti)` leads back through AS-2's redemption record and the ID-JAG `jti` to AS-1's issuance record, but not to one operation. The operation ID in `_meta`, logged by vMCP as reported, unverified trace context, ties a gateway record to a single call. Because `act` names only the reviewer, it is also what lets an investigator reach delegation `D42` from a gateway log. Each recipient records only what it verifies or observes. ## Scheduled runs @@ -780,6 +719,12 @@ authorization_details=[{"type": "mecatl_schedule", "schedule": "sch-nightly-7", **Output.** A schedule grant bound to `mecatl`, the schedule, and Alice, with the horizon. The broker stores it under the schedule. Presented at the refresh-token grant, it returns `invalid_grant`, as `RT-A42` does. +**Failures.** As session open's, with `session_owned` for a schedule recorded for another owner. + +**Why.** Setup is the last moment Alice is present, so it is when Mecatl obtains her authority for runs she will not watch. + +**Evidence.** AS-1 records the conversion, as at session open, naming the schedule instead of a session. + ### Scheduled-run session open **Input.** When the schedule fires, mecak8s calls `OpenSession` with the schedule ID in place of an access token, and the broker allocates a session ID for the run. The broker exchanges the stored schedule grant: @@ -800,12 +745,12 @@ client_assertion= 1. Client authentication as `mecatl`. 2. The request is well formed: the token types above and exactly one `mecatl_session` entry. -3. The subject token resolves to a schedule grant bound to `mecatl`, not revoked, and before its horizon. A session refresh token is refused here. +3. The subject token resolves to a schedule grant bound to `mecatl`, not revoked, and before its horizon. A broker refresh token for a session is refused here. 4. Alice's upstream refresh token passes the [upstream re-check](#upstream-re-check), as in step (7) check 5. 5. The entry's owner equals the schedule grant's recorded owner. 6. Session `A57` has never been opened, or this is a retry: the same schedule grant, client, and session, while `A57` is open. A retry revokes the previous refresh token in the same transaction. -**Output.** A session refresh token for `A57`, with the same response shape as session open, whose expiry is no later than the schedule's horizon. AS-1 records which schedule grant it came from, so revoking the schedule grant revokes it. From here the run is an ordinary session: instances register to `A57`, and steps (6) to (9) apply unchanged, including the `V` cache key. When the run ends, mecak8s closes `A57`, which revokes its refresh token. +**Output.** A broker refresh token for `A57`, with the same response shape as session open, whose expiry is no later than the schedule's horizon. AS-1 records which schedule grant it came from, so revoking the schedule grant revokes it. From here the run is an ordinary session: instances register to `A57`, and steps (6) to (9) apply unchanged, including the `V` cache key. When the run ends, mecak8s closes `A57`, which revokes its refresh token. **Failures.** @@ -815,7 +760,7 @@ client_assertion= | 2 | 400 `invalid_request` or `invalid_authorization_details` | No; a broker bug. | | 3, revoked | 400 `invalid_request` with the grant's revocation reason; `schedule_ended` if the schedule was deleted | No; the run does not start. | | 3, past the horizon | 400 `invalid_request` with `owner_action_required` | No; Alice is notified to renew. | -| 3, a session refresh token | 400 `invalid_request` with `denied` | No; a broker bug. | +| 3, a broker refresh token | 400 `invalid_request` with `denied` | No; a broker bug. | | 4 | As step (7) check 5 | As step (7) check 5. | | 5 | 400 `invalid_request` with `owner_mismatch` | No. | | 6 | 400 `invalid_authorization_details` with `session_owned` | No. | @@ -829,6 +774,71 @@ Pre-authorization is renewable without recreating the schedule. Renewing a live A saved owner or an available provider connection does not authorize an MCP operation on its own. Each call still passes the broker checks, AS-2 redemption, and vMCP's credential lookup. +## Failure matrix + +The failures each step can return: where each is detected, what the user sees, and what it revokes. "Fails closed" means no provider call is made. Configuration faults, such as `invalid_client`, `unauthorized_client`, and `invalid_target`, fail every affected call until an operator fixes them, and are not listed again. + +| Failure | Detected at | Response | User-visible outcome | Revokes | +| --- | --- | --- | --- | --- | +| Expired or invalid AS-1 access token | mecak8s | 401 `invalid_token` | Client refreshes silently once; signs in again if that fails | Nothing | +| Not the owner of `hostS42` | mecak8s | 403 `not_session_owner` | Request refused | Nothing | +| Workload credential expired | Broker, agent-instance issuer | `UNAUTHENTICATED` | None: mecak8s rereads the rotated token | Nothing | +| Workload not allowed | Broker, agent-instance issuer | `PERMISSION_DENIED` | Every request fails; an operator fault | Nothing | +| Owner mismatch at open | AS-1 | `invalid_request` with `owner_mismatch`, recorded | Session open fails | Nothing | +| Session owned by another user, or closed | AS-1 | `invalid_authorization_details` with `session_owned`, recorded | Session open fails | Nothing | +| Access token already opened a session | AS-1 | `invalid_request` with `token_used` | Client refreshes and retries once | Nothing | +| Access token issued before a user-wide revocation | AS-1 | `invalid_request` with that revocation's reason | Alice signs in again | Nothing more | +| Echoed entry differs | Broker | Open fails | Session open fails | The new `RT-A42`, closing `A42` | +| Registration into a closed or foreign session | Agent-instance issuer | The closing reason's status, or `PERMISSION_DENIED` | Agent cannot start until a new session opens | Nothing | +| Re-registration under a different owner | Agent-instance issuer | `PERMISSION_DENIED` | Agent cannot start | Nothing | +| Root registration of a non-root-eligible definition | Agent-instance issuer | `PERMISSION_DENIED` | Session cannot start with that definition | Nothing | +| Definition name or depth beyond the caps | Agent-instance issuer | `INVALID_ARGUMENT` | Child agent cannot start | Nothing | +| SVID expired | Broker, AS-1 | `UNAUTHENTICATED`, `invalid_request` with `svid_expired` | None: renewed and retried once | Nothing | +| SVID invalid or instance not registered to the session | Broker, AS-1 | `PERMISSION_DENIED`, `invalid_request` with `denied` | Call denied | Nothing | +| Run bound to another instance, a root run from a child instance, or delegation not reported | Broker | `PERMISSION_DENIED` | Call denied | Nothing | +| Run owner differs from the grant's recorded owner | Broker | `PERMISSION_DENIED`, recorded | Call denied | Nothing | +| No route, or ceiling denies the tool | Broker | `PERMISSION_DENIED` | Call denied | Nothing | +| Inconsistent delegation report | Broker | `PERMISSION_DENIED`, `ALREADY_EXISTS` | Delegated task cannot call tools | Nothing | +| Session closed or past its hard expiry | Broker, AS-1 | `invalid_request` with `session_closed`; the broker returns its status from error reasons | A new session opens on Alice's next request | Already revoked | +| Grant revoked by sign out everywhere | Broker, AS-1 | `invalid_request` with `signed_out`; the broker returns its status from error reasons | Run stops; Alice is signed out | Already revoked | +| Schedule grant presented to an ID-JAG exchange, or a broker refresh token used to open a scheduled run | AS-1 | `invalid_request` with `denied` | Call or run fails; a broker bug | Nothing | +| Company IdP refuses the refresh | AS-1 | `invalid_request` with `upstream_revoked` | Every agent and schedule for Alice stops; she is signed out | All of Alice's grants and client refresh tokens | +| Upstream refresh token past its recorded expiry, missing, or refused after an ambiguous refresh | AS-1 | `invalid_request` with `owner_action_required` | "Needs owner action": Alice signs in again | All of Alice's grants and client refresh tokens | +| Company IdP unreachable or 5xx, or the refresh lease timed out | AS-1 | 503 | This call fails as retryable; later calls retry | Nothing | +| AS-1 refuses the combination | AS-1 | `invalid_request` with `denied` | Call denied | Nothing | +| No consent link (external) | AS-2 | `invalid_grant` with `consent_required` | `mcp_authorization_pending` through "Allow Mecatl" | Nothing | +| ID-JAG replayed | AS-2 | `invalid_grant` with `invalid_assertion` | Call fails; a broker bug or a stolen ID-JAG | Nothing | +| `V` rejected by vMCP | vMCP | 401 `invalid_token` | None: one retry with a new `V` | Cached `V` dropped | +| vMCP session rejected | vMCP | 404 | None: one retry with a new session | Stored session ID dropped | +| vMCP policy denies the tool | vMCP | Tool error | Call denied | Nothing | +| No provider credential | vMCP | "Needs authorization" | `mcp_authorization_pending`; Alice connects GitHub | Nothing | +| Dispatch happened, no completion observed | Broker | Unknown outcome | Reported as unknown; not replayed | Nothing | + +## Error reasons + +RFC 8693 §2.2.2 requires `invalid_request` for any subject or actor token that is invalid or unacceptable by policy, so the error code alone cannot tell the broker what to do. AS-1 and AS-2 therefore start `error_description` with a reason code, either alone or followed by a colon and text: `error_description=session_closed`, or `error_description=session_closed: session ended`. RFC 6749 §5.2 limits `error_description` to the characters `%x20-21 / %x23-5B / %x5D-7E`, so the text never contains `"` or `\`; reason codes are lowercase letters and underscores. The broker carries the reason in a gRPC `google.rpc.ErrorInfo` detail, and mecak8s maps it to an outcome. + +| Reason | Returned by | Broker gRPC status | mecak8s outcome | +| --- | --- | --- | --- | +| `subject_invalid` | AS-1, session open | `UNAUTHENTICATED` | Ask the client to refresh its access token and retry once. | +| `token_used` | AS-1, session open | `UNAUTHENTICATED` | The same. | +| `owner_mismatch` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug. | +| `session_owned` | AS-1, session open | `PERMISSION_DENIED` | Fail the open; a mecak8s pairing bug or a closed session ID reused. | +| `session_closed` | AS-1, broker | `FAILED_PRECONDITION` | Open a new broker session on the user's next request. | +| `signed_out` | AS-1, broker | `FAILED_PRECONDITION` | Stop the run; the client shows the user as signed out. At session open: 401, and the client signs in again. | +| `upstream_revoked` | AS-1, broker | `PERMISSION_DENIED` | Stop the run; the client shows the user as signed out. At session open: 401, and the client signs in again. | +| `schedule_ended` | AS-1, broker | `FAILED_PRECONDITION` | The schedule was deleted; its run does not start. | +| `owner_action_required` | AS-1, broker | `FAILED_PRECONDITION` | New outcome `mcp_owner_action_required`, next to `mcp_authorization_pending`: the user must sign in again. | +| `superseded` | AS-1 | `ABORTED` | A later open replaced the session's refresh token; the broker rereads the stored one and retries once. | +| `svid_expired` | AS-1, broker | `UNAUTHENTICATED` | Renew the SVID and retry once; not shown to the user. | +| `denied` | AS-1, broker | `PERMISSION_DENIED` | Deny the call. | +| `consent_required` | AS-2 | `FAILED_PRECONDITION` | The existing `mcp_authorization_pending` outcome. | +| `invalid_assertion` | AS-2 | `INTERNAL` | Fail the call; a broker bug. | +| HTTP 503 | AS-1, AS-2 | `UNAVAILABLE` | Fail the call as a retryable infrastructure error, not a denial. | +| `invalid_client`, `unauthorized_client`, `invalid_target`, or no reason | AS-1, AS-2 | `INTERNAL` | Fail the call; a configuration fault or broker bug. An external AS-2 that sends no reason on `invalid_grant` is treated as `consent_required`. | + +A scheduled run has no client to show anything to: for every revocation reason except `schedule_ended`, it ends with "needs owner action" and Alice is notified. + ## Lifetimes and renewal An agent instance keeps its identity across runs. Each credential's lifetime is independent: @@ -846,12 +856,14 @@ An agent instance keeps its identity across runs. Each credential's lifetime is | ID-JAG | Short lifetime and single use. | Never renewed; one per `V`. | Broker | | `V` | Short lifetime. | A skew margin before `exp` at the next call, or within the same call after a 401; one exchange per cache key at a time. | Broker, through steps (7) and (8) | | Key sets: AS-1's, AS-2's, the agent trust bundle, the cluster JWKS, and the broker's `jwks_uri` | Operator rotation. | On an unknown `kid`, rate-limited, and periodically. | Each verifier | -| Consent link | Revocation at AS-2. | The user links again. | Alice | -| Upstream provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | Gateway, or Alice | +| Consent link | Revocation in the gateway UI or by Mecatl. | The user links again. | Alice | +| Provider credential | Provider expiry or revocation. | Refresh at the gateway, or the user connects again. | Gateway, or Alice | + +When a renewal fails, the credential's users fail closed: a workload credential with `UNAUTHENTICATED`, an agent-instance SVID by failing that instance's calls, `V` with step (7) or (8)'s outcome, and tokens signed with a rotated key are rejected until the verifier's key-set refresh succeeds. The example times are illustrative, not selected defaults: `J-reviewer` is valid `1800000001..1800000121`, `V-reviewer` `1800000005..1800000120`. `RT-A42`'s hard expiry and a schedule grant's horizon bound how long Mecatl acts without a fresh sign-in; the company IdP administrator should know them. -Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs, `V`, and vMCP session IDs. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. +Closing or revoking the session revokes `RT-A42` and drops that session's cached ID-JAGs and `V`. vMCP sessions belong to the instance and user, not the broker session: they survive a replacement broker session and end when the instance ends or the user's grants are revoked. A recipient validating only a self-contained token may still accept an already-issued `V` until it expires. ## Sign-out and revocation @@ -900,12 +912,12 @@ This works with any IdP that issues refresh tokens and needs no IdP-specific eve ## Sessions at the vMCP -Each agent instance has its own vMCP session (`Mcp-Session-Id`) for each user it acts for. The broker creates it on the instance's first MCP call and closes it when the instance ends, so backend state such as an open page or a selected repository carries across that instance's runs. +Each agent instance has its own vMCP session (`Mcp-Session-Id`) for each user it acts for. The broker creates it on the instance's first MCP call and closes it when the instance ends or the user's grants are revoked, so backend state such as an open page or a selected repository carries across that instance's runs. - Sessions are never shared between instances. - The vMCP binds each session to the user and the acting instance: `V`'s `iss`, `sub`, and `act.sub`. A token naming another instance in its `act` cannot use the session. -- The run is not part of the session or token. -- The broker stores each session ID under the instance, gateway, and user, so a [shared session](run-owner.md) never hands one user's backend state to another. After a restart it reattaches. When the vMCP reports the session gone, the broker creates a new one. A wedged session can be reset explicitly. +- The run is not part of the session or token; the run ID travels in trace headers. +- The broker stores each session ID under the instance, gateway, and user, not the broker session, so a [shared session](run-owner.md) never hands one user's backend state to another, and a replacement broker session keeps the instance's vMCP sessions. After a restart it reattaches. When the vMCP reports the session gone, the broker creates a new one. A wedged session can be reset explicitly. - An instance has at most one active run at a time; Mecatl rejects a second concurrent run on the same session or child. Runs of one instance therefore never share a vMCP session concurrently. If that rule ever changes, session assignment must be revisited. ## Pauses and restart @@ -936,7 +948,7 @@ None of these values is selected. Each bounds a risk the operator should know: ## Later work -None of these is selected. Each closes a gap the sections above name. +These are not part of the identity work. Run grants and the agent ceiling at the gateway come with the authority work; the rest are options, not selected. **Session-bound access token.** When a session is created, the user's client could silently refresh its AS-1 token with a `mecatl_session` entry naming that session, and use that token for it. AS-1 would then convert only a token bound to the session being opened. The client, not only mecak8s, would witness the pairing, and a stolen access token would work for one session instead of the whole API. It needs `authorization_details` at AS-1's refresh grant, a session-bound token per session in the TUI and Studio, and a two-step session open. It changes nothing downstream of `RT-A42`. @@ -951,7 +963,7 @@ None of these is selected. Each closes a gap the sections above name. **Per-agent attestation.** Attesting each agent's identity needs agents in their own pod, sandbox, or Kubernetes object, for example attested through the [SPIFFE Broker API](https://github.com/spiffe/spiffe/pull/340). That is out of scope for this design. -**DPoP for `V`.** Binding `V` to a broker key would make a stolen `V` useless at the vMCP. Binding a credential to a worker's key would still not establish which agent inside that worker made a call, or bind the MCP arguments. +**DPoP for `V`.** Binding `V` to a broker key would make a stolen `V` useless at the vMCP. Binding a credential to a worker's key would still not establish which agent inside that worker made a call, protect against a compromised worker using its own key, or bind the MCP arguments. **Faster revocation.** Two steps would make revocation faster or broader. AS-1 could receive logout and account events from the company IdP: OIDC Back-Channel Logout from Keycloak, or event hooks from Okta. Entra and Google Workspace send neither to third-party applications. And each broker refresh token could be bound to Alice's AS-1 login session, so that signing out at AS-1 itself ends her agents. Shared Signals (SSF, CAEP, RISC) carry the same events but have little IdP support today. @@ -972,9 +984,9 @@ Workload authentication identifies the Mecatl service contacting the broker. Age | Workload authentication to the broker | Agent-instance identity | | --- | --- | | Kubernetes ServiceAccount bearer over TLS | None: workload-only identification | -| Kubernetes ServiceAccount bearer over TLS | Broker-issued instance JWT-SVID | -| SPIRE workload JWT-SVID over TLS | Broker-issued instance JWT-SVID | -| SPIRE workload X.509-SVID over mTLS | Broker-issued instance JWT-SVID | +| Kubernetes ServiceAccount bearer over TLS | Agent-instance JWT-SVID | +| SPIRE workload JWT-SVID over TLS | Agent-instance JWT-SVID | +| SPIRE workload X.509-SVID over mTLS | Agent-instance JWT-SVID | These are alternatives, not a required implementation sequence. Broker-issued agent-instance JWT-SVIDs do not require SPIRE. @@ -993,7 +1005,7 @@ The agent-instance SVID identifies the acting agent instance through its SPIFFE Creation ancestry is distinct from execution delegation. The same instance can participate in multiple runs, so its SPIFFE ID alone does not identify which parent run delegated the current task. Run and call records supply that context. -A definition category and name are not enough to establish provenance. Two repositories can both define `code-reviewer`. Before selecting definition-specific policy, the broker needs a verified association with the source that supplied the definition. +A definition category and name are not enough to establish provenance. Two repositories can both define `code-reviewer`. Step (0) gives the broker that association for global definitions. For repository definitions, which mecak8s only names, it remains open. #### Example instance identities diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md index b1c987accf..23be66e9f0 100644 --- a/docs/drafts/agent-identity/implementation.md +++ b/docs/drafts/agent-identity/implementation.md @@ -20,7 +20,7 @@ Use literal, nonempty SPIFFE-safe definition names (`[A-Za-z0-9._-]+`, except `. Apply any issuance limit to the **whole URI**, including child hops, without truncating it. [SPIFFE-ID §2.3](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#23-maximum-spiffe-id-length) requires support through 2048 bytes and advises against generating longer IDs. The proposed caps are the definition category and name each up to 63 bytes, and up to 8 child levels below the root, which keeps the longest subject near 1,600 bytes; registration past either cap is refused. Legacy-name compatibility is also open. The path has no version segment, so incompatible grammar requires migration. -## AS-1 grant and AS-2 user resolution +## Grant record The flow has four integration points: AS-1 sign-in, the session-open exchange, the per-call ID-JAG, and AS-2 redemption. [Credentials and lifecycle](credentials-and-lifecycle.md#interactive-execution) gives each one's request, ordered checks, response, and errors; this section adds only what an implementer needs beyond that. The original company token never leaves AS-1: AS-1 is an ordinary OIDC client of the company IdP, keeps only the upstream refresh token for its re-check, and nothing downstream receives or exchanges a company token. @@ -38,7 +38,7 @@ The flow has four integration points: AS-1 sign-in, the session-open exchange, t | Source schedule grant | | For a scheduled run's session only: revoking the schedule grant revokes this refresh token. | | Revocation reason | | Set when revoked: `session_closed`, `signed_out`, `upstream_revoked`, `owner_action_required`, or, for a schedule grant, `schedule_ended`. | -**Open.** Whether external gateways adopt the AS-2 reason codes in `error_description`; until one does, the broker treats its `invalid_grant` as `consent_required`. No callback or private mapping-response fields are specified. Whether the "Allow Mecatl" step and the provider's consent screen can be one redirect chain is also open; candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. +**Open.** The persistence ordering of the correlation records. Whether external gateways adopt the AS-2 reason codes in `error_description`; until one does, the broker treats its `invalid_grant` as `consent_required`. No callback or private mapping-response fields are specified. How an external gateway that does not send reason codes reports "needs authorization" or "consent required" as a structured outcome is also open; candidates include an AS-2 login chain through AS-1, MCP URL elicitation, and the connector gateway's connect and status APIs. So is whether the "Allow Mecatl" step and the provider's consent screen can be one redirect chain. ## Credential verification @@ -92,14 +92,14 @@ Test the `V` cache: Test schedule grants: - Setting up a schedule converts a fresh access token with a `mecatl_schedule` entry into a schedule grant. -- When the schedule fires, the broker exchanges the schedule grant for a session refresh token whose expiry is no later than the horizon; the run's calls then pass steps (6) to (9). Revoking the schedule grant revokes that session's refresh token. +- When the schedule fires, the broker exchanges the schedule grant for a broker refresh token whose expiry is no later than the horizon; the run's calls then pass steps (6) to (9). Revoking the schedule grant revokes that session's refresh token. - A schedule grant is refused as the subject token of an ID-JAG exchange, and a session's refresh token is refused for opening a scheduled run. - A scheduled run whose owner differs from the owner recorded with the schedule grant is refused. - A scheduled-run open retried with the same schedule grant, client, and session succeeds; a schedule grant past its horizon or revoked is refused with its reason. - A run's session closes, revoking its refresh token, when the run ends. - A schedule grant presented at the refresh-token grant returns `invalid_grant`. -Test vMCP and the upstream credential: +Test vMCP and the provider credential: - vMCP rejects a `V` with the wrong issuer, audience, `typ`, or expiry. - vMCP loads the credential for (gateway, native user, provider) of a delegated `V` that carries no `tsid`, and returns "needs authorization" when none exists. One user's `V` never loads another user's credential. @@ -255,7 +255,7 @@ Verify the [AuditEvent profile](audit.md#auditevent-profile) through the real to - After `A42` is opened for Alice, AS-1 refuses to open `A42` again with Bob's token, including after a broker restart. - A session ID is never reused for a new session. -**Later work for session-bound tokens.** Before adopting [session-bound tokens](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token), check how each client would hold them. The TUI keeps tokens in the OS keyring and Studio in sealed server-side cookies; both would need one session-bound token per open session, refreshed silently. Studio's server holds those tokens for the browser, so check how it stores and selects them per session and whether that changes its cookie and CSRF handling. +**Later work for session-bound tokens.** Before adopting [session-bound tokens](credentials-and-lifecycle.md#later-work), check how each client would hold them. The TUI keeps tokens in the OS keyring and Studio in sealed server-side cookies; both would need one session-bound token per open session, refreshed silently. Studio's server holds those tokens for the browser, so check how it stores and selects them per session and whether that changes its cookie and CSRF handling. ## Run owner checks diff --git a/docs/drafts/agent-identity/mcp-authorization.md b/docs/drafts/agent-identity/mcp-authorization.md index 89d90417ee..fe6bfe2b0b 100644 --- a/docs/drafts/agent-identity/mcp-authorization.md +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -82,7 +82,7 @@ In the [interactive walkthrough](credentials-and-lifecycle.md#interactive-execut Both requests also require valid credentials and execution-context checks, and must pass local checks and the downstream AS-2 and vMCP checks. The reviewer's SVID identifies the child; even with creation ancestry in its subject, it does not prove authority under `D42`. The child presents its own credential, not the parent's SVID. Future broker enforcement of task-specific delegated bounds will need trusted evidence of those bounds. -For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. The broker holds no connection evidence of its own. See [the vMCP step](credentials-and-lifecycle.md#9-vmcp-decision-and-upstream-credential). +For both calls, Alice's use of her GitHub account is checked downstream. AS-2 redeems the ID-JAG only for a mapped or consent-linked native user, and vMCP loads only the credential stored for `(gateway.example.com, usr-alice-7c1d, github)`. The model cannot select another user's credential. Matching account names or email addresses establish nothing. The broker holds no connection evidence of its own. See [the vMCP step](credentials-and-lifecycle.md#9-vmcp-decision-and-provider-credential). ## Authorization, credentials, and dispatch diff --git a/docs/drafts/agent-identity/mcp-gateway-modes.md b/docs/drafts/agent-identity/mcp-gateway-modes.md index 78d262a6c9..581f0c242e 100644 --- a/docs/drafts/agent-identity/mcp-gateway-modes.md +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -45,7 +45,7 @@ The per-call flow, with every request, token, and check, is in [credentials and | Mode | How AS-2 resolves and authorizes the user | | --- | --- | -| Embedded | ID-JAG as specified. Users log in to AS-2 through AS-1, so AS-1 is the SSO IdP AS-2 trusts (§4.1) and the subject is the shared AS-1 `sub` (§5). `u-alice-91` maps directly to native user `usr-alice-7c1d`. Consent is implicit: AS-1, AS-2, and the credential store share one administrative domain. | +| Embedded | ID-JAG as specified. Users log in to AS-2 through AS-1, so AS-1 is the SSO IdP AS-2 trusts (§4.1) and the subject is the shared AS-1 `sub` (§5). `u-alice-91` maps directly to native user `usr-alice-7c1d`. Consent is implicit: AS-1, AS-2, and the credential store share one administrative domain. AS-1 and AS-2 are distinct issuers with distinct keys, so no issuer redeems its own ID-JAG. | | External | Identity chaining across administrative domains ([chaining §§2.1, 2.5](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-chaining-17.html#section-2.1)) using the ID-JAG token profile. AS-2's users log in through the gateway's own IdP, so the §4.1 precondition, that the resource AS already trusts the IdP for SSO, does not hold, and its consent waiver does not apply. AS-2 trusts AS-1 only as an assertion issuer. It redeems only when the ID-JAG's `(iss, sub)` and `client_id` match a consent link, which supplies the native user (chaining §2.2, deployment-specific resolution). No just-in-time provisioning. | The external consent link records (native user, AS-1 issuer, AS-1 `sub`, client `mecatl`, grant time, and scope or providers). Alice creates it once per gateway, through AS-2's own login, by approving "Allow Mecatl" on a consent screen AS-2 generates. It is keyed on the AS-1 `sub` in the ID-JAG, never on a `sub_id` (§9.5). It stops a compromised AS-1 from using the gateway credentials of a user who never chose Mecatl. Admin pre-consent without an independent witness of the user is rejected for the same reason. The link's creation flow, the link-swap attack it must resist, and the shared-IdP shortcut are in the [implementation companion](implementation.md#external-consent-link). The gateway UI can revoke the link. @@ -84,7 +84,7 @@ Embedded mode is outside the cross-domain scope that ID-JAG §9.3 states, becaus ## Integration requirements and evidence limits -AS-1 must accept `RT-A42` as `subject_token` and the registered agent JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state and hard expiry, re-checks the user's upstream refresh token when due, checks the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `ag_` identifier. +AS-1 must accept `RT-A42` as `subject_token` and the registered agent-instance JWT-SVID as `actor_token` from client `mecatl` authenticated with `private_key_jwt`. It resolves the subject only from the refresh token's record, checks session state and hard expiry, re-checks the user's upstream refresh token when due, checks the actor's registration to the broker session, authorizes client, subject, actor, and AS-2 target together, and returns an ID-JAG whose single-level `act` holds the actor's opaque `ag_` identifier. AS-2 must authenticate `mecatl`, validate the ID-JAG under ID-JAG §4.4.1, reject a reused `jti` (RFC 7523 §3), and resolve the native user: directly in embedded mode, and only through a consent link in external mode. It must issue an access token for the native user that preserves `act` and `client_id`, and no refresh token. Unresolved or unlinked subjects fail closed. diff --git a/docs/drafts/agent-identity/run-owner.md b/docs/drafts/agent-identity/run-owner.md index b5652340de..c3a3dbcfef 100644 --- a/docs/drafts/agent-identity/run-owner.md +++ b/docs/drafts/agent-identity/run-owner.md @@ -32,7 +32,7 @@ Shared sessions build on the run owner with additions: | Members other than the creator | The creator recorded separately | A member list kept by mecak8s; the owner check becomes a membership check | | Revoking one member | Revocation by user | Nothing | -Shared sessions should arrive together with [run grants](credentials-and-lifecycle.md#3-session-open-and-the-broker-refresh-token), not with a standing refresh token per member. With standing tokens, mecak8s's report of who started a run would select that member's token, so a compromised mecak8s could act as any member. A run grant is minted from the access token of the person who started the run, so no report from mecak8s decides whose credentials are used. A run grant could take the shape of a transaction token issued by AS-1 or the broker, whose `txn` and `sub` cannot change; it never reaches AS-2 or the vMCP. +Shared sessions should arrive together with [run grants](credentials-and-lifecycle.md#later-work), not with a standing refresh token per member. With standing tokens, mecak8s's report of who started a run would select that member's token, so a compromised mecak8s could act as any member. A run grant is minted from the access token of the person who started the run, so no report from mecak8s decides whose credentials are used. A run grant could take the shape of a transaction token issued by AS-1 or the broker, whose `txn` and `sub` cannot change; it never reaches AS-2 or the vMCP. Shared sessions also raise product questions outside identity: From de4303c0af2c648865ac76ea9d2654ec2f10147b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 16 Oct 2026 13:24:44 +0200 Subject: [PATCH 50/50] docs: draw the README's trust boundaries explicitly Replace the "Where trust lives" diagram with one that draws administrative domains as boxes and the four trust boundaries as numbered edges: user entry, credential custody between mecak8s and the broker, gateway entry, and provider egress, each labelled with what crosses and who checks. It shows the intended deployment, a separate broker and an external gateway, and a note says what differs today. The agent trust domain is shown as a naming label on the agents, not a boundary, since agents share the mecak8s process and their SVIDs are asserted. AS-1 touches both user entry and gateway entry. Co-Authored-By: mecatl --- docs/drafts/agent-identity/README.md | 76 ++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/docs/drafts/agent-identity/README.md b/docs/drafts/agent-identity/README.md index 370ae67cc5..07940b2cec 100644 --- a/docs/drafts/agent-identity/README.md +++ b/docs/drafts/agent-identity/README.md @@ -31,7 +31,7 @@ An **instance** is one use of a definition in a session. Alice and Bob can use t A **run** is one execution by an instance. Reviewing PR 42 and later PR 57 uses the same reviewer instance in two runs. A delegated child has its own instance and run, linked to the instance that created it and to the run that delegated to it. -The **user** is the person on whose behalf the agents act. mecak8s, the service that hosts the agents, is the platform **workload**: it serves many users at once and has its own infrastructure identity. Each agent instance also has an identity of its own, in a separate agent trust domain. +The **user** is the person on whose behalf the agents act. mecak8s, the service that hosts the agents, is the platform **workload**: it serves many users at once and has its own infrastructure identity. Each agent instance also has a name of its own: a SPIFFE ID in a separate agent trust domain, signed by the broker's agent-instance issuer. That name is for attribution, not isolation; every agent runs inside the same mecak8s process. ## Where trust lives @@ -50,33 +50,65 @@ Downstream, the **vMCP gateway** sends protected calls to MCP backends and provi Mecatl uses two authorization servers so that it can work with an MCP gateway it does not operate, such as the connector gateway in the Stacklok Enterprise Platform. That gateway holds users' provider connections, such as their GitHub or Google tokens, under its own administration, and its vMCP accepts only access tokens from the gateway's own authorization server; Mecatl can neither mint those tokens nor hold the credentials behind them. So AS-1, Mecatl's server, issues a grant naming the user and the acting agent, and AS-2, the gateway's server, verifies the grant, decides whether to honor it, and issues its own access token. When Mecatl runs the gateway itself (embedded mode), it uses the same two servers and the same protocol, so there is one code path; there the split is a protocol boundary rather than a security boundary, although the two servers still keep separate keys. +The diagram draws each administrative domain as a box and each trust boundary as a numbered thick arrow, labelled with what crosses it and who checks it. + ```mermaid flowchart LR - U["Client"] -->|"Sign-in"| A1 - U -->|"Session creation and requests, AS-1 access token"| H["mecak8s"] - F["Agent-definition sources"] -->|"Global definitions at startup"| R - R -->|"Execution configuration"| H - subgraph B["Identity broker"] - R["Definition loader"] - I["Authorization and audit
OAuth client mecatl"] - A1["AS-1: login and ID-JAG issuer"] - R -->|"Definition records and ceilings"| I + C["Alice's client"] + IdP["Company IdP"] + subgraph P["Mecatl platform"] + subgraph K["mecak8s"] + H["Harness"] + M["Model
untrusted"] + AG["Agent instances
acting agent, asserted
spiffe://agents.example.com/..."] + EX["Executor Pods
untrusted workspace code"] + end + subgraph B["Identity broker"] + DL["Definition loader"] + IS["Agent-instance issuer"] + A1["AS-1
login and ID-JAG issuer"] + BC["Broker core
authorization, client mecatl"] + AU["Audit"] + end end - A1 -->|"Redirect to company sign-in"| IdP["Company IdP"] - subgraph W["vMCP gateway (embedded or external)"] - T["Gateway AS (AS-2) and credential storage"] + subgraph G["Gateway"] + A2["AS-2"] V["vMCP"] - T --- V + S["Provider credential store"] + end + subgraph PR["Providers"] + GH["GitHub"] end - H -->|"Workload-authenticated instance registration"| I - H <-->|"Workload-authenticated MCP calls; AS-1 access token once at session open; agent JWT-SVID per call"| I - I -->|"Session refresh token; ID-JAG on a cache miss"| A1 - I -->|"Redeem ID-JAG for V"| T - I <-->|"Authorized MCP calls with V"| V - V <-->|"Protected calls"| P["MCP backends / providers"] - I -->|"Decisions and observed outcomes"| A["Audit records"] + C ==>|"1 User entry: OIDC sign-in, AS-1 checks"| A1 + A1 <-->|"OIDC federation"| IdP + C ==>|"1 AS-1 access token, mecak8s checks"| H + H --- M + H --- AG + H -->|"workspace operations"| EX + H ==>|"2 Custody: workload credential plus reported agent, run, delegation. Broker checks"| BC + IS -.->|"agent JWT-SVIDs"| AG + DL -->|"definitions and ceilings"| BC + A1 -->|"ID-JAG on a cache miss"| BC + BC -->|"decisions and outcomes"| AU + A1 -.->|"3 AS-2 trusts AS-1 as ID-JAG issuer"| A2 + BC ==>|"3 Gateway entry: ID-JAG, consent link required. AS-2 checks"| A2 + BC ==>|"3 MCP call with V, vMCP checks"| V + A2 --- V + V --- S + V ==>|"4 Provider egress: Alice's stored credential, GitHub checks"| GH ``` +| Boundary | What crosses | Who checks | +| --- | --- | --- | +| 1. User entry | Alice signs in at AS-1 with OIDC, and AS-1 sends her on to the company IdP. Her client then sends the AS-1 access token to mecak8s. | AS-1 at sign-in; mecak8s on each API request. | +| 2. Credential custody | mecak8s calls the broker with its workload credential, which the broker authenticates. The acting agent, run, and delegation are reports from mecak8s, which the broker checks for consistency but cannot verify. mecak8s holds no signing keys or refresh tokens. | The broker. | +| 3. Gateway entry | The broker redeems an ID-JAG from AS-1 at AS-2, which in external mode requires Alice's consent link, and calls the vMCP with the resulting access token `V`. | AS-2 when it redeems the ID-JAG; the vMCP when it validates `V`. | +| 4. Provider egress | The vMCP calls GitHub with Alice's stored GitHub credential. | GitHub. | + +AS-1 sits on two boundaries: it signs Alice in at boundary 1 and signs the ID-JAG that AS-2 checks at boundary 3. The agent trust domain is not a boundary; it names agents for attribution, inside mecak8s. + +The diagram shows the intended deployment. Today the broker runs inside mecak8s, so credential custody is a module seam and an attacker with code execution in mecak8s holds AS-1's key; and an embedded gateway shares Mecatl's administrative domain, so gateway entry there is a protocol boundary, not a security one. The [threat model](threat-model.md#a-compromised-mecak8s) says what each deployment closes. + This design scopes broker authorization to MCP tools. Local deployments can keep direct tool access, and none of these guarantees apply to them. Extending the boundary to filesystem operations, Shell, and agent spawning is a [future direction](all-tool-mediation.md). The broker does not decide whether agents start or whether runs execute; mecak8s owns agent lifecycle and run execution, and registering an instance records its identity without granting permission to run. ## One request, end to end @@ -108,7 +140,7 @@ The [threat model](threat-model.md) has the full table, the facts mecak8s report The design composes existing OAuth and workload-identity standards rather than inventing a token format. Sign-in is OIDC with the authorization code flow and PKCE, and mecak8s validates AS-1's access token as an RFC 9068 JWT access token. Session open and ID-JAG issuance are RFC 8693 token exchanges, and AS-2 redeems the ID-JAG with the RFC 7523 JWT-bearer grant. The broker authenticates as a confidential client with `private_key_jwt`, so the refresh tokens it holds are useless without its key. That key belongs to the broker, ideally in a key management service; ToolHive's authorization server also accepts a SPIFFE JWT-SVID as client authentication, which could later replace the key with the broker's own workload identity. -The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different trust domains in the OAuth sense ([administrative domains](credentials-and-lifecycle.md#7-id-jag-from-as-1)); embedded mode, where all parties share one, reuses the format to keep one protocol path. +The grant format is ID-JAG ([draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)), and external mode follows identity chaining ([draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)). ID-JAG is scoped to parties in different [administrative domains](credentials-and-lifecycle.md#7-id-jag-from-as-1) (trust domains, in OAuth's term); embedded mode, where all parties share one, reuses the format to keep one protocol path. Agent credentials are SPIFFE JWT-SVIDs. Each protected call carries two identities, comparable to the layered workload authentication the WIMSE architecture describes, except that only the first is authenticated: the platform workload is authenticated, and the acting agent is asserted by the agent-instance issuer. Registration links the two: the broker accepts an agent only if it was registered to a session bound to that workload. The ID-JAG and the vMCP access token are bearer tokens; short lifetimes, a single audience, and single use of the ID-JAG limit what a stolen one can do.