Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.
recordings/B2.3.mp4Subset-only lets a privileged user hand an agent authority it must never hold; ceiling-only lets the agent exceed the person who asked.
Token exchange that intersects presented scope with the actor's ceiling, and records the chain.
Run both narrowing rules against a request that passes one and fails the other.
The access token your agent holds is a password: whoever reads it out of a log can spend it. Three published standards fix that — an SVID says which workload is calling, RFC 8693 says on whose authority, and RFC 8705 binds the token to the certificate that earned it. Most estates implement the middle one and stop.
identity.token_exchange in your tree lets any hop ask for anything the human may delegate. You make it narrow, and you make act nest so actor_chain reads dana => advisor => workflow instead of naming only the last agent to touch it.Making a delegation chain narrow rather than merely exist: each hop bounded by what the hop before it held, and the actor claim nested so an investigator reads every hop instead of the last one.
Subset-only delegation lets a privileged user hand an agent authority it must never hold; ceiling-only lets the agent exceed the person who asked.
Token exchange that intersects the presented scope with the actor's ceiling, and records the chain.
Delegations whose result sits inside both the request and the ceiling, and the recorded depth of each chain.
Measure this
| what to measure | denominator or target |
|---|---|
| delegations whose result sits inside both the request and the ceiling | share of delegations |
| delegation chain depth | recorded per chain |
AI agent pod authorization downstream
server service
+-------------+ +-------------+ +-------------+
| X.509 SVID | --mTLS--> | RFC 8693 | --> | re-derives |
| spiffe://.. | layer 1 | exchange | | x5t#S256 |
| | | | | from ITS |
| user token | ---------> | sub = user | | own TLS |
+-------------+ layer 2 | act = agent | | connection |
| cnf = x5t | +-------------+
+------+------+ layer 3
|
scope issued = requested
& presented (subset)
& actor ceiling (never-exceed)Mitigates: T3 Privilege Compromise · T8 Repudiation · T14 Human Attacks.
The agent has its own identity now. This lesson is about carrying the user's authority alongside it — without losing it, amplifying it, or handing it to whoever picks the token out of a log.
Three layers do that, and each one answers a question the layer above it cannot. Skipping any of them leaves a specific, named hole.
Layer 1 — mTLS with an X.509 SVID. Is this the workload it claims to be? The agent pod presents a client certificate whose URI SAN is a SPIFFE ID: spiffe://cybertravels.com/ns/prod/sa/agent-alpha. The platform issues it on attestation (B2.2), it lives minutes rather than months, and it rotates without anybody being told. Nothing downstream trusts a name in a header again.
Layer 2 — OAuth on-behalf-of, RFC 8693 token exchange. On whose authority? The agent presents two tokens: the user's (subject_token) and its own SVID (actor_token). The authorization server returns one access token whose sub is still the user and whose act claim names the agent. Delegation is written down rather than inferred, and it nests — act.act records the hop before — so alice → orchestrator → agent-alpha survives into the audit log.
Layer 3 — RFC 8705 certificate-bound tokens. Is the presenter the one it was issued to? The issued token carries a cnf claim holding x5t#S256: the SHA-256 thumbprint of the client certificate that asked for it. The downstream service recomputes the thumbprint of the certificate on its own TLS connection and compares. A bearer token is a password; a bound token is useless to anyone holding it without the private key that earned it.
On top of the three layers, delegation still has to narrow, and it has to satisfy two rules rather than one:
Subset of presented. The issued token carries no more scope than the incoming one. Stops the agent inventing authority.
Within the actor's ceiling. The issued token carries no more than the receiving agent may ever hold. Stops a privileged user handing an agent authority the agent must never have.
Neither is sufficient alone, and they fail in opposite directions. Subset-only lets an admin's request give a low-trust agent payments:refund — legitimately, and it looks correct in every log. Ceiling-only lets an agent exceed the person who asked, which is B1.6. The issued scope is the intersection.
On the standards. RFC 8693 (token exchange) and RFC 8705 (mTLS client authentication and certificate-bound access tokens) are published and widely implemented — none of this is future work. The IETF OAuth working group additionally has a live draft for AI agents acting on a user's behalf, which adds agent-specific metadata to the same exchange. It is a draft, not an RFC, and nothing in this lesson depends on it: all three layers are buildable on RFC 8693 and RFC 8705 as they stand today.
What this control closes. Both narrowing rules, at every hop, plus a binding that makes the token worthless off the connection that earned it.
Everything above is the protocol, modelled in the standard library so this skill runs with the internet switched off. That proves RFC 8693 and RFC 8705 work. It does not prove the product you are about to deploy implements them, which is a different question and has a different answer.
labs/tools/keycloak-obo/ downloads Keycloak 26.0.7, starts it with mTLS, configures this realm and runs the same three checks. Three of them behaved as the specifications describe:
the agent asks for its own token over plain HTTP, with no certificate:
{"error":"invalid_request",
"error_description":"Client Certification missing for MTLS HoK Token Binding"}
its x5t#S256 thumbprint (computed with openssl):
BuTPvYMaI3z-suLCcWsnFHDCv_6VQdDrYwLlf70Sjfg
the cnf claim on the token Keycloak issued:
{"x5t#S256": "BuTPvYMaI3z-suLCcWsnFHDCv_6VQdDrYwLlf70Sjfg"}
the same token, presented to a resource server that compares:
legitimate agent HTTP 200
the thief HTTP 403 cnf mismatch - token was not issued to this client
no client cert HTTP 403 no client certificate presented
The thief's certificate is signed by the same CA and is therefore trusted. Trust is not what separates them.
And two did not. These are the ones worth carrying out of this lesson:
Keycloak's standard token exchange emits no act claim — with or without an actor_token. It returns 200 either way and produces the same token. azp does name the agent, so the information is not lost, but azp is one value and act nests: a three-hop chain has nowhere to go. The delegation chain reconstructed in the next cell is something you write a protocol mapper for.
The exchange drops the certificate binding. The direct token carries cnf. The exchanged token — requested over the same mTLS connection, with the same certificate — does not. So the token the agent actually carries downstream, the one issued for acting on alice's behalf, is a bearer token again, and the theft the binding was bought to prevent is back.
Neither is a reason not to build this. Both are reasons to check your own deployment rather than assume the control is on because the feature exists.
Every hop is on the token, so the chain reconstructs from the token alone rather than by correlating four services' logs on timestamp. This is the thing B1.14 could not do.
The two findings above are what you get from checking a deployment rather than reading its design document, and CyberTravels has four agents and a payments API to check. The procedure walks every hop — user, agent, MCP server, tool, downstream — looks for token passthrough, and refuses to accept a matching sub as proof of delegation, because impersonation produces one too. This is the file in this repository:
The skill — skills/attestation/identity-chain-verifier/SKILL.md
name: identity-chain-verifier
description: >-
Verify workload identity and end-to-end on-behalf-of propagation across
user, agent, MCP server, tool and downstream. Use to check for token
passthrough, to distinguish delegation from impersonation, or when asked
whether a downstream can attribute an action to the original user.
allowed-tools: Bash, Read, GrepControls: Control 3 — workload identity and OBO attribution
Delegation is cryptographically distinguishable from impersonation, and token passthrough is visible in the audience claim. This control is genuinely verifiable.
When a deployment claims to act on behalf of a human, and whenever that claim appears in an audit trail. Delegation is impossible without an actor token, so the presence of one is proof rather than assertion — which is why this control reaches HIGH confidence and most do not.
Input — the fixture committed at the top of scripts/identity_chain_verifier.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.
Output — the opening lines of a real run:
the access token the payments API will actually see:
{
"act": {
"sub": "spiffe://cybertravels.com/ns/prod/sa/agent-alpha"
},
"aud": "https://payments.cybertravels.internal",
"cnf": {
The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.
{
"deployment_id": "str",
"chain": [
{"hop": "user|agent|mcp|tool|downstream", "principal": "str",
"audience": "str", "token_type": "x509|jwt",
"mode": "delegation|impersonation|passthrough", "ttl_seconds": 0}
],
"passthrough_violations": [{"from_hop": "str", "to_hop": "str", "audience": "str"}],
"delegation_proven": true,
"jwt_only_hops": ["str"],
"verdict": "PASS|PARTIAL|FAIL"
}
Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.
The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.
Install it, once. From the folder you cloned. --all does every agent it finds on your machine:
python3 scripts/install_skills.py --all --lessons B2.3Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.
Then pick b2-3-delegation-that-narrows:
| agent | install just for this agent | start it |
|---|---|---|
| Claude Code | python3 scripts/install_skills.py --tool claude --lessons B2.3 | type /b2-3-delegation-that-narrows |
| GitHub Copilot (VS Code) | python3 scripts/install_skills.py --tool copilot --lessons B2.3 | type /b2-3-delegation-that-narrows in Chat |
| Cursor | python3 scripts/install_skills.py --tool cursor --lessons B2.3 | type / in Agent chat and search for it |
| Codex | python3 scripts/install_skills.py --tool agents --lessons B2.3 | type $b2-3-delegation-that-narrows, or run /skills |
No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.
python3 scripts/lesson.py B2.3Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.
python3 scripts/checkpoint.py --at B2.3 --out work/cybertravels
python3 scripts/checkpoint.py --at B2.3 --diffOn Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.
Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.
Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.
If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.
The verifier skill loads and reports its shape: the description an agent routes on, the tools it may use, and a procedure that walks every hop of the chain rather than checking the token it was handed. Read its failure modes against the Keycloak findings above — a matching sub is not delegation, and a chain with no audience check cannot see passthrough at all.
Find your token exchange and check three things: does it set an act claim, does it check the actor's ceiling as well as the subset rule, and does anything downstream look at cnf? Most implementations do the subset rule only — it is the one the specification example shows.
Tools used
Skill source: skills/attestation/identity-chain-verifier