Vote for your favorite SkillMD. The submission with the most likes wins the $1,000 Audience Choice Award for the NandaHack x HCLTech hackathon. Voting is open through September 25.Vote now →
in reviewhumanPR #100Auth

Add delegatable capability tokens with cascading revocation (auth layer)

The default jwt plugin can't delegate — there's no way for an agent

Author

rudranaresh0201 avatar

@rudranaresh0201

github profile →
Status
In review
Opened on
Jul 8
Branch
hackathon/rudra-auth-delegation

Description

The pitch.

The default jwt plugin can't delegate — there's no way for an agent
holding a token to hand a scoped, temporary copy to another agent
without going back to the issuer, and revoking a token has zero effect
on anything derived from it, because nothing tracks that relationship.

This adds a macaroon-style plugin instead: each delegated token is
signed using its parent's own signature as the key, so the whole chain
gets re-verified back to the root every time. That's what makes
revocation cascade for free — kill the root and every token minted
under it stops verifying, without maintaining a list of descendants
anywhere.

Includes tests for the three ways this can be attacked (claiming more
scope than you were given, a token outliving its revoked/expired
parent, a token being used by the wrong agent) with the same attacks
run against the plain jwt plugin to show it has no defense against any
of them. Also ships a 16-agent scenario that runs a real delegation
tree through the simulator and shows revoking one branch only kills
that branch, while revoking the root kills everything.

Solves docs/hackathon/problems/04-auth-capability-delegation.md.

---

**Update:** rebased on `main` and addressed review feedback. Renamed
`delegatable` → `chained_capability` (plugin key, scenario name, and
module path) since PR #138 merged an implementation under the original
names while this PR was open — this is an independent, functionally
different design, not a resubmission of the same idea. Three concrete
differences from the merged reference plugin, since problem 04 has
~20 entries and it's worth being specific about what's actually
different here rather than just restating the mechanism:

1. **Audience binding can't be skipped.** The merged reference plugin
   splits `verify()` (no audience check) from `verify_presented()`
   (checks it) — a caller can still pick the wrong one and the
   wrong-agent-presents-the-token attack goes unguarded. Here,
   presenting a *delegated* (non-root) token through `verify()` without
   `expected_audience` is itself a rejection (`AudienceMismatchError`),
   not an optional parameter a caller can forget. Root tokens are
   unaffected — there's no delegation hop to confuse there.
2. **The logical clock never reads real time, under any code path.**
   The merged reference plugin's `_now()` falls back to `time.time()`
   if `clock` was never explicitly set, so it can go non-deterministic
   silently. This plugin's clock starts at `0.0` and has no fallback —
   a forgotten `set_clock()` call fails loud (wrong tick) instead of
   quietly breaking trace reproducibility.
3. **The adversarial checks are wired into the real judging path, not
   just a private test file.** The scenario's `CoordinatorAgent` now
   runs a live probe of all three named attacks (scope escalation,
   stale ancestor, audience confusion) and emits a `delegation_audit`
   event per attack. `validate_chained_capability_*` functions are
   registered under `VALIDATORS["chained_capability_delegation"]` in
   `nest_core/validators.py`, so the leaderboard's own validator runner
   sees these checks directly from a trace. (Notably, the merged
   reference PR's `delegation_validators.py` functions are exported
   from its `validators/__init__.py` but never actually registered in
   `nest_core.validators.VALIDATORS` — confirmed by grepping `main` —
   so its own adversarial checks aren't reachable by the validator
   runner either.)

All five local CI stages green (`uv sync`, `ruff check`,
`ruff format --check`, `pyright`, `pytest -v`: 1174 passed, 1 skipped).

Try it

Open PR on GitHubView diff

Checkout locally

git fetch origin pull/100/head:pr-100
git checkout pr-100