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/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] 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 681ddc9dea..0000000000 --- a/docs/drafts/agent-identity-model.md +++ /dev/null @@ -1,1249 +0,0 @@ -# Agent identity: mecatl as its own SPIFFE trust domain - -Same tier as [`docs/scoped-resource-grants.md`](scoped-resource-grants.md). - -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 new file mode 100644 index 0000000000..07940b2cec --- /dev/null +++ b/docs/drafts/agent-identity/README.md @@ -0,0 +1,192 @@ +# Agent identity and authority + +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. + +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.* + +## 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 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 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'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 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. + +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 + +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. 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. 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, 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. + +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 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 + 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 + subgraph G["Gateway"] + A2["AS-2"] + V["vMCP"] + S["Provider credential store"] + end + subgraph PR["Providers"] + GH["GitHub"] + end + 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 + +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. + +**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. + +**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, 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. + +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. + + +## Deployment and threats + +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 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. 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. + +## 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. + +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. + +## 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 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: + +- 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 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: + +- 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 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 + +Two questions bear on what the design can promise: + +- 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. + +| Chapter | What it covers | +| --- | --- | +| [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. | +| [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. diff --git a/docs/drafts/agent-identity/agent-definitions.md b/docs/drafts/agent-identity/agent-definitions.md new file mode 100644 index 0000000000..3c326e17b4 --- /dev/null +++ b/docs/drafts/agent-identity/agent-definitions.md @@ -0,0 +1,175 @@ +# Agent definitions + +*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 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. 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. 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. 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 + L["Definition loader
Broker module"] + D["AgentDef
Reusable definition"] + 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"] + + L -->|"loads at startup"| D + 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 +``` + +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. + +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 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 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. | + +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. + +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. + +## What AgentDef contains + +[`AgentDef`](../../../engine/tool/agentsource.go) combines several concerns: + +| Concern | Fields | Purpose | +| --- | --- | --- | +| 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` | 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 field for a definition-source identifier or a revision. + +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. + +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. 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 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. 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. + +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, 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. 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. 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. 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`. 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. 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. 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'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 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. + +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. + +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#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. + +## Identity continuity across runs and restarts + +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 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 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 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. | +| 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. | + +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 + +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 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 broker computes two digests: + +- **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 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 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 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 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. 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..673cbbbc7d --- /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. 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. + +## 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 new file mode 100644 index 0000000000..e777596b5f --- /dev/null +++ b/docs/drafts/agent-identity/audit.md @@ -0,0 +1,160 @@ +# 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.* + +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. + +## Evidence and provenance + +Records must distinguish verified facts from trusted reports. Qualify subjects by issuer or configured trust domain; a workload subject alone is not globally unique. + +| 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, 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, 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, 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. | +| 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, following the [AuditEvent profile](#auditevent-profile); ToolHive's authorization server records no audit events today, so this is new work. + +## AuditEvent profile + +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. + +| 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 `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 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. | +| 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, 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: + +| Event | Outcomes | +| --- | --- | +| 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: + +- 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, 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 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; +- the broker's decision, dispatch, and observed outcome for each tool call; +- the vMCP's tool-call event; +- 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. + +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 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 + +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, 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. + +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 `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 + +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 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 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 + +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. + +**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: + +| 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. + +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 + +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. + +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/credentials-and-lifecycle.md b/docs/drafts/agent-identity/credentials-and-lifecycle.md new file mode 100644 index 0000000000..58404dd1ea --- /dev/null +++ b/docs/drafts/agent-identity/credentials-and-lifecycle.md @@ -0,0 +1,1032 @@ +# Credentials and lifecycle + +*This chapter describes the intended architecture, not current implementation status.* + +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. + +## 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 | 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 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. | +| **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, 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 + +| 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 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 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. | + +## 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). + +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 (TUI / Studio) + participant H as mecak8s + box Broker + participant L as Definition loader + participant I as Agent-instance issuer + 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 L,B: (0) Broker startup + L->>L: Load global definitions from configured sources + 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) + 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 = Mecatl API) + client refresh token + + 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 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) + 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, 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 credential + I-->>H: mainSVID + end + + Note over H,G: (6) Main call: pull_request_list + 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 + 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 + 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, definition reference global/code-reviewer,
parentRef = mainRef, A42), workload credential + I-->>H: reviewerRef + H->>B: ReportDelegation D42: A42, R41 to R42, run owner Alice + 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 + + 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 +``` + +The diagram follows the successful path. Other outcomes: + +- **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. 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:** + +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. + +**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. + +**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 + +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](#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. + +**Input.** Each Mecatl API request carries `Authorization: Bearer `. A fictional decoded access token: + +```json +{"typ": "at+jwt", "alg": "ES256", "kid": "as1-key-1"} +{ + "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": 1800000800, + "jti": "at-alice-7731" +} +``` + +**Checks, at mecak8s** (RFC 9068 §4): + +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. +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 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. + +**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 + +**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 +{ + "iss": "https://kubernetes.example.com", + "sub": "system:serviceaccount:mecatl:mecak8s", + "aud": ["https://broker.example.com"], + "iat": 1799999900, + "nbf": 1799999900, + "exp": 1800003500, + "kubernetes.io": { + "namespace": "mecatl", + "serviceaccount": {"name": "mecak8s", "uid": "sa-example-42"}, + "pod": {"name": "mecak8s-worker-7", "uid": "pod-example-7"} + } +} +``` + +**Checks, at the broker and the agent-instance issuer,** offline against the cluster's keys: + +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. +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`. 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. 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. 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; 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 +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= +``` + +**Checks, at AS-1:** + +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. 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, 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. 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 +{ + "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"}}] +} +``` + +`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 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.** 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` 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 `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, 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 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. + +**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 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, 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. + +### (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. + +**Registration checks, at the agent-instance issuer:** + +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 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 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`. + +**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/global/code-reviewer/inst/inc_gh56irfcpnupjqgfdw7dhzzw4m", + "aud": ["https://broker.example.com"], + "exp": 1800000600 +} +``` + +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. `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. 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. +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.** + +| Check | Response | Retry | +| --- | --- | --- | +| Registration 1 | Step (2)'s codes | Only an expired workload credential. | +| 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 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. | + +**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 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 [lifetimes and renewal](#lifetimes-and-renewal) 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). + +**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 +{"typ": "client-authentication+jwt", "alg": "ES256", "kid": "broker-2026-10"} +{ + "iss": "mecatl", "sub": "mecatl", + "aud": "https://broker.example.com/as", + "jti": "ca-7f3a", "iat": 1800000000, "exp": 1800000060 +} +``` + +**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` 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) 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. + +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. + +**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 +{ + "tool": "pull_request_read", + "arguments": {"repository": "acme/payments", "number": 42} +} +``` + +**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 `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` 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. + +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-provider-credential). + +**Failures.** + +| Check | Response | Retry | +| --- | --- | --- | +| 1 | Step (2)'s codes | Only an expired workload credential. | +| 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. | + +**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. 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. + +### (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, 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 +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 +resource=https://gateway.example.com/mcp +mecatl_operation=op-pr42-001 +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. 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. + +**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"} +{ + "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", + "iat": 1800000001, + "exp": 1800000121 +} +``` + +- `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 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. + +**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, 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. | +| 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. + +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. 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. + +### (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`. 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 +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 `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: 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 | +| --- | --- | +| 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. | + +**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": "ag_7hq2x9"}, + "client_id": "mecatl", + "aud": "https://gateway.example.com/mcp", + "iat": 1800000005, + "exp": 1800000120, + "jti": "access-pr42-001" +} +``` + +`V`'s lifetime does not exceed the ID-JAG's. + +**Failures.** + +| Check | Response | Broker outcome and retry | +| --- | --- | --- | +| 1 | 401 `invalid_client` | Call fails; not retryable. | +| 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` 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.** 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. + +**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. + +**Evidence.** AS-2 records the redemption or denial: ID-JAG `jti`, client `mecatl`, native user, actor, resource, and decision. + +### (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. 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); 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. 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, 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.** + +| 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. | + +**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. Each recipient records only what it verifies or observes. + +## 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. + +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, 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. + +**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: + +```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 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 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.** + +| 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 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. | +| 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. + +## 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: + +| 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 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 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 + +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 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, 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. + +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 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)). + +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. 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. + +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 | 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` 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. + +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. + +## 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 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 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 + +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. 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 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. + +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. + +## Configuration + +None of these values is selected. Each bounds a risk the operator should know: + +- **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. + +## Later work + +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`. + +**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. + +**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. + +**Stronger agent-instance SVIDs.** Two strengthenings: + +- **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. + +**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, 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. + +**SPIFFE client authentication.** The broker could authenticate as OAuth client `mecatl` with its SPIFFE workload identity instead of a separate private key. + +**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`. + +**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. + +## Reference + +The following sections are reference material for the steps above. + +### 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 | 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. + +| 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. + +### 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. + +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`. Step (0) gives the broker that association for global definitions. For repository definitions, which mecak8s only names, it remains open. + +#### 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/global/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. diff --git a/docs/drafts/agent-identity/implementation.md b/docs/drafts/agent-identity/implementation.md new file mode 100644 index 0000000000..23be66e9f0 --- /dev/null +++ b/docs/drafts/agent-identity/implementation.md @@ -0,0 +1,340 @@ +# 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). + +## 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 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. + +## SPIFFE identifier construction + +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 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. + +## 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. + +**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 | +| --- | --- | --- | +| 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` | | 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`, `owner_action_required`, or, for a schedule grant, `schedule_ended`. | + +**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 + +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 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 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 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. + +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 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: + +- 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 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 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 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 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 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. +- 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. + +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. + +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 + +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 | +| --- | --- | --- | --- | +| 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, 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 | +| `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), 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 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 | +| 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 | +| 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 | +| 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#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. + +## 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` 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 + +### 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. + +## Cedar authorization and audit verification + +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. +- 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 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 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. +- 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. +- 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. +- 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#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 + +- 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 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 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: + +- 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. + +## 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; 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, 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. +- 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 + +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 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; +- 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: + +- **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. + +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 new file mode 100644 index 0000000000..fe6bfe2b0b --- /dev/null +++ b/docs/drafts/agent-identity/mcp-authorization.md @@ -0,0 +1,101 @@ +# MCP authorization + +*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. 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 + +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. 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, 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. + +## 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 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 + +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. 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. + +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)). 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). + +### 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. + +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: + +- 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 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. + +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 + +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 | 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 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. + +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 + +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 | +| --- | --- | +| 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. | + +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, 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 new file mode 100644 index 0000000000..581f0c242e --- /dev/null +++ b/docs/drafts/agent-identity/mcp-gateway-modes.md @@ -0,0 +1,93 @@ +# MCP gateway modes + +*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. + +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 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 | 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 | + +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 + +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`. + +**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, 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`. + +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. + +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, 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. + +## 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 | +| --- | --- | +| 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. + +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 + +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, 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; +- ID-JAG lifetime and `jti` replay handling. + +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 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 + +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. + +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 + +- **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. + +## Integration requirements and evidence limits + +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. + +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. diff --git a/docs/drafts/agent-identity/run-owner.md b/docs/drafts/agent-identity/run-owner.md new file mode 100644 index 0000000000..c3a3dbcfef --- /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 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. +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#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: + +- **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. diff --git a/docs/drafts/agent-identity/threat-model.md b/docs/drafts/agent-identity/threat-model.md new file mode 100644 index 0000000000..8bd289f92e --- /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. + +## 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, 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. | +| 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. 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. 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=