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.
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 diffCheckout locally
git fetch origin pull/231/head:pr-231
git checkout pr-231