Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion SKILL-CARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/evidence/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/evidence/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/agent-stack/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/agent-stack/skills/agent-interop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
100 changes: 99 additions & 1 deletion plugins/agent-stack/skills/agent-interop/references/gateway.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading