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 →
mergedhumanPR #231Identity

Enforce Run Grant permissions on join and every mailbox action

nandatown run --identity joins each role through a Run Grant: a controller-signed authorization for one disposable session key, for one run, with named permissions.

Author

Status
Merged
Merged on
Aug 29
Branch
fix/enforce-grant-permissions

Description

The pitch.

nandatown run --identity joins each role through a Run Grant: a controller-signed authorization for one disposable session key, for one run, with named permissions. The coordinator verified the signature chain at join but never checked the permissions ,  require_permission() existed with no call sites. A grant saying "claim and ack only" produced a session that could also send.

Beyond the bug, this is the primitive the "Test the Path" proposal depends on: Town must be able to show "the agent did something it was told not to." Without enforcement there was no such thing as a denied action, so no bundle could contain that evidence.

# What changed

- Enforced on join and every mailbox action. join, claim, send, and ack are checked against the grant. A refusal is recorded as intent + grant_permission_denied event — the attempt is evidence. GET /participants and GET /inbox/notify stay open (no such permission exists in the vocabulary).
- Fails closed across restarts and workers. Permissions live with the session (participants.permissions_json, written in the same transaction as the session) and pinned identities in a new run_identities table, replacing two process-local dicts. ALTER TABLE migration for older databases. SQL NULL = token-joined; '[]' = granted nothing.
- No sidestepping the grant. A role pinned to a portable identity cannot join with a bare token (403 grant_required). A wrong token is refused without writing anything, so knowing a run_id alone can't pad an attested bundle. A superseded wider grant cannot be replayed to re-widen a session the controller has narrowed (grant_issued_at is recorded; older grants never replace newer ones).
- Validated before trusted. verify_grant rejects permissions that isn't a list of strings (a signed null used to yield an unrestricted session; "acknowledged" matched ack by substring) and a non-numeric issued_at. make_grant([]) now means no permissions, not all.
- Runner keeps up. external hand-off now includes TOWN_GRANT for pinned roles (it only sent TOKEN, which is refused) and creates the state dir. A harness that token-joins a pinned role ends the run immediately with a harness_refused_grant runner event instead of waiting out the timeout.
- Reported. report.md adds "Refused by grant permissions: N". README, docs/architecture.md, examples/byoa_seller.py, and the new agent scaffold state that the stdlib example is token-only (a grant join needs an Ed25519 session proof).

# Test plan

- [x] tests/test_grant_permissions.py (23): each permission denies its action; denials take no lease, deliver nothing, leave drop_wakeup/lost_ack faults armed; denied join mints no session (row-level); malformed permissions ×4 reject the grant; [] ≠ default; restart persistence across two build_app() on one DB, including pins; token join refused for pinned roles, wrong token leaves no event; older wider grant does not re-widen; external hand-off carries the grant and the run passes; token-only harness ends early; legacy-DB migration
- [x] Full suite: 181 passed
- [x] Track runs PASSED: quote-clean --identity, quote-crash-restart --identity (restarted seller re-joins via grant), quote-llm --identity, quote-clean, quote-lost-ack; Lab marketplace
- [x] Three independent review passes; every finding folded in

# Known gaps / follow-ups

- No --permissions flag on nandatown identity grant or the runner yet — a restricted grant is only producible by hand.
- Grant revocation is still TTL-only.
- _spawn_participant still passes TOKEN to pinned roles (dead credential; the town refuses it).
- require_permission opens one extra SQLite connection per mailbox action; could be folded into session_owner.

Try it

Open PR on GitHubView diff

Checkout locally

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