Identities for entities did.bot
agent llm did
didbot plan scope-policy.md
9.0 kB
Markdown
at commit 18ba4fe0


id: scope-policy title: An agent cannot be granted what its owner has not allowed status: open crates: [didbot-serve, didbot-pds] dependsOn: [oauth, policy-store] exitCriterion: > An app requesting a scope outside an agent's profile is refused at the authorize step, and a hard-blocked scope is refused whatever policy says. #

scope-policy #

Superseded. policy settles the model this epic assumed — denials only, evaluated at the write, from three sources — and where the two disagree, this file is wrong. Read that one first.

This was blocked and is not. Both things it waited on are in the tree: oauth's granular grammar landed (crates/didbot-serve/src/oauth/scope.rs, with Scope::contains, Scope::intersect and ScopeSet::intersect), and the hook this epic exists to fill is called on the live path — ScopePolicy::ceiling, at PAR, whose only implementations today are GrantAnyScope and ConfiguredCeiling. A hook waiting for an implementer is not a dependency that has not landed. The policy-store half is not blocking either, by this file's own "from server configuration first" item.

The ceiling is not policy #

What an app asks for is intersected with what this agent may ever hold, and the intersection, taken at every use, is what the token may do. Calling that a policy overstates it, and the honest description is worth keeping in front of whoever implements the rest of this file:

  • It is the deployment's statement, not the owner's and not a rule in policy's tree. It says the most any agent on this server may ever hold, and it is keyed on neither the agent nor the client — both arguments to ScopePolicy::ceiling are ignored by every implementation there is.
  • It applies by intersection, and can only subtract. ConfiguredCeiling::new keeps an atom only if the compiled-in ceiling (GrantAnyScope) already admits it whole, so the widest a configured ceiling can be is the compiled-in one. That is what lets a scope live in a configuration file at all — see plan/policy.md's "configuration may narrow, never grant".
  • It runs where the tree runs, at the pushed authorization request, and its narrowing is written into the same decision record and the same evaluation log as the tree's denials. It runs again at every token issue and every write, against the login's whole request, so an approval covers that request and not only what the record granted; see oauth.

Retiring it has a precondition. A ceiling belongs in the tree the day the tree can express it, and today it cannot: didbot_policy::Subject::Grant carries a client_id and the requested scopes, and no account, so a per-account rule has nothing to match on. Adding that field to the grant subject is the work that closes the gap; until it lands, one blunt deployment-wide ceiling is the only shape available.

  • **Built at the decision, not yet on the wire format this item also
    names.** `ScopeSet::narrow` returns the grant and the atoms the ceiling
    did not give in full, and `oauth::decision::Verdict` carries `narrow`
    with `granted`, `cut` and the rule that cut it — see
    [oauth](oauth.md)'s decision record, which is what an agent and the
    consent page both read. `ScopeSet::intersect` still refuses, which is
    what a later ceiling check against an issued grant wants. What is *not*
    built is the refusal's wire format: it is an `invalid_scope` JSON body,
    not a `403` with
    `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. The
    write routes send that challenge, in the `DPoP` scheme, for a write
    outside its token's scope; see Done.
    
  • **The configuration half is built.** `[oauth] scope_ceiling` in
    `didbot-config` is an enumeration of scope atoms, and
    `oauth::authorize::ConfiguredCeiling` is the `ScopePolicy` built from
    it. It may only narrow: every atom is checked against the compiled-in
    ceiling and dropped unless that ceiling admits it whole, so an operator
    naming a hard-blocked capability gets nothing for it.
    
    **The owner's-records half is built beside it, not in its place.**
    `crates/didbot-serve/src/policy_poll.rs` builds the operator's
    `bot.did.policy` and `bot.did.policyBinding` records into the gate, and
    PAR asks that gate about a `Subject::Grant` alongside the ceiling.
    

Done #

  • **Requested directly is not yet a thing that can be asked for**, so
    today this is a flat refusal rather than the "cannot hide inside a
    permission set" this item describes. The distinction needs a request
    shape that says "this atom alone, deliberately", which nothing has.
    
    A narrow ceiling cannot express this and must not be asked to. A
    blocked atom overlaps the atoms a ceiling *does* admit —
    `transition:generic` covers `repo:*` and `rpc:*` — so a ceiling check
    on its own pays it out in those pieces, which is a wider grant than
    the rest of the request would have got. `ScopeSet::narrow` projects
    from the requested atom onto the ceiling and only within one kind, so
    that holds even with no list loaded, and the list is what makes the
    answer a refusal rather than a silence.