Skip to content

Commit 447ede0

Browse files
test(scopes): pin deliberate OAuth scope-model assumptions
Encode three conscious, non-obvious decisions as named, commented tests so they stay visible to developers revisiting the scope model: - PAT filtering keeps read-only repo-only tools visible without any scope (public-repo access is useful), while the OAuth challenge path has no such exception and still challenges for repo. The asymmetry is intentional. - workflow is a grantable/supported scope (PATs carry it; OAuth can request it up front) but is intentionally never declared on a tool, so the challenge path never asks for it; there is also no scopes.Workflow constant. A test fails if a tool ever requires it, forcing a deliberate decision. - Multi-scope requirements are treated as AND because the declaration can't express OR; hierarchy substitution still applies. OR-groups remain the escape hatch if a real need arises. Add cross-referencing comments at the two code sites (scope_filter.go, oauth.go). Docs/comments and tests only; no logic or tool-declaration changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 02e9ccf commit 447ede0

3 files changed

Lines changed: 130 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
8+
"github.com/github/github-mcp-server/pkg/inventory"
9+
"github.com/github/github-mcp-server/pkg/scopes"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// This file pins the deliberate, non-obvious assumptions baked into the OAuth
17+
// scope model so they stay visible to developers revisiting them. Each test
18+
// documents WHAT we assume, WHY, and the escape hatch if the assumption ever
19+
// needs to change. If one of these fails, treat it as a prompt to make a
20+
// conscious decision, not to silence the test.
21+
22+
// TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo encodes the
23+
// intentional asymmetry between the two enforcement paths for a read-only tool
24+
// whose only requirement is repo-ish access:
25+
//
26+
// - PAT filtering (CreateToolScopeFilter) is best-effort and keeps such tools
27+
// VISIBLE even when the token advertises no scopes, because they still work
28+
// against PUBLIC repositories and that access is useful.
29+
// - The OAuth scope-challenge path (scopes.ToolScopeInfo.Satisfies /
30+
// HasRequiredScopes) has NO such exception: it treats `repo` as genuinely
31+
// required and will challenge the user to grant it.
32+
//
33+
// In other words: we'd rather show-and-let-the-API-decide for PATs, but
34+
// proactively request the scope for OAuth where challenging is cheap and clean.
35+
func TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo(t *testing.T) {
36+
readOnlyRepoTool := &inventory.ServerTool{
37+
Tool: mcp.Tool{
38+
Name: "read_only_repo_tool",
39+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
40+
},
41+
RequiredScopes: []string{"repo"},
42+
}
43+
44+
// PAT side: shown even with no token scopes (public-repo access is useful).
45+
patFilter := CreateToolScopeFilter([]string{})
46+
shown, err := patFilter(context.Background(), readOnlyRepoTool)
47+
require.NoError(t, err)
48+
assert.True(t, shown, "PAT filtering should keep a read-only repo tool visible without any scope (public repo access)")
49+
50+
// OAuth side: the same requirement is NOT satisfied by an empty scope set,
51+
// so the challenge middleware would request `repo`.
52+
assert.False(t, scopes.HasRequiredScopes([]string{}, []string{"repo"}),
53+
"OAuth challenge model must treat repo as required (no public-repo exception)")
54+
info := &scopes.ToolScopeInfo{RequiredScopes: []string{"repo"}}
55+
assert.False(t, info.Satisfies(),
56+
"Satisfies must report unsatisfied for a repo tool with no granted scopes (triggers a challenge)")
57+
assert.Equal(t, []string{"repo"}, info.MissingScopes(),
58+
"the challenge should ask for exactly the missing repo scope")
59+
}
60+
61+
// TestAssumption_WorkflowScopeIsGrantableButNeverChallenged encodes that the
62+
// `workflow` scope is intentionally reachable only as an up-front grant, never
63+
// via an on-demand scope challenge:
64+
//
65+
// - It IS advertised in oauth.SupportedScopes, so a classic PAT can carry it
66+
// and the default OAuth login can request it up front.
67+
// - But NO tool declares it as a required scope, so the challenge path can
68+
// never ask for it on demand. (There is also deliberately no scopes.Workflow
69+
// constant, so a tool cannot declare it via the typed API without someone
70+
// first adding the constant.)
71+
//
72+
// This is a conscious risk-aversion choice: `workflow` grants control over
73+
// GitHub Actions workflow files, so we don't auto-request it. If a tool ever
74+
// genuinely needs it, the path is: add a scopes.Workflow constant, declare it on
75+
// the tool, and accept that the challenge will then request `workflow` (it is
76+
// already in SupportedScopes, so the mechanics work). This test will fail at
77+
// that point to force that decision to be made deliberately.
78+
func TestAssumption_WorkflowScopeIsGrantableButNeverChallenged(t *testing.T) {
79+
assert.Contains(t, ghoauth.SupportedScopes, "workflow",
80+
"workflow should remain a supported/grantable scope (PATs carry it; OAuth can request it up front)")
81+
82+
inv, err := NewInventory(translations.NullTranslationHelper).
83+
WithToolsets([]string{"all"}).
84+
Build()
85+
require.NoError(t, err)
86+
87+
for _, tool := range inv.AllTools() {
88+
assert.NotContains(t, tool.RequiredScopes, "workflow",
89+
"tool %q declares the workflow scope as required; the OAuth challenge path would then request it. "+
90+
"That is an intentional escape hatch — update this test and confirm the risk is acceptable.", tool.Tool.Name)
91+
}
92+
}
93+
94+
// TestAssumption_MultiScopeRequirementsAreTreatedAsAND encodes that when a tool
95+
// declares more than one required scope we treat them as a conjunction (ALL
96+
// required), because the declaration ([]scopes.Scope) cannot express "any of".
97+
// We cannot distinguish a genuine hard-AND from a genuine hard-OR, so we
98+
// conservatively require all of them. Hierarchy substitution still applies, so
99+
// an ancestor scope satisfies a required descendant.
100+
//
101+
// If a real OR requirement ever appears, the escape hatch is to extend the
102+
// model to OR-groups (AND across groups, OR within a group); see
103+
// scopes.HasRequiredScopes. Until then, AND is the deliberate default.
104+
func TestAssumption_MultiScopeRequirementsAreTreatedAsAND(t *testing.T) {
105+
required := []string{"repo", "read:org"}
106+
107+
// AND: one of the two scopes is not enough.
108+
assert.False(t, scopes.HasRequiredScopes([]string{"repo"}, required),
109+
"a token with only repo must NOT satisfy a {repo, read:org} tool (treated as AND, not OR)")
110+
111+
// Both scopes present satisfies the conjunction.
112+
assert.True(t, scopes.HasRequiredScopes([]string{"repo", "read:org"}, required),
113+
"a token holding both required scopes satisfies the conjunction")
114+
115+
// Hierarchy substitution still applies on top of AND: admin:org grants read:org.
116+
assert.True(t, scopes.HasRequiredScopes([]string{"repo", "admin:org"}, required),
117+
"an ancestor scope (admin:org) still satisfies a required descendant (read:org) under AND")
118+
}

