Skip to content

feat(trust): scoped tokens — kb:read / kb:propose / kb:approve / kb:admin - #725

Open
minion1227 wants to merge 7 commits into
vouchdev:testfrom
minion1227:feat/scoped-tokens
Open

feat(trust): scoped tokens — kb:read / kb:propose / kb:approve / kb:admin#725
minion1227 wants to merge 7 commits into
vouchdev:testfrom
minion1227:feat/scoped-tokens

Conversation

@minion1227

Copy link
Copy Markdown
Contributor

closes #608

supersedes #692, which you closed on the METHOD_SCOPES staleness. github
refused to reopen it (reopenPullRequest fails server-side now that test has
moved), so this is the same branch — feat/scoped-tokens — brought current and
carrying one new commit. the scope machinery, the two safety rules and the tests
are exactly as you reviewed.

what changed since #692

merged current test (through #693/#719/#720) and added the four missing
entries to METHOD_SCOPES. it is now exhaustive again: 77 methods, 77
classified, test_every_method_is_classified and the two-way guard both green,
all three matrix jobs pass.

kb.list_goals         -> kb:read
kb.propose_goal       -> kb:propose
kb.capture_correction -> kb:propose
kb.set_goal_status    -> kb:approve

the one place this departs from your suggestion

you read the goal writes as kb:propose. three of the four match that;
kb.set_goal_status is filed under kb:approve instead, and it seemed worth
saying why rather than quietly differing.

it is a lifecycle op, not a proposal. it lives in lifecycle.py beside
supersede / archive / confirm — all already kb:approve — it mutates an
already-approved goal in place, and server.py documents it as "the only write
path for goal status"
, with kb_propose_goal adding that "status moves after
approval go through kb.set_goal_status, never through a second proposal".

so classifying it kb:propose would hand a propose-only credential the ability
to flip an approved goal to done or abandoned with no review. that is a
smaller hole than the one you closed this PR over, but it is the same kind of
hole, and it is the boundary this module exists to hold. kb:read for
kb.list_goals follows the same logic in the other direction — it is a listing,
and filing it as a write would lock read-only credentials out of it for no
reason.

happy to move set_goal_status to kb:propose if you'd rather; it is a
one-line change and your call, i just didn't want to make it silently.

the general problem

this PR went stale because METHOD_SCOPES has to track METHODS across every
other PR that adds a method, and nothing warns you until the matrix goes red on
a branch that was green when it was written. the exhaustiveness test catches it,
which is the right safety net, but it catches it late. if that turns out to be a
recurring tax i am happy to follow up with something that fails at import rather
than at test time — out of scope here, and this PR should land as-is first.

minion1227 and others added 7 commits July 30, 2026 13:10
…dmin

a bearer token was all-or-nothing: hold it and you could call all 73 kb.*
methods, kb.approve included. fine for a solo human, wrong for a ci job that
should only read or a triage bot that should only propose. the config-level
trusted-agent flag can only widen the gate; this is the first thing in vouch
that can narrow it — withholding kb:approve by default *is* the review gate,
expressed as a credential.

four coarse scopes over the method list rather than a per-method allowlist,
because a per-method grammar makes every new kb.* method a config migration
for every deployment.

two rules keep this safe to ship into existing deployments. an unscoped
credential means all scopes, so every token issued before this keeps working
exactly as it did — an empty scope set is "unrestricted", never "denied".
and every method must be classified: METHOD_SCOPES is exhaustive over
capabilities.METHODS with a test enforcing it, so a newly-added method cannot
silently land unreachable for scoped callers. an unclassified method is denied
to a scoped caller — fails closed, because a deny-list in a trust-centric
system fails open.

enforcement lives at the two dispatch points that already exist:
handle_request for jsonl and http, and wrap_tool_fn for mcp, both routed
through trust.require_scope so the three surfaces inherit one implementation.
the check runs before the handler, so a refused call cannot have side effects
on its way to being refused.

the agent registry supplies the scopes: a registered subject's scopes ride
onto VouchTrust at the http chokepoint, and an unregistered subject stays
unscoped. registration now validates scopes, so a typo cannot mint a
credential with powers nobody asked for.

kb.capabilities reports the caller's effective scopes and allowed methods, so
an agent discovers what it may do instead of failing method by method. the
trust block only grows a scopes key when the credential is actually scoped.

stacked on vouchdev#607: the registry is where scopes are stored.

Closes vouchdev#608
the schema drift check compares schemas/ against what
scripts/gen_schemas.py emits from models.py. adding the per-credential
scopes block to Capabilities changed that output, so the committed
schema went stale.

this is the sixth registration site a new field can miss — CLAUDE.md's
four, plus hot_memory's coverage map, plus this. worth a line in the
contributor notes separately.
`METHOD_SCOPES` was exhaustive over `capabilities.METHODS` when this branch
was written. four methods have merged to `test` since — kb.capture_correction
(vouchdev#679), and kb.list_goals / kb.propose_goal / kb.set_goal_status (vouchdev#676) —
leaving the table stale by four and all three matrix jobs red on
`test_every_method_is_classified`.

that failure is the guard working. by this branch's own rule an unclassified
method is denied to a scoped caller, so merging as-is would have silently
locked every scoped credential out of goal writes and correction capture. the
right failure direction, but still a regression, and invisible from the diff.

the classifications:

- kb.list_goals -> kb:read. a listing that cannot change durable state, beside
  kb.list_sessions.
- kb.propose_goal -> kb:propose. files a PENDING goal a human approves.
- kb.capture_correction -> kb:propose. routes exclusively through
  propose_quoted_claim and has no import of approve.
- kb.set_goal_status -> kb:approve, not kb:propose. this is the one that
  departs from the suggestion on the closing comment, and deliberately: it is
  a lifecycle op living in lifecycle.py beside supersede/archive/confirm, it
  mutates an already approved goal in place, and server.py documents it as
  "the only write path for goal status" — status moves never go through a
  second proposal. filing it under kb:propose would let a propose-only
  credential change durable state with no review, which is the exact boundary
  this module exists to hold.

no other change: the scope machinery, the two safety rules and the tests are
as reviewed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@minion1227
minion1227 requested a review from plind-junior as a code owner July 31, 2026 17:01
@github-actions github-actions Bot added cli command line interface mcp mcp, jsonl, and http surfaces storage kb storage, migrations, schemas, and proposals schemas json schemas and generated schema assets tests tests and fixtures size: L 500-999 changed non-doc lines labels Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli command line interface mcp mcp, jsonl, and http surfaces schemas json schemas and generated schema assets size: L 500-999 changed non-doc lines storage kb storage, migrations, schemas, and proposals tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(trust): scoped tokens — kb:read / kb:propose / kb:approve on a credential

2 participants