diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6c7bd38..e1206d1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "displayName": "Agent Stack", "source": "./plugins/agent-stack", "description": "Four skills: agent-orchestrator — tool-calling loops, pipelines with checkpoints, provider routing with fallback, memory architecture, plus the wallet side of reselling LLM access; agent-evals — run/trace/thread evals, LLM judges, and fixtures grown from production; agent-interop — MCP servers and clients, A2A agent cards, the MCP Registry, and gateways; agent-harness — system prompts, tool shaping, workflow-vs-agent, and auditing an agent system.", - "version": "0.25.6", + "version": "0.26.0", "author": { "name": "ssheleg", "url": "https://x.com/sshlg93" diff --git a/CHANGELOG.md b/CHANGELOG.md index cb360eb..8924d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## 0.26.0 — delegation: which agent acts, for which person + +- **`agent-interop/references/gateway.md` gains the on-behalf-of model (B-155).** The file + already said that authority does not travel down a chain of agents by itself, but not how it + should. The new section describes it: + - a short token in RFC 8693 shape: `sub` is the person, the nested `act` chain lists the + agents, `aud` names exactly one callee, then `scope`, an effect ceiling and a grant reference; + - one issuer that only narrows on each onward hop; + - the callee checks the token offline against a published key set, in a fixed order; + - calls go direct, with no proxy, and a forwarded call carries the token in a header of its own; + - a service's own token is never used to call it; + - spend is checked with a separate meter token, and `limit-reached` names its window; + - an approval is a document signed over an RFC 8785 action digest and is used once; + - one set of refusal codes; + - what this model does not protect against. +- Fabric `fabric-delegation/0.1` is the worked example (DEC-0039 at + `passioncode-ai/fabric-agent-contract@8e49fbd`, ADR-0144 at `passioncode-ai/fabric@812876cf`), + pinned on its own "Delegation pinned" line. Three traps are added: calling a service with its + own token, `aud` as an array or a token with no `typ`, and trusting the token's spend claim. + `agent-interop`'s reference table names delegation as a reason to load the file. + ## 0.25.6 — a lightweight tag cannot publish a release - **The release refuses a lightweight tag.** `release.yml` gains one step, right after diff --git a/SKILL-CARD.md b/SKILL-CARD.md index 6f94f48..f7ab541 100644 --- a/SKILL-CARD.md +++ b/SKILL-CARD.md @@ -5,7 +5,7 @@ | Field | Value | |---|---| | Pack | `agent-stack` | -| Version | `0.25.6` | +| Version | `0.26.0` | | Skills | `agent-orchestrator`, `agent-evals`, `agent-interop`, `agent-harness` | | License | MIT | | Source | https://github.com/ssheleg/agent-stack | diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md index b63b4ee..c392a99 100644 --- a/docs/evidence/backlog.md +++ b/docs/evidence/backlog.md @@ -40,7 +40,7 @@ row per line, appended, so two runs closing two rows do not land on the same lin | B-133 | **`governance.md`'s four-boundary table put per-tool authorisation at the moment of invocation, and never named the stronger control one layer earlier.** Schema gating was §3's rule and argued as assembly hygiene rather than as safety; nothing said a tool absent from the schema cannot be argued for or probed, that sub-agent isolation needs `message_history = None` beside the filtering, or that an approval system without persistence converts itself into no approval system. | 2026-08-31 harness-engineering harvest, queue position 8 (ARCH) | 2 | 0 | 1 | **2.0** | **closed 2026-08-31, shipped in v0.22.0** — *The cheapest control is absence*, with both isolation mechanisms, the 40%-to-under-5% context figures, and the rule underneath them (**eager for what you own and always need, lazy for what is foreign and might not be used**). Approval fatigue filed as a safety failure that leaves clean logs. | | B-134 | **§7 named four things to instrument and no format to carry them, and the obvious standard omits two of the four.** `grep -ri opentelemetry` over the pack returned one table cell in `agent-interop/references/mcp.md`. Meanwhile `gen_ai.evaluation.result` has no attribute for who produced a score — the field §7 requires by name — and the conventions carry no cost attribute at all while splitting usage across eleven token fields. | 2026-08-31 harness-engineering harvest, observability bundle (10 findings, one source) | 2 | 0 | 2 | **1.0** | **closed 2026-09-01, shipped in v0.23.0** — `references/otel-genai.md`: the span-name formulas and closed 17-value operation enum, the two Required attributes that make conformance a weak floor, the missing scorer field with OpenInference's `annotation.annotator_kind` as the extension to carry, the three content tiers and the upload hook that runs independently of opt-in **and** of the sampling decision, the eleven token attributes with the both-directions cost error, the disjoint OpenInference/OpenLLMetry vocabularies under one trace id, and the three senses of *replay*. Status recorded honestly: the spec moved repositories, its Schema URL reads `TODO`, and every `gen_ai.*` attribute is Development. | | B-135 | **The family audit asked for a debulk of two bodies at capacity, and the debulk was declined rather than done blind.** Wave AUDIT-WAVE-0906 flagged `agent-orchestrator/SKILL.md` and `agent-evals/SKILL.md` as near the working limit — re-measured at this commit by `audit_skill.py --house`: **4655/4750** (98.0%) and **4631/4750** (97.5%), both `0 GAP`. The family precedent (B-141 class) is that manufacturing headroom with no pending edit is negative-value work: room bought today is spent by nobody, and a trim chosen without the edit it serves cuts on the wrong line. B-129 already records the durable half — this body is at capacity and the remedy is a split, a routing decision. Debulk belongs to the release that next edits these bodies, priced into that release rather than shipped as its own. | family audit 2026-09-06, wave AUDIT-WAVE-0906 | 1 | 0 | 2 | **0.5** | **open.** Deliberately not scheduled: the trigger is the next edit to either body, and whichever release brings that edit pays the displacement (or executes B-129's split) as part of its own work. | -| B-155 | **`agent-interop/references/gateway.md` names per-caller authorisation and per-hop policy (`:49`, `:108`) but no on-behalf-of model.** Nothing says how authority travels down a chain of agents. Fabric's delegation model (ADR-0144, CO-258) asks the doctrine to describe it: one issuer mints a short delegation JWT carrying an RFC 8693 `act` chain, one audience, capabilities, an effect ceiling and a grant; the callee verifies it offline against published JWKS; calls go direct, with no proxy in the path; `approval/1` and the common refusal codes apply. Doctrine only, no product change. Write it once the plan lands on Fabric main, citing that commit instead of the branch. | peer request fabric-workspace-91, 2026-10-11; plan `passioncode-ai/fabric@32046d14:docs/evidence/plans/2026-10-11-delegation-model/README.md` (branch agent/release-040-wave1) | 3 | 0 | 2 | **1.5** | open — waits for the Fabric plan on main | +| B-155 | **`agent-interop/references/gateway.md` names per-caller authorisation and per-hop policy (`:49`, `:108`) but no on-behalf-of model.** Nothing says how authority travels down a chain of agents. Fabric's delegation model (ADR-0144, CO-258) asks the doctrine to describe it: one issuer mints a short delegation JWT carrying an RFC 8693 `act` chain, one audience, capabilities, an effect ceiling and a grant; the callee verifies it offline against published JWKS; calls go direct, with no proxy in the path; `approval/1` and the common refusal codes apply. Doctrine only, no product change. Write it once the plan lands on Fabric main, citing that commit instead of the branch. | peer request fabric-workspace-91, 2026-10-11; plan `passioncode-ai/fabric@32046d14:docs/evidence/plans/2026-10-11-delegation-model/README.md` (branch agent/release-040-wave1) | 3 | 0 | 2 | **1.5** | **closed 2026-10-11** — 0.26.0, `gateway.md` § Delegation; pinned to Fabric main `812876cf` and the contract's `8e49fbd` | ## Open, and why diff --git a/docs/evidence/verification.md b/docs/evidence/verification.md index 8510f57..f0188b5 100644 --- a/docs/evidence/verification.md +++ b/docs/evidence/verification.md @@ -10,6 +10,12 @@ This file exists because its absence read as zero exposure. `sshlg-skills` board --- +## Release candidate — v0.26.0 (2026-10-11) + +| REQ | What ships | How it was confirmed | Confirmed | +|---|---|---|---| +| B-155 | `gateway.md` § "Delegation — who is acting, and for whom", plus three traps, pinned to Fabric `fabric-delegation/0.1` (`8e49fbd`) and ADR-0144 (`812876cf`) | the claim names, the limits, the check order, the meter flow, the approval order and the count of refusal codes were checked line by line against `docs/specification/delegation.md` at `8e49fbd`. `python3 test/validate.py` exits 0. The spec-pin check failed on a multi-line pin before the pin was split | **planted** — the pin check watched failing | + ## Released — v0.25.6 (2026-10-10) | REQ | What ships | How it was confirmed | Confirmed | diff --git a/package.json b/package.json index fc923d0..a2fd739 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ssheleg/agent-stack", - "version": "0.25.6", + "version": "0.26.0", "scripts": { "test": "python3 test/validate.py && python3 test/plant_guard_test.py && node test/installer_test.js && npm run test:audit", "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done" diff --git a/plugins/agent-stack/.claude-plugin/plugin.json b/plugins/agent-stack/.claude-plugin/plugin.json index f5634c7..e16a555 100644 --- a/plugins/agent-stack/.claude-plugin/plugin.json +++ b/plugins/agent-stack/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "agent-stack", "displayName": "Agent Stack", "description": "Four skills: agent-orchestrator — tool-calling loops, pipelines with checkpoints, provider routing with fallback, memory architecture, plus the wallet side of reselling LLM access; agent-evals — run/trace/thread evals, LLM judges, and fixtures grown from production; agent-interop — MCP servers and clients, A2A agent cards, the MCP Registry, and gateways; agent-harness — system prompts, tool shaping, workflow-vs-agent, and auditing an agent system.", - "version": "0.25.6", + "version": "0.26.0", "author": { "name": "ssheleg", "url": "https://x.com/sshlg93" diff --git a/plugins/agent-stack/skills/agent-interop/SKILL.md b/plugins/agent-stack/skills/agent-interop/SKILL.md index 212dfa9..f755444 100644 --- a/plugins/agent-stack/skills/agent-interop/SKILL.md +++ b/plugins/agent-stack/skills/agent-interop/SKILL.md @@ -109,7 +109,7 @@ an index; the trigger lives in the file, so the two cannot drift apart. | [`references/mcp-scale.md`](references/mcp-scale.md) | the host has **more servers and tools than context** — progressive discovery, programmatic tool calling, cache interaction, and the security surface each one opens | | [`references/a2a.md`](references/a2a.md) | the other side is **another agent, not a tool** — agent cards and discovery, the task lifecycle and its terminal states, three protocol bindings, streaming versus push | | [`references/registry.md`](references/registry.md) | you are **publishing a server** or building something that consumes the registry — `server.json`, reverse-DNS namespaces and how ownership is proved, the publish flow, and the registry's three refusals | -| [`references/gateway.md`](references/gateway.md) | agent traffic needs **one controlled seam** — what a gateway must do that an API gateway does not, federation and name collisions, and where authorization actually belongs | +| [`references/gateway.md`](references/gateway.md) | agent traffic needs **one controlled seam** — what a gateway must do that an API gateway does not, federation and name collisions, where authorization actually belongs, and **delegation** — a token that says which agent acts for which person | --- diff --git a/plugins/agent-stack/skills/agent-interop/references/gateway.md b/plugins/agent-stack/skills/agent-interop/references/gateway.md index 5782291..387aafc 100644 --- a/plugins/agent-stack/skills/agent-interop/references/gateway.md +++ b/plugins/agent-stack/skills/agent-interop/references/gateway.md @@ -1,10 +1,15 @@ # The gateway layer — one controlled seam for agent traffic **Load this when:** many clients meet many servers, tool access needs to differ per caller, -or somebody has asked who is allowed to call what and you cannot answer from one place. +somebody has asked who is allowed to call what and you cannot answer from one place, or an +agent calls another **on someone's behalf** and the callee has to know for whom. **Spec pinned:** agentgateway `1.4.x` from `agentgateway.dev/docs/standalone/latest` · read 2026-08-13 +**Delegation pinned:** RFC 8693 §4.1–4.2, RFC 8725 §3.11, RFC 8785; worked example Fabric +`fabric-delegation/0.1` (`passioncode-ai/fabric-agent-contract@8e49fbd`, ADR-0144 at +`passioncode-ai/fabric@812876cf`) · read 2026-10-11 + Linux Foundation, Apache-2.0, written in Rust. This file is **vendor-neutral first**: what a gateway for agent traffic must do, then @@ -17,6 +22,7 @@ still holds. - What a gateway must do - Federation and the name-collision problem - Where authorization belongs +- Delegation — who is acting, and for whom - agentgateway, concretely - Traps @@ -112,6 +118,92 @@ Two rules that survive any product choice: probabilistic — anything consequential needs a deterministic limit or a human. See `agent-orchestrator/references/governance.md`. +## Delegation — who is acting, and for whom + +"Authority does not automatically travel" is the problem; this is the shape that carries it. +Without it, every callee sees one of two wrong things: **a shared token** (it cannot tell +callers apart, so no limit holds per caller) or **the callee's own host credential borrowed +by the caller** (a confused deputy with that service's full power). Neither says on whose +behalf the work runs. + +**The token.** One short signed document per call path, in the shape of OAuth 2.0 Token +Exchange (RFC 8693): + +| Claim | Carries | The rule that makes it work | +|---|---|---| +| `sub` | the **person** the work is for | always a person — even when the grant was given to an agent, `sub` is whoever gave it; the agents are the next claim | +| `act` | the **actor chain** (RFC 8693 §4.1) | the agent calling now outermost, earlier actors nested inside; cap the depth (Fabric: 4) | +| `aud` | **one** callee | a string, never an array: a token good at two callees is a token one of them can replay at the other | +| `scope` | the callee's capability names (§4.2) | whole names, space-delimited | +| effect ceiling | the most a call may *do* — read, draft, send, charge, delete, deploy… | external effects are not ordered against each other: a `delete` ceiling admits no `charge` | +| grant reference | the standing grant the call is under | opaque to the callee; it is what usage reports attribute spend by | +| `exp`, `jti` | minutes, not hours (Fabric: at most 600 s) | there is no long-lived token between agents; revoking the grant stops minting at once | + +Give the token an explicit `typ` (RFC 8725 §3.11) so it cannot be read as another document +the same key signs. + +**One issuer, and the issuer narrows.** A single issuer that holds the operator's consent +mints by token exchange: the caller proves itself and asks for one audience and some +capabilities, and gets a token only inside a live grant. A token for an onward call only +**narrows** its parent — same principal, one more actor, no wider effect, no later expiry, +no more remaining spend. Attenuation is the issuer's check, so a callee never reasons about a +chain it cannot see. + +**The callee verifies offline**, in a fixed order, against the issuer's published key set +(JWKS): signature first — nothing in an unsigned token is believed — then lifetime with a +small clock leeway, audience is itself, chain depth, the called capability is in `scope`, its +declared effect is under the ceiling. Keys come only from the published set, never from the +token's header (`jku`, `jwk`, `x5u`). Then it acts for `sub`, names the outermost actor as its +caller and records the chain with the work. The issuer must be up to mint; with no issuer a +call fails closed — never back to a borrowed credential. + +**The issuer is not the route.** Calls carrying a token go **direct**, caller to callee, as +`Authorization: Bearer`. Where something in the middle forwards a call because it holds the +product's credential, the delegation token rides in a header of its own beside it, so the +callee sees the chain whichever way the call came. Where this meets +the gateway (this file's own rule, not Fabric's): a gateway still enforces per-tool policy, +but the identity worth recording on its audit trail is the token's chain, not "the gateway +called". + +**The host credential is the host's.** A service's own descriptor token is for its host — +discovery, events, usage, login — and, once callers can mint, is not presented to call a +capability; existing callers that borrow it switch when the issuer exists. A +token never travels in a URL, an argument vector, a tool's arguments, a log line or a usage +report. + +**Money is a separate check against a meter.** The token's remaining-spend figure informs and +never authorizes. Before spending, the callee asks the meter with a token **of its own**: +exchanged onward with `aud` = the meter and read-only scope, because the token it holds names +itself as audience and cannot be handed over. The meter reserves against the grant's windows +and every level above (Fabric: the estate total), and refuses `limit-reached` **naming the +window** that ran out — a limit refusal without its window cannot be acted on. + +**An approval is a document, not a ceremony.** When an action needs a person, the callee +refuses `approval-required` with the action's digest — SHA-256 of its RFC 8785 canonical +JSON, so two encodings of one action cannot get two digests. The action must be plain JSON +values: a date, a map or an `undefined` member would serialise as `{}` and let two actions +share a digest, so the canonicaliser refuses them. +The approval is a signed document over that digest, the one callee, the approver and the +method, consumed once by `jti` in a store that survives a restart. Order: token, then the +meter, then consume the approval, then act — an approval is not spent when the meter refuses. + +**One refusal vocabulary** for authorisation only — `unauthenticated`, `token-expired`, +`wrong-audience`, `scope-not-granted`, `effect-above-ceiling`, `no-grant`, `grant-revoked`, +`limit-reached`, `approval-required`, `chain-too-deep`, `issuer-unavailable`, … (Fabric's +full set is seventeen). Transport and product errors keep their own codes; an unknown state is +a denial. + +**What it does not buy.** A token file readable by the operator's account is readable by any +process of that account. Delegation gives every hop an attributable name, narrowing and +per-caller limits; it does not protect against a hostile local process. That takes an +approval or a device check — and proof of possession (DPoP, mutual TLS) is a further step this +shape leaves open. + +**Worked example.** Fabric's `fabric-delegation/0.1` (contract DEC-0039, decided in Fabric +ADR-0144) implements the token, the verifier, the meter flow, the approval and the refusal +codes above — with schemas, positive/negative/semantic fixtures and a reference verifier: `docs/specification/delegation.md` in `passioncode-ai/fabric-agent-contract` +at `8e49fbd`. Read its check table (DL-13) before writing a verifier in another language. + ## agentgateway, concretely A unified HTTP and gRPC proxy written in Rust — a control plane and a data plane — under the @@ -176,5 +268,11 @@ Two consequences for whoever runs the gateway: credentials and the per-hop policy are the others. - **Federating servers you do not trust into one namespace.** Federation makes a hostile server's tools look exactly as legitimate as everyone else's. +- **Calling a service with its own descriptor token.** It works, it is unattributable, and it + hands the caller everything that token can do. Mint a delegation token for that one call. +- **`aud` as an array, or a token without `typ`.** Both let one signed document be replayed + where it was never meant to be accepted. +- **Checking spend from the token's claim.** It was the remainder at mint time; the meter + decides. - **Forgetting the reconnect.** MCP notification delivery is best-effort; a hop in the middle makes a dropped subscription more likely, not less. Poll as well.