pkg/github/scope_filter.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter {
6767
return func(_ context.Context, tool *inventory.ServerTool) (bool, error) {
6868
// Read-only tools requiring only repo/public_repo work on public repos without any scope.
6969
// Tools that also require a non-repo scope (e.g. {repo, read:org}) fall through to the AND check.
70+
//
71+
// Note: this public-repo exception is PAT-only. The OAuth scope-challenge
72+
// path (scopes.ToolScopeInfo.Satisfies) has no equivalent and will still
73+
// challenge for `repo`. That asymmetry is intentional; see
74+
// TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo.
7075
if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.RequiredScopes) {
7176
return true, nil
7277
}

pkg/http/oauth/oauth.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ var SupportedScopes = []string{
3737
"project",
3838
"gist",
3939
"notifications",
40+
// workflow is grantable (PATs carry it; OAuth can request it up front) but is
41+
// intentionally not declared as a required scope on any tool, so the on-demand
42+
// scope challenge never asks for it — workflow grants control over Actions
43+
// workflow files and we don't auto-request it. There is deliberately no
44+
// scopes.Workflow constant either. See
45+
// TestAssumption_WorkflowScopeIsGrantableButNeverChallenged before wiring it
46+
// to a tool.
4047
"workflow",
4148
"codespace",
4249
}

0 commit comments

Comments
 (0)