8ec62491c6
* feat(repos): add confirmed repository deletion Add a destructive delete_repository tool that requires an exact owner/repo confirmation through multi-round-trip elicitation. Gate the tool to MCP protocol 2026-07-28 and newer across local and remote transports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(inventory): generalize tool availability guards Gate protocol-restricted tools on required elicitation capabilities and enforce direct calls inside the registered handler so SDK result finalization remains intact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * feat(http): protect MRTR request state Seal repository deletion targets for self-hosted HTTP with a stable AES-256-GCM key. Hide only delete_repository when no key is configured and expose an optional sealer interface for remote integrators. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(repos): expire deletion confirmations Bind sealed repository deletion state to the immutable repository ID and a ten-minute expiry. Re-check identity before deletion so replay cannot affect a recreated repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(http): preserve tool and scope restrictions Apply static allowlists before removing unavailable tools and fail closed on invalid configured tool names. Model independent OAuth requirements as conjunctive groups so repository deletion requires both delete_repo and repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(repos): require protected confirmation state Give stdio a process-local request-state sealer and make deletion fail closed without one. Preserve legacy any-of OAuth behavior globally while documenting and enforcing delete_repository's conjunctive delete_repo and repo requirements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(oauth): request repository deletion scope Include delete_repo in the supported OAuth scope set used by stdio login, HTTP protected-resource metadata, and tool filtering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(oauth): require deletion scope opt-in Keep delete_repo in protected-resource discovery for step-up authorization while excluding it from the default stdio OAuth grant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(oauth): derive scope sets from catalog Generate protected-resource supported scopes and the lower-risk default OAuth grant from one canonical scope definition list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(scopes): own OAuth scope catalog Move supported and default OAuth scope policy into pkg/scopes so protected-resource metadata and stdio grants derive from the scope domain package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(scopes): require workflow scope opt-in Keep workflow and codespace in protected-resource discovery while excluding both from the default OAuth grant alongside delete_repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 --------- Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
279 lines
7.9 KiB
Go
279 lines
7.9 KiB
Go
package scopes
|
|
|
|
import (
|
|
"slices"
|
|
"sort"
|
|
)
|
|
|
|
// Scope represents a GitHub OAuth scope.
|
|
// These constants define all OAuth scopes used by the GitHub MCP server tools.
|
|
// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps
|
|
type Scope string
|
|
|
|
const (
|
|
// NoScope indicates no scope is required (public access).
|
|
NoScope Scope = ""
|
|
|
|
// Repo grants full control of private repositories
|
|
Repo Scope = "repo"
|
|
|
|
// PublicRepo grants access to public repositories
|
|
PublicRepo Scope = "public_repo"
|
|
|
|
// DeleteRepo grants permission to delete repositories
|
|
DeleteRepo Scope = "delete_repo"
|
|
|
|
// ReadOrg grants read-only access to organization membership, teams, and projects
|
|
ReadOrg Scope = "read:org"
|
|
|
|
// WriteOrg grants write access to organization membership and teams
|
|
WriteOrg Scope = "write:org"
|
|
|
|
// AdminOrg grants full control of organizations and teams
|
|
AdminOrg Scope = "admin:org"
|
|
|
|
// Gist grants write access to gists
|
|
Gist Scope = "gist"
|
|
|
|
// Notifications grants access to notifications
|
|
Notifications Scope = "notifications"
|
|
|
|
// ReadProject grants read-only access to projects
|
|
ReadProject Scope = "read:project"
|
|
|
|
// Project grants full control of projects
|
|
Project Scope = "project"
|
|
|
|
// SecurityEvents grants read and write access to security events
|
|
SecurityEvents Scope = "security_events"
|
|
|
|
// User grants read/write access to profile info
|
|
User Scope = "user"
|
|
|
|
// ReadUser grants read-only access to profile info
|
|
ReadUser Scope = "read:user"
|
|
|
|
// UserEmail grants read access to user email addresses
|
|
UserEmail Scope = "user:email"
|
|
|
|
// ReadPackages grants read access to packages
|
|
ReadPackages Scope = "read:packages"
|
|
|
|
// WritePackages grants write access to packages
|
|
WritePackages Scope = "write:packages"
|
|
|
|
// Workflow grants permission to update GitHub Actions workflow files
|
|
Workflow Scope = "workflow"
|
|
|
|
// Codespace grants full control of codespaces
|
|
Codespace Scope = "codespace"
|
|
)
|
|
|
|
type oauthScopeDefinition struct {
|
|
scope Scope
|
|
byDefault bool
|
|
}
|
|
|
|
var oauthScopeDefinitions = []oauthScopeDefinition{
|
|
{scope: Repo, byDefault: true},
|
|
{scope: DeleteRepo},
|
|
{scope: ReadOrg, byDefault: true},
|
|
{scope: ReadUser, byDefault: true},
|
|
{scope: UserEmail, byDefault: true},
|
|
{scope: ReadPackages, byDefault: true},
|
|
{scope: WritePackages, byDefault: true},
|
|
{scope: ReadProject, byDefault: true},
|
|
{scope: Project, byDefault: true},
|
|
{scope: Gist, byDefault: true},
|
|
{scope: Notifications, byDefault: true},
|
|
{scope: Workflow},
|
|
{scope: Codespace},
|
|
}
|
|
|
|
// SupportedOAuthScopes returns every OAuth scope the server may request.
|
|
func SupportedOAuthScopes() []string {
|
|
return oauthScopes(false)
|
|
}
|
|
|
|
// DefaultOAuthScopes returns the lower-risk scopes requested by default.
|
|
func DefaultOAuthScopes() []string {
|
|
return oauthScopes(true)
|
|
}
|
|
|
|
func oauthScopes(defaultOnly bool) []string {
|
|
result := make([]string, 0, len(oauthScopeDefinitions))
|
|
for _, definition := range oauthScopeDefinitions {
|
|
if !defaultOnly || definition.byDefault {
|
|
result = append(result, string(definition.scope))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ScopeHierarchy defines parent-child relationships between scopes.
|
|
// A parent scope implicitly grants access to all child scopes.
|
|
// For example, "repo" grants access to "public_repo" and "security_events".
|
|
var ScopeHierarchy = map[Scope][]Scope{
|
|
Repo: {PublicRepo, SecurityEvents},
|
|
AdminOrg: {WriteOrg, ReadOrg},
|
|
WriteOrg: {ReadOrg},
|
|
Project: {ReadProject},
|
|
WritePackages: {ReadPackages},
|
|
User: {ReadUser, UserEmail},
|
|
}
|
|
|
|
// ScopeSet represents a set of OAuth scopes.
|
|
type ScopeSet map[Scope]bool
|
|
|
|
// NewScopeSet creates a new ScopeSet from the given scopes.
|
|
func NewScopeSet(scopes ...Scope) ScopeSet {
|
|
set := make(ScopeSet)
|
|
for _, scope := range scopes {
|
|
set[scope] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
// ToSlice converts a ScopeSet to a slice of Scope values.
|
|
func (s ScopeSet) ToSlice() []Scope {
|
|
scopes := make([]Scope, 0, len(s))
|
|
for scope := range s {
|
|
scopes = append(scopes, scope)
|
|
}
|
|
// Sort for deterministic output
|
|
slices.Sort(scopes)
|
|
return scopes
|
|
}
|
|
|
|
// ToStringSlice converts a ScopeSet to a slice of string values.
|
|
// The returned slice is sorted for deterministic output.
|
|
func (s ScopeSet) ToStringSlice() []string {
|
|
scopes := make([]string, 0, len(s))
|
|
for scope := range s {
|
|
scopes = append(scopes, string(scope))
|
|
}
|
|
sort.Strings(scopes)
|
|
return scopes
|
|
}
|
|
|
|
// ToStringSlice converts a slice of Scopes to a slice of strings.
|
|
func ToStringSlice(scopes ...Scope) []string {
|
|
result := make([]string, len(scopes))
|
|
for i, scope := range scopes {
|
|
result[i] = string(scope)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ExpandScopes takes a list of required scopes and returns all accepted scopes
|
|
// including parent scopes from the hierarchy.
|
|
// For example, if "public_repo" is required, "repo" is also accepted since
|
|
// having the "repo" scope grants access to "public_repo".
|
|
// The returned slice is sorted for deterministic output.
|
|
func ExpandScopes(required ...Scope) []string {
|
|
if len(required) == 0 {
|
|
return nil
|
|
}
|
|
|
|
accepted := make(map[string]bool)
|
|
|
|
// Add required scopes
|
|
for _, scope := range required {
|
|
accepted[string(scope)] = true
|
|
}
|
|
|
|
// Add parent scopes that grant access to required scopes
|
|
for parent, children := range ScopeHierarchy {
|
|
for _, child := range children {
|
|
if accepted[string(child)] {
|
|
accepted[string(parent)] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to slice and sort for deterministic output
|
|
result := make([]string, 0, len(accepted))
|
|
for scope := range accepted {
|
|
result = append(result, scope)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
// ExpandScopeGroups returns one accepted-scope group for each independently
|
|
// required scope. A token must satisfy every group, while any scope within a
|
|
// group is sufficient because parent scopes grant the same permission.
|
|
func ExpandScopeGroups(required ...Scope) [][]string {
|
|
groups := make([][]string, 0, len(required))
|
|
for _, scope := range required {
|
|
groups = append(groups, ExpandScopes(scope))
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// expandScopeSet returns a set of all scopes granted by the given scopes,
|
|
// including child scopes from the hierarchy.
|
|
// For example, if "repo" is provided, the result includes "repo", "public_repo",
|
|
// and "security_events" since "repo" grants access to those child scopes.
|
|
func expandScopeSet(scopes []string) map[string]bool {
|
|
expanded := make(map[string]bool, len(scopes))
|
|
for _, scope := range scopes {
|
|
expanded[scope] = true
|
|
// Add child scopes granted by this scope
|
|
if children, ok := ScopeHierarchy[Scope(scope)]; ok {
|
|
for _, child := range children {
|
|
expanded[string(child)] = true
|
|
}
|
|
}
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement.
|
|
// A tool's acceptedScopes includes both the required scopes AND parent scopes
|
|
// that implicitly grant the required permissions (via ExpandScopes).
|
|
//
|
|
// For PAT filtering: if ANY of the acceptedScopes are granted by the token
|
|
// (directly or via scope hierarchy), the tool should be visible.
|
|
//
|
|
// Returns true if the tool should be visible to the token holder.
|
|
func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool {
|
|
// No scopes required = always allowed
|
|
if len(acceptedScopes) == 0 {
|
|
return true
|
|
}
|
|
|
|
// Expand token scopes to include child scopes they grant
|
|
grantedScopes := expandScopeSet(tokenScopes)
|
|
|
|
// Check if any accepted scope is granted by the token
|
|
for _, accepted := range acceptedScopes {
|
|
if grantedScopes[accepted] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasRequiredScopeGroups reports whether the token satisfies every independent
|
|
// required-scope group.
|
|
func HasRequiredScopeGroups(tokenScopes []string, groups [][]string) bool {
|
|
if len(groups) == 0 {
|
|
return true
|
|
}
|
|
grantedScopes := expandScopeSet(tokenScopes)
|
|
for _, group := range groups {
|
|
satisfied := false
|
|
for _, accepted := range group {
|
|
if grantedScopes[accepted] {
|
|
satisfied = true
|
|
break
|
|
}
|
|
}
|
|
if !satisfied {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|