Skip to content

Fix OAuth scope satisfaction to AND-of-ORs (required means required)#2778

Open
SamMorrowDrums wants to merge 3 commits into
mainfrom
sammorrowdrums-fix-oauth-scope-and-of-ors
Open

Fix OAuth scope satisfaction to AND-of-ORs (required means required)#2778
SamMorrowDrums wants to merge 3 commits into
mainfrom
sammorrowdrums-fix-oauth-scope-and-of-ors

Conversation

@SamMorrowDrums

@SamMorrowDrums SamMorrowDrums commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

Tool scope requirements conflate two different relationships and collapse both into a single OR list, which makes PAT tool‑filtering and OAuth scope‑challenges over‑permissive:

  1. Conjunction (AND) — a tool needs several different capabilities. e.g. ui_get reads labels (repo) and teams (read:org).
  2. Hierarchy / substitution (OR) — a single requirement is satisfied by itself or a higher (ancestor) scope. e.g. read:org is satisfied by read:org | write:org | admin:org.

These compose as AND‑of‑ORs: satisfy every required scope, where each required scope can be met by itself or an ancestor scope.

Previously AcceptedScopes = ExpandScopes(required) was checked with OR, so a token holding only repo was treated as satisfying ui_get (which also needs read:org): the PAT filter showed the tool and no scope challenge was issued, then it failed at runtime on the org call. The generated docs also rendered multiple required scopes as Required OAuth Scopes (any of), which is wrong for AND requirements.

Fix

  • RequiredScopes is the single source of truth for all allow/challenge decisions. Required means required.
  • Satisfaction = AND‑of‑ORs, implemented by expanding the token downward through the existing scope hierarchy (repo → public_repo, security_events; admin:org → write:org, read:org; …). A tool is satisfied iff, for every required scope, the expanded token set contains it.
  • AcceptedScopes is now display‑only metadata and no longer drives any filter/challenge/missing decision.
  • Scope challenges now request only the missing required scopes (merged with the user's existing scopes for the scope= parameter).
  • Docs: removed the (any of) rendering — all listed required scopes are AND.

Affected tools

Exactly 3 tools declare multiple required scopes, all {repo, read:org}, all genuinely AND:

  • ui_get (pkg/github/ui_tools.go)
  • list_issue_types (pkg/github/issues.go)
  • list_issue_fields (pkg/github/issue_fields.go)

Security tools are behavior‑neutral

All security tools (code_scanning.go, secret_scanning.go, dependabot.go, security_advisories.go) declare a single {security_events} scope. A single required scope has nothing to conjoin, and repo already expands to security_events via the hierarchy, so the AND change is behavior‑neutral for them: a repo token still satisfies them, and a public_repo‑only token was already hidden before this change. Explicit neutrality tests were added at both the scope and filter layers.

Scope filtering is best‑effort, not an authorization boundary (fail‑open)

This is the guiding posture, now encoded in comments and docs/scope-filtering.md: scope filtering is a best‑effort UX nicety for classic ghp_ PATs only, not an authorization boundary — the GitHub API is the source of truth. The server fails open: it only hides (or challenges) when confident the token cannot work, and prefers showing the tool when access is plausible. Filtering stays gated to ghp_ classic PATs and is skipped when scopes can't be fetched. Fine‑grained / OAuth / GitHub App tokens are unaffected.

Documented limitations (pre‑existing; intentionally not solved here)

The scope hierarchy only models ancestor substitution, so it does not cover every way a tool can legitimately be satisfied:

  1. Sibling scopes outside the hierarchy. Code scanning alerts on a public repo are readable with public_repo, a sibling of the declared security_events (both children of repo) — token expansion can't bridge siblings, so a public_repo‑only PAT gets security tools hidden even though it could read public alerts.
  2. Org roles are invisible to scopes. A security‑manager role grants access orthogonally to OAuth scopes; we can't see it at filter/challenge time.
  3. Public‑vs‑private repo is unknowable at filter time, and which scope suffices depends on it.

A faithful model would make RequiredScopes a CNF list of OR‑groups (groups AND‑ed, members within a group OR‑ed), with code scanning as the motivating example: security_events OR public_repo OR repo. That structure is deliberately not built in this PR (YAGNI); the fail‑open posture keeps these cases from being wrongly hidden.

No tool declarations or scopes were changed; tool input schemas are untouched (toolsnaps unchanged).

Validation

  • script/lint — 0 issues
  • UPDATE_TOOLSNAPS=true go test ./... then go test -race ./... — green, no .snap changes
  • script/generate-docs — regenerated README.md, docs/feature-flags.md, docs/insiders-features.md; hand‑updated docs/scope-filtering.md
  • script/test — green

Split out of the PR #2751 review discussion.

Tool scope requirements conflate two relationships: conjunction (a tool needs
several different capabilities, AND) and hierarchy/substitution (a requirement
is met by itself or a higher scope, OR). Previously both collapsed into one OR
list checked against the expanded AcceptedScopes, so a token holding only repo
was treated as satisfying ui_get (which also needs read:org): the PAT filter
showed it and no scope challenge was issued, then it failed at runtime.

Make RequiredScopes the single source of truth and evaluate satisfaction as
AND-of-ORs: expand the token downward through the hierarchy and require every
declared scope to be present. AcceptedScopes becomes display-only metadata.

- HasRequiredScopes: AND semantics over requiredScopes.
- ToolScopeInfo.Satisfies (was HasAcceptedScope) + precise MissingScopes.
- scope_filter: filter on RequiredScopes; keep read-only repo-only special case.
- scope_challenge: challenge with only the missing required scopes.
- generate-docs: drop "(any of)" rendering; all required scopes are AND.

Affects ui_get, list_issue_types, and list_issue_fields (all {repo, read:org}).
Filtering stays gated to classic ghp_ PATs and fails open; fine-grained/OAuth/
app tokens are unaffected. Split out of the PR #2751 review discussion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums SamMorrowDrums requested a review from a team as a code owner June 26, 2026 14:12
Copilot AI review requested due to automatic review settings June 26, 2026 14:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes OAuth scope satisfaction for classic PAT tool filtering and OAuth scope challenges by treating multiple required scopes as AND requirements, while still allowing hierarchy-based substitution (a higher scope satisfies a lower one). This aligns runtime behavior, scope challenges, and generated documentation with the intended “required means required” semantics.

Changes:

  • Update scope satisfaction logic to AND-of-ORs by expanding the token’s scopes downward through the scope hierarchy and requiring all declared RequiredScopes.
  • Make OAuth scope challenges request only the missing required scopes, merged with the user’s existing scopes for the scope= parameter.
  • Update generated docs to remove the misleading “(any of)” phrasing and add a short preamble clarifying AND semantics for flagged/insiders tool listings.
Show a summary per file
File Description
README.md Removes “(any of)” wording so multiple required scopes are rendered as AND.
pkg/scopes/scopes.go Changes HasRequiredScopes semantics to require all declared required scopes via downward token expansion.
pkg/scopes/scopes_test.go Updates/extends tests to cover AND behavior and hierarchy satisfaction.
pkg/scopes/map.go Updates ToolScopeInfo satisfaction/missing-scope logic to use required scopes with hierarchy expansion.
pkg/scopes/map_test.go Updates tests for ToolScopeInfo satisfaction and precise missing-scope computation.
pkg/http/middleware/scope_challenge.go Scope challenge now requests only missing required scopes and describes them in the error.
pkg/github/scope_filter.go PAT tool filtering now uses RequiredScopes as authoritative and enforces AND semantics.
pkg/github/scope_filter_test.go Updates filter tests to model {repo, read:org} as true AND requirements.
docs/insiders-features.md Removes “(any of)” and adds a preamble clarifying AND + hierarchy substitution.
docs/feature-flags.md Removes “(any of)” and adds a preamble clarifying AND + hierarchy substitution.
cmd/github-mcp-server/generate_docs.go Updates tool doc rendering to always label required scopes as required (AND).
cmd/github-mcp-server/feature_flag_docs.go Prepends scope semantics clarification to feature-flag/insiders generated sections.

Review details

  • Files reviewed: 12/12 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread pkg/scopes/map.go
Comment on lines +82 to 84
func (t *ToolScopeInfo) Satisfies(userScopes ...string) bool {
if t == nil || len(t.RequiredScopes) == 0 {
return true // No scopes required
SamMorrowDrums and others added 2 commits June 26, 2026 16:17
Correct the over-claim that the scope hierarchy covers all OR cases. The
hierarchy only models ancestor substitution, not sibling alternatives, so it
cannot represent every way a tool may legitimately be satisfied.

- Reword HasRequiredScopes doc: AND-of-ORs is ancestor-only; note the
  best-effort/fail-open posture and make the future CNF extension concrete using
  code scanning as the motivating example (security_events OR public_repo OR repo).
- Note in CreateToolScopeFilter that filtering is a best-effort UX filter, not an
  authorization boundary (gated to ghp_ PATs, skipped when scopes can't be fetched).
- Verify security tools ({security_events}) stay behavior-neutral under AND: a
  repo token still satisfies them; a public_repo-only token was already hidden
  before. Add explicit neutrality tests at the scope and filter layers.
- docs/scope-filtering.md: add a Limitations and Fail-Open Posture section
  (sibling scopes, org roles, repo visibility) and a best-effort intro note.

No tool declarations or scopes changed; no CNF structure built.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Flesh out the Limitations section: public-vs-private repo ambiguity (code
scanning public_repo vs private security_events/repo), sibling-OR alternatives
naming the four security tools, org roles invisible to scopes, and other token
types skipped (gated to ghp_) as fail-open by design. Note the OR-groups
extension point.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